frontend/e2e/player-v10.test.ts
Rama master · réplica de lectura del repositorio original.
/* Video.js 10 官方控件与媒体引擎在演示短片上的播放链。 */
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join } from 'node:path';
import type { Browser } from 'playwright-core';
import { launch, layout, requiredEnv, visit, VIEWPORTS } from './harness.ts';
import { DETAIL, openFollowFeed } from './follow-fixture.ts';
describe('Video.js 10 播放链', () => {
let browser: Browser;
let fixture: string;
let files: Map<string, Buffer>;
before(async () => {
fixture = realpathSync(mkdtempSync(join(tmpdir(), 'peach-v10-hls-')));
execFileSync(requiredEnv('PEACH_E2E_FFMPEG'), ['-hide_banner', '-loglevel', 'error', '-f', 'lavfi',
'-i', 'testsrc2=size=160x90:rate=15', '-t', '3', '-an', '-c:v', 'libx264', '-pix_fmt', 'yuv420p',
'-f', 'hls', '-hls_time', '1', '-hls_list_size', '0', '-hls_segment_filename', join(fixture, 'part-%d.ts'), join(fixture, 'video.m3u8')]);
execFileSync(requiredEnv('PEACH_E2E_FFMPEG'), ['-hide_banner', '-loglevel', 'error', '-f', 'lavfi',
'-i', 'testsrc2=size=160x90:rate=15', '-t', '3', '-an', '-c:v', 'libx264', '-pix_fmt', 'yuv420p',
'-f', 'dash', 'video.mpd'], { cwd: fixture });
files = new Map(readdirSync(fixture).map(name => [name, readFileSync(join(fixture, name))]));
assert.ok(files.has('init-stream0.m4s'), [...files.keys()].join(', '));
browser = await launch();
});
after(async () => { await browser?.close(); if (fixture) rmSync(fixture, { recursive: true, force: true }) });
it('详情自动播放请求读取 MP4 并解码', { timeout: 60_000 }, async () => {
const opened = await visit(browser, `/assets/${requiredEnv('PEACH_E2E_ITEM')}`, VIEWPORTS[0]!);
const { page, problems } = opened;
try {
await page.addInitScript(() => {
localStorage.setItem('peach.settings.v1', JSON.stringify({ detailAutoplay: true }));
const play = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function () { this.muted = true; return play.call(this) };
});
const stream = page.waitForResponse(reply => new URL(reply.url()).pathname === '/stream');
await page.reload();
await page.waitForFunction(() => {
const video = document.querySelector<HTMLVideoElement>('#stage video.vjs-tech');
return !!video && video.currentTime > .15 && video.videoWidth > 0;
}, undefined, { timeout: 15_000 });
assert.ok([200, 206].includes((await stream).status()));
assert.deepEqual(problems, []);
} finally { await opened.close() }
});
it('媒体解码失败保存本机浏览器上下文与原生错误', { timeout: 60_000 }, async () => {
const opened = await visit(browser, `/assets/${requiredEnv('PEACH_E2E_ITEM')}`, VIEWPORTS[0]!);
const { page } = opened;
try {
await page.route('**/stream?**', route => route.fulfill({ body: 'invalid video', contentType: 'video/mp4' }));
const recorded = page.waitForResponse(reply => new URL(reply.url()).pathname === '/api/diagnostics/client-error'
&& reply.request().postDataJSON()?.kind === 'media');
await page.reload();
const response = await recorded;
assert.equal(response.status(), 200);
const evidence = response.request().postDataJSON();
assert.ok([3, 4].includes(evidence.playback.code));
assert.equal(evidence.playback.source_route, '/stream');
assert.ok('h264' in evidence.browser.native_codecs);
assert.match(evidence.browser.user_agent, /Chrome/);
assert.equal(JSON.stringify(evidence).includes('session='), false);
} finally { await opened.close() }
});
it('关注片源的 DASH MIME 挂载官方引擎并解码', { timeout: 60_000 }, async () => {
const opened = await openFollowFeed(browser, `/posts/${DETAIL.collection}`, VIEWPORTS[0]!, { ready: '#stage [data-follow-detail-side]' });
const { page, problems } = opened;
try {
const response = page.waitForResponse(reply => {
const url = new URL(reply.url()); return url.pathname === '/api/follow' && url.searchParams.get('item') === String(DETAIL.collection);
});
await page.reload();
const payload = await (await response).json();
payload.groups[0].primary.media_type = 'application/dash+xml'; payload.groups[0].primary.duration = 3;
await page.route(url => url.pathname === '/api/follow' && url.searchParams.has('item'), route => route.fulfill({ json: payload }));
await page.route('**/api/follow/play', route => route.fulfill({ json: { ok: true } }));
await page.route('**/api/follow/activity', route => {
assert.equal(route.request().postDataJSON().item, DETAIL.collection);
return route.fulfill({ json: { ok: true, item: DETAIL.collection } });
});
await page.route('**/follow-stream**', route => route.fulfill({ body: files.get('video.mpd')!, contentType: 'application/dash+xml' }));
await page.route(/\/(?:init|chunk)-stream\d+(?:-\d+)?\.m4s(?:\?|$)/, route =>
route.fulfill({ body: files.get(basename(new URL(route.request().url()).pathname))!, contentType: 'video/mp4' }));
await page.reload();
await page.locator('#stage dash-video > video.vjs-tech').waitFor();
await page.locator('#stage video.vjs-tech').evaluate((node: HTMLVideoElement) => { node.muted = true });
const activity = page.waitForResponse(reply => new URL(reply.url()).pathname === '/api/follow/activity');
await page.locator('#stage media-play-button').click();
await page.waitForFunction(() => {
const video = document.querySelector<HTMLVideoElement>('#stage video.vjs-tech')!;
return video.currentTime > .8 && video.videoWidth === 160;
}, undefined, { timeout: 15_000 }).catch(async error => {
const state = await page.locator('#stage video.vjs-tech').evaluate((node: HTMLVideoElement) => ({
paused: node.paused, time: node.currentTime, width: node.videoWidth, ready: node.readyState, src: node.currentSrc,
error: node.error && { code: node.error.code, message: node.error.message },
}));
throw new Error(`${String(error)} ${JSON.stringify({ state, problems })}`);
});
await page.locator('#stage video.vjs-tech').evaluate((node: HTMLVideoElement) => node.pause());
assert.equal((await activity).status(), 200);
await page.locator('#closeStage').click();
await page.locator('.video-js').waitFor({ state: 'detached' });
assert.deepEqual(problems, []);
} finally { await opened.close() }
});
for (const viewport of VIEWPORTS) {
it(`${viewport.name}:播放、暂停、倍速、进度、字幕与卸载`, { timeout: 60_000 }, async () => {
const opened = await visit(browser, `/assets/${requiredEnv('PEACH_E2E_ITEM')}`, viewport);
const { page, problems } = opened;
try {
await page.locator('#stage .video-js .vjs-peach-bezel').waitFor({ state: 'attached' });
const media = page.locator('#stage hlsjs-video');
assert.equal(await media.count(), 1);
if (!viewport.mobile) {
await page.locator('#stage .vjs-volume-panel').hover();
assert.match(await page.locator('#stage media-slider-value').textContent() || '', /\d+%/);
}
await page.locator('#stage video.vjs-tech').evaluate((node: HTMLVideoElement) => { node.muted = true });
await page.locator('#stage media-play-button').click();
await page.waitForFunction(() => {
const video = document.querySelector<HTMLVideoElement>('#stage video.vjs-tech');
return !!video && video.currentTime > .15 && video.videoWidth > 0;
}, undefined, { timeout: 15_000 });
await page.locator('#stage media-play-button').click();
assert.equal(await page.locator('#stage video.vjs-tech').evaluate((node: HTMLVideoElement) => node.paused), true);
await page.locator('#stage [data-player-quality] > button').click();
await page.locator('#stage [data-player-speed]').click();
await page.locator('#stage [data-player-speed-option="1.5"]').click();
assert.equal(await page.locator('#stage video.vjs-tech').evaluate((node: HTMLVideoElement) => node.playbackRate), 1.5);
await page.locator('#stage [data-player-quality] > button').click();
await page.locator('#stage media-time-slider media-slider-thumb').focus();
await page.keyboard.press('Home');
await page.waitForFunction(() => document.querySelector<HTMLVideoElement>('#stage video.vjs-tech')!.currentTime < .1);
await page.locator('#stage video.vjs-tech').evaluate((node: HTMLVideoElement) => {
const track = node.addTextTrack('subtitles', '测试字幕', 'zh'); track.mode = 'disabled';
track.addCue(new VTTCue(0, 2, '字幕回归'));
});
await page.locator('#stage [data-player-quality] > button').click();
await page.locator('#stage [data-player-subtitles]').click();
await page.getByRole('menuitemradio', { name: '测试字幕', exact: true }).click();
assert.equal(await page.locator('#stage video.vjs-tech').evaluate((node: HTMLVideoElement) =>
[...node.textTracks].find(track => track.label === '测试字幕')?.mode), 'showing');
await page.route('**/v10-hls/**', async route => {
const name = basename(new URL(route.request().url()).pathname);
assert.match(name, /^(video\.m3u8|part-\d+\.ts)$/);
await route.fulfill({ body: files.get(name)!,
contentType: name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t' });
});
await media.evaluate(node => {
(node as unknown as { source: { src: string; type: string } }).source = {
src: '/v10-hls/video.m3u8', type: 'application/vnd.apple.mpegurl',
};
});
await page.locator('#stage video.vjs-tech').evaluate((node: HTMLVideoElement) => { node.currentTime = 0; return node.play() });
await page.waitForFunction(() => {
const video = document.querySelector<HTMLVideoElement>('#stage video.vjs-tech')!;
return video.currentTime > .2 && video.videoWidth === 160;
}, undefined, { timeout: 15_000 });
await page.locator('#stage video.vjs-tech').evaluate((node: HTMLVideoElement) => node.pause());
const geometry = await layout(page);
assert.ok(geometry.scrollWidth <= geometry.viewportWidth);
assert.deepEqual(geometry.offenders, []);
await page.locator('#closeStage').click();
await page.locator('.video-js').waitFor({ state: 'detached' });
assert.deepEqual(problems, []);
} finally { await opened.close() }
});
}
});