test(browser): ENG-05 add real playback projects and artifact mapping

This commit is contained in:
Harvey Zhao committed 2026-09-10 22:25:13 +08:00
1 parent 458bc3215d
commit 7318d33822
20 files changed
+1241 -9

No files matched your search

+6
View File
@@ -19,3 +19,9 @@ Use the pinned Node/Yarn toolchain from `../refactor/toolchain-setup.md`.
Set `ARTPLAYER_TEST_CORE` to a built `.js`, `.legacy.js` or `.mjs` core file to add a candidate to the same public-contract run, then execute `node --test test/public-behavior.test.js`. An invalid path/export fails the run. This is a narrow event contract check, not isolated package installation or complete API compatibility; ENG-07 owns tarball consumption.
When a production module moves, update its loader mapping and its maintenance documentation, preserving the behavioral assertions. New contracts belong in contracts/; test-specific controlled state belongs in helpers/. Do not modify frozen refactor/fixtures or baseline captures just to pass changed behavior. Record historical defects and candidate fixes separately.
# Real browser tests
See [browser/README.md](browser/README.md) for `yarn test:browser`, browser installation,
published/current combinations, media fixtures, candidate mapping and failure reports.
These tests are separate from the fast Node suite and run in the Browser playback smoke CI job.
+84
View File
@@ -0,0 +1,84 @@
# Browser regression entry
Use the pinned Node and Yarn versions, then run:
```sh
yarn install --frozen-lockfile
yarn test:browser:install
yarn test:browser
yarn test:browser --project=chromium
yarn test:browser:report
```
The three projects run actual Chromium, Firefox and WebKit. They use isolated browser contexts,
no retries, a loopback server on 8084, and no existing dev server. Set `ARTPLAYER_BROWSER_PORT`
to use another free port. The server builds core and chapter from current JS/TS entries in memory;
it does not modify dist or docs outputs. Restart the test command after source changes.
`playback.spec.js` tests published/current core with current chapter, real decoded frame colors,
media time advancing, pause, seek, source change to the existing docs video, destroy and real
HTTP failure. These are initial smoke contracts, not full chapter or lifecycle coverage.
`fixtures.js` captures browser version, actual scripts/media hashes, page errors, console errors,
failed requests and final state. Tests attach decoded pixels and server-side media requests too.
Windows WebKit media requests are not reliably visible to Playwright's page network events;
use the loopback request log to prove that media was fetched. Its `videoWidth` can reflect layout
size, so intrinsic-size compatibility is a separate test, not the playback-success predicate.
Reports, failure screenshots and traces are under `refactor/.cache/browser/`. They contain local
test URLs and paths. JSON and HTML are regenerated each run; archive them before another run
if evidence must be retained. A successful HTTP page fetch does not validate its editor UI.
## Files and routes
- `server.mjs`: current/published artifact provenance, docs serving, Range and controlled failures.
- `player.html`: lightweight page, real ArtPlayer constructor and user-gesture play/pause buttons.
- `fixtures.js`: per-test error and provenance attachment; contexts/storage are Playwright-owned.
- `playback.spec.js`: behavior assertions shared by browser and core version.
- `media/`: committed synthetic fixtures. CI needs no FFmpeg to use them.
`/test/manifest.json` identifies every mapped core/chapter artifact and synthetic media file by
SHA-256. `/published/<package>.js` verifies the frozen npm archive/member from BASE-01.
`/candidate/<package>.js` maps the current build. Existing docs `/compiled/<package>.js` and
`/uncompiled/<package>/index.js` are aliases for that candidate. Other compiled/uncompiled paths
fail explicitly; add a verified mapping before testing another package, rather than serving stale
build output. Other docs pages, examples and samples use their existing paths without source edits.
`/test/pattern.mp4` and `.webm` support GET/HEAD and single byte ranges (including suffix ranges).
Invalid ranges return 416. `/test/fail.mp4` always returns 503. Add a unique `?case=<test-id>` to
media URLs and inspect `/test/requests.json?case=<test-id>` for actual server requests. The log is
bounded and server-local; it is not a production endpoint or a substitute for decoded frames.
To test extracted candidate UMD files, set `ARTPLAYER_BROWSER_ARTIFACTS` to a JSON file:
```json
{
"artplayer": "./extracted/artplayer/dist/artplayer.js",
"artplayer-plugin-chapter": "./extracted/chapter/dist/artplayer-plugin-chapter.js"
}
```
Paths resolve relative to that JSON. Both keys are required; missing files abort startup. No source
fallback is allowed in this mode. ENG-07 owns tarball creation and broader consumer checks.
For manual docs interaction, run `node test/browser/server.mjs` and open the existing editor URL
on 8084 with chapter's libs/example parameters. EX-03 still owns the full Monaco Run/TS/storage
workflow, all examples and external SDKs. The lightweight page does not complete that task.
## Synthetic media provenance
Generated locally from FFmpeg's `testsrc2`, without external footage or audio, using
`ffmpeg version 2025-11-12-git-6cdd2cbe32-full_build-www.gyan.dev`:
```sh
ffmpeg -f lavfi -i testsrc2=size=320x180:rate=24 -t 8 -an -c:v libx264 -profile:v baseline -pix_fmt yuv420p -movflags +faststart test/browser/media/pattern.mp4
ffmpeg -f lavfi -i testsrc2=size=320x180:rate=24 -t 8 -an -c:v libvpx -b:v 160k test/browser/media/pattern.webm
```
MP4 SHA-256: `0ad1d7ad286aec6e0fa9e27fc1e487e7277df2e603e30040fb942365763d0233`.
WebM SHA-256: `9d3c2b3dec9581a599de2d6732992ef7846790e0d1368e6e131bd339b39e984f`.
MP4 is the initial playback contract; WebM is available for subsequent codec-specific cases.
Do not regenerate fixtures silently: update hashes and decoded-frame checks together.
The docs `assets/sample/video.mp4` remains an existing sample with its original provenance limits
tracked by BASE-MEDIA-01. The new synthetic fixtures do not resolve rights for existing media.
Browser binaries/codec support vary by OS; these projects do not replace Safari/iOS/Android
device checks. See the [official browser guidance](https://playwright.dev/docs/browsers).
+33
View File
@@ -0,0 +1,33 @@
import process from 'node:process'
import { test as base, expect } from '@playwright/test'
export const test = base.extend({
diagnostics: [async ({ page, browser, request }, use, testInfo) => {
const errors = []
const consoleErrors = []
const failedRequests = []
page.on('pageerror', error => errors.push(error.message))
page.on('console', (message) => {
if (message.type() === 'error')
consoleErrors.push(message.text())
})
page.on('requestfailed', request => failedRequests.push({ url: request.url(), resourceType: request.resourceType(), failure: request.failure() }))
await use({ errors, consoleErrors, failedRequests })
const manifest = await (await request.get('/test/manifest.json')).json()
const state = page.isClosed()
? null
: await page.evaluate(() => {
const video = document.querySelector('video')
return {
url: location.href,
userAgent: navigator.userAgent,
events: window.events,
media: video ? { source: video.currentSrc, width: video.videoWidth, height: video.videoHeight, duration: video.duration, currentTime: video.currentTime, error: video.error?.code } : null,
}
}).catch(() => null)
await testInfo.attach('browser-evidence', { contentType: 'application/json', body: JSON.stringify({ browser: browser.version(), platform: process.platform, manifest, state, errors, consoleErrors, failedRequests }, null, 2) })
expect(errors, 'Unhandled browser errors').toEqual([])
}, { auto: true }],
})
export { expect }
Binary file not shown.
Binary file not shown.
+100
View File
@@ -0,0 +1,100 @@
import { expect, test } from './fixtures.js'
for (const core of ['published', 'candidate']) {
test(`${core}: real playback, pause, seek, switch and destroy`, async ({ page, request, diagnostics }, testInfo) => {
const caseId = encodeURIComponent(testInfo.testId)
await page.goto(`/test/player.html?core=${core}`)
await page.evaluate(url => window.createPlayer(url, true), `/test/pattern.mp4?case=${caseId}`)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
expect(await page.locator('video').evaluate(video => video.duration)).toBeCloseTo(8, 1)
await expect(page.locator('.art-chapter')).toHaveCount(1)
await page.locator('#play').click()
await expect.poll(() => page.locator('video').evaluate(video => video.currentTime)).toBeGreaterThan(0.25)
// Read decoded frame colors, independent of WebKit Windows' layout-sized videoWidth.
const pixels = await page.locator('video').evaluate((video) => {
const canvas = document.createElement('canvas')
canvas.width = 80
canvas.height = 45
const ctx = canvas.getContext('2d')
ctx.drawImage(video, 0, 0, 80, 45)
return [30, 45, 75].map(x => [...ctx.getImageData(x, 2, 1, 1).data])
})
expect(pixels[0][0]).toBeGreaterThan(180)
expect(pixels[0][1]).toBeGreaterThan(180)
expect(pixels[0][2]).toBeLessThan(80)
expect(pixels[1][0]).toBeLessThan(80)
expect(pixels[1][2]).toBeGreaterThan(180)
expect(pixels[2][1]).toBeGreaterThan(180)
expect(pixels[2][2]).toBeGreaterThan(180)
const mediaRequests = await (await request.get(`/test/requests.json?case=${caseId}`)).json()
expect(mediaRequests.some(item => item.path === '/test/pattern.mp4')).toBe(true)
await testInfo.attach('decoded-frame', { body: JSON.stringify({ pixels, mediaRequests }), contentType: 'application/json' })
await page.locator('#pause').click()
await expect.poll(() => page.locator('video').evaluate(video => video.paused)).toBe(true)
const pausedTime = await page.locator('video').evaluate(video => video.currentTime)
// A bounded observation interval checks that pause stops the media clock.
await page.waitForTimeout(200)
expect(await page.locator('video').evaluate(video => video.currentTime)).toBeCloseTo(pausedTime, 2)
await page.evaluate(() => {
window.art.seek = 3
})
await expect.poll(() => page.evaluate(() => window.events.includes('video:seeked'))).toBe(true)
expect(await page.locator('video').evaluate(video => video.currentTime)).toBeCloseTo(3, 1)
// Reuse a real docs sample, independently of the short synthetic fixture.
await page.evaluate(url => window.art.switchUrl(url), `/assets/sample/video.mp4?case=${caseId}`)
await expect.poll(() => page.locator('video').evaluate(video => video.duration)).toBeGreaterThan(90)
await page.locator('#play').click()
await expect.poll(() => page.locator('video').evaluate(video => video.currentTime)).toBeGreaterThan(0.25)
const events = await page.evaluate(() => window.events)
expect(events).toContain('video:loadedmetadata')
expect(events).toContain('ready')
expect(events.indexOf('video:loadedmetadata')).toBeLessThan(events.indexOf('ready'))
expect(events).toContain('video:playing')
expect(events).not.toContain('video:error')
await page.evaluate(() => window.art.destroy())
await expect(page.locator('.player')).toBeEmpty()
expect(await page.evaluate(() => window.Artplayer.instances.length)).toBe(0)
expect(diagnostics.consoleErrors).toEqual([])
// Chromium cancels media range requests as buffering, seeking and source ownership change.
// Only exact cancellations of this test's two media URLs are expected; retain all in evidence.
const unexpectedRequests = diagnostics.failedRequests.filter((item) => {
const url = new URL(item.url)
return !(item.resourceType === 'media' && item.failure?.errorText === 'net::ERR_ABORTED'
&& url.origin === new URL(page.url()).origin
&& url.searchParams.get('case') === testInfo.testId
&& ['/test/pattern.mp4', '/assets/sample/video.mp4'].includes(url.pathname))
})
expect(unexpectedRequests).toEqual([])
})
}
test('controlled HTTP media failure emits a real video error', async ({ page, request }, testInfo) => {
const caseId = encodeURIComponent(testInfo.testId)
await page.goto('/test/player.html')
await page.evaluate(url => window.createPlayer(url), `/test/fail.mp4?case=${caseId}`)
await expect.poll(() => page.evaluate(() => window.events.includes('video:error'))).toBe(true)
const requests = await (await request.get(`/test/requests.json?case=${caseId}`)).json()
expect(requests.some(item => item.path === '/test/fail.mp4')).toBe(true)
expect((await request.get('/test/fail.mp4')).status()).toBe(503)
expect(await page.locator('video').evaluate(video => video.error.code)).toBeGreaterThan(0)
await page.evaluate(() => window.art.destroy())
await expect(page.locator('.player')).toBeEmpty()
})
test('Range, media identity and docs candidate routes', async ({ request }) => {
const manifest = await (await request.get('/test/manifest.json')).json()
const candidate = await request.get('/candidate/artplayer.js')
const alias = await request.get('/uncompiled/artplayer/index.js')
expect(await alias.body()).toEqual(await candidate.body())
expect(manifest.resources['/uncompiled/artplayer/index.js'].aliasOf).toBe('/candidate/artplayer.js')
const full = await (await request.get('/test/pattern.mp4')).body()
for (const [range, start, end] of [['bytes=0-31', 0, 32], ['bytes=-16', full.length - 16, full.length], ['bytes=32-', 32, full.length]]) {
const response = await request.get('/test/pattern.mp4', { headers: { Range: range } })
expect(response.status()).toBe(206)
expect(await response.body()).toEqual(full.subarray(start, end))
}
expect((await request.get('/test/pattern.mp4', { headers: { Range: 'bytes=999999999-' } })).status()).toBe(416)
expect((await request.get('/compiled/missing.js')).status()).toBe(404)
expect((await request.get('/mobile.html')).status()).toBe(200)
expect((await request.get('/?libs=./uncompiled/artplayer-plugin-chapter/index.js&example=chapter')).status()).toBe(200)
})
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ArtPlayer browser contracts</title>
<style>
.player {
width: 640px;
height: 360px;
}
</style>
<script src="/__CORE__/artplayer.js"></script>
<script src="/__CHAPTER__/artplayer-plugin-chapter.js"></script>
</head>
<body>
<div class="player"></div>
<button id="play">Play</button>
<button id="pause">Pause</button>
<script>
window.createPlayer = (url, chapter = false) => {
window.events = []
window.art = new Artplayer({
container: '.player',
url,
muted: true,
plugins: chapter
? [artplayerPluginChapter({ chapters: [{ start: 0, end: Infinity, title: 'Pattern' }] })]
: [],
})
for (const name of [
'video:loadedmetadata',
'ready',
'video:playing',
'video:pause',
'video:seeked',
'video:error',
'destroy',
]) {
art.on(name, () => events.push(name))
}
document.querySelector('#play').onclick = () => art.play()
document.querySelector('#pause').onclick = () => art.pause()
}
</script>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
/* eslint-disable antfu/no-top-level-await -- Build test resources before accepting requests. */
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { build } from 'vite'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { getEntryFile } from '../../scripts/projects.js'
import { getGlobalName, getViteBuildConfig } from '../../scripts/utils.js'
const workspace = fileURLToPath(new URL('../../', import.meta.url))
const port = Number(process.env.ARTPLAYER_BROWSER_PORT || 8084)
assert(Number.isInteger(port) && port > 0 && port < 65536, 'Invalid browser test port')
const files = new Map()
const requests = new Map()
const manifest = { kind: 'artplayer-browser-test', resources: {} }
function add(route, bytes, source) {
files.set(route, bytes)
manifest.resources[route] = { source, sha256: hash(bytes), bytes: bytes.length }
}
// An explicit artifact map takes precedence over source builds and never falls back.
const mapFile = process.env.ARTPLAYER_BROWSER_ARTIFACTS
const overrides = mapFile ? JSON.parse(fs.readFileSync(mapFile, 'utf8')) : {}
for (const name of ['artplayer', 'artplayer-plugin-chapter']) {
const route = `/candidate/${name}.js`
if (mapFile) {
assert(Object.hasOwn(overrides, name), `Missing candidate artifact: ${name}`)
const filename = path.resolve(path.dirname(path.resolve(mapFile)), overrides[name])
add(route, fs.readFileSync(filename), { kind: 'artifact', file: filename })
}
else {
const project = path.join(workspace, 'packages', name)
const config = getViteBuildConfig({ entry: getEntryFile(project), name: getGlobalName(name), format: 'umd', fileName: `${name}.js`, minify: false })
config.build.write = false
const result = await build({ root: project, ...config })
const chunks = (Array.isArray(result) ? result : [result]).flatMap(item => item.output).filter(item => item.type === 'chunk')
assert.equal(chunks.length, 1, 'Browser source loader requires one self-contained chunk')
add(route, Buffer.from(chunks[0].code), { kind: 'workspace-build', entry: path.relative(workspace, getEntryFile(project)) })
}
for (const alias of [`/compiled/${name}.js`, `/uncompiled/${name}/index.js`]) {
files.set(alias, files.get(route))
manifest.resources[alias] = { ...manifest.resources[route], aliasOf: route }
}
}
const releases = JSON.parse(fs.readFileSync(path.join(workspace, 'refactor/baselines/releases.json'), 'utf8')).releases
for (const release of releases) {
const member = `package/${release.manifest.main.replace(/^\.\//, '')}`
const bytes = readMember(await ensureArchive(release), member)
assert.equal(hash(bytes), release.files[member], 'Published browser artifact differs from frozen baseline')
add(`/published/${release.name}.js`, bytes, { kind: 'npm-release', version: release.version, integrity: release.integrity, member })
}
for (const extension of ['mp4', 'webm']) {
const name = `test/browser/media/pattern.${extension}`
add(`/test/pattern.${extension}`, fs.readFileSync(path.join(workspace, name)), { kind: 'generated-media', file: name })
}
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }
function send(req, res, bytes, filename) {
res.setHeader('Content-Type', mime[path.extname(filename)] || 'application/octet-stream')
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Accept-Ranges', 'bytes')
if (req.headers.range) {
const match = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range)
const start = match?.[1] ? Number(match[1]) : Math.max(0, bytes.length - Number(match?.[2]))
const end = match?.[1] && match[2] ? Math.min(bytes.length - 1, Number(match[2])) : bytes.length - 1
if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(start) || start > end || start >= bytes.length) {
res.writeHead(416, { 'Content-Range': `bytes */${bytes.length}` }).end()
return
}
res.statusCode = 206
res.setHeader('Content-Range', `bytes ${start}-${end}/${bytes.length}`)
bytes = bytes.subarray(start, end + 1)
}
res.setHeader('Content-Length', bytes.length)
res.end(req.method === 'HEAD' ? undefined : bytes)
}
add('/assets/sample/video.mp4', fs.readFileSync(path.join(workspace, 'docs/assets/sample/video.mp4')), { kind: 'docs-media', file: 'docs/assets/sample/video.mp4' })
const server = http.createServer((req, res) => {
try {
assert(['GET', 'HEAD'].includes(req.method), 'Unsupported method')
const url = new URL(req.url, `http://127.0.0.1:${port}`)
const caseId = url.searchParams.get('case')
if (url.pathname === '/test/requests.json') {
send(req, res, Buffer.from(JSON.stringify(requests.get(caseId) || [])), 'requests.json')
return
}
if (caseId && /\.(?:mp4|webm)$/.test(url.pathname)) {
assert(caseId.length <= 200 && (requests.has(caseId) || requests.size < 10000), 'Test request log limit')
const records = requests.get(caseId) || []
if (records.length < 100)
records.push({ path: url.pathname, range: req.headers.range || null, method: req.method })
requests.set(caseId, records)
}
if (url.pathname === '/favicon.ico') {
res.writeHead(204).end()
return
}
if (url.pathname === '/test/manifest.json') {
send(req, res, Buffer.from(JSON.stringify(manifest)), 'manifest.json')
return
}
if (url.pathname === '/test/fail.mp4') {
res.writeHead(503, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }).end('Intentional media failure')
return
}
if (files.has(url.pathname)) {
send(req, res, files.get(url.pathname), url.pathname)
return
}
if (url.pathname === '/test/player.html') {
const core = url.searchParams.get('core') || 'candidate'
const chapter = url.searchParams.get('chapter') || 'candidate'
assert(['candidate', 'published'].includes(core) && ['candidate', 'published'].includes(chapter), 'Invalid test combination')
const html = fs.readFileSync(path.join(workspace, 'test/browser/player.html'), 'utf8')
.replace('__CORE__', core)
.replace('__CHAPTER__', chapter)
send(req, res, Buffer.from(html), 'player.html')
return
}
assert(!/^\/(?:test|candidate|published|compiled|uncompiled)\//.test(url.pathname), 'Unmapped test artifact')
const root = fs.realpathSync(path.join(workspace, 'docs'))
let filename = path.resolve(root, `.${decodeURIComponent(url.pathname)}`)
if (fs.statSync(filename).isDirectory())
filename = path.join(filename, 'index.html')
const relative = path.relative(root, fs.realpathSync(filename))
assert(relative && !relative.startsWith('..') && !path.isAbsolute(relative), 'Invalid docs path')
send(req, res, fs.readFileSync(filename), filename)
}
catch (error) {
res.writeHead(404, { 'Content-Type': 'text/plain' }).end(error.message)
}
})
server.listen(port, '127.0.0.1', () => process.stdout.write(`Browser test server: http://127.0.0.1:${port}\n`))
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close())