test(mediabunny): [PKG-MB-02] establish native input and lifecycle baselines

This commit is contained in:
Harvey Zhao committed 2026-09-12 17:43:56 +08:00
1 parent 63c8938dd7
commit 03451c029b
12 files changed
+7583 -13

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { hash } from '../../refactor/scripts/releases.mjs'
import { mbHistorical } from '../helpers/mediabunny.js'
import { tracklessMp4 } from '../helpers/trackless-mp4.js'
import { expect, test } from './fixtures.js'
const implementations = (await mbHistorical()).filter(item => item.name.startsWith('published'))
const manifest = JSON.parse(fs.readFileSync(new URL('./media/hls/manifest.json', import.meta.url)))
const hls = new Map(Object.entries(manifest.files).map(([name, expected]) => {
const bytes = fs.readFileSync(new URL(`./media/hls/${name}`, import.meta.url))
assert.equal(hash(bytes), expected.sha256)
return [name, bytes]
}))
const pattern = fs.readFileSync(new URL('./media/pattern.mp4', import.meta.url))
const trackless = tracklessMp4(pattern)
for (const implementation of implementations) {
for (const input of ['webm', 'blob', 'stream', 'audio', 'hls', 'trackless']) {
test(`MediaBunny ${implementation.name}: native ${input} input and seek baseline`, async ({ page, browserName }, testInfo) => {
await page.route('**/mb-hls/**', async (route) => {
const name = new URL(route.request().url()).pathname.split('/').at(-1)
const body = hls.get(name)
await route.fulfill({ status: body ? 200 : 404, body: body || 'Missing fixture', contentType: name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t' })
})
await page.route('**/mb-trackless.mp4', route => route.fulfill({ status: 200, body: trackless, contentType: 'video/mp4' }))
await page.goto('/test/player.html?core=published')
await page.addScriptTag({ content: `(() => {
const pendingFrames = new Set();
const requestAnimationFrame = callback => {
const id = window.requestAnimationFrame(time => { pendingFrames.delete(id); callback(time); });
pendingFrames.add(id); return id;
};
const cancelAnimationFrame = id => { pendingFrames.delete(id); window.cancelAnimationFrame(id); };
window.mbPendingFrames = pendingFrames;
const module = { exports: {} }; const exports = module.exports;
${implementation.code}; window.mbFactory = module.exports.default || module.exports;
})();` })
const capabilities = await page.evaluate(() => ({ VideoDecoder: typeof VideoDecoder, AudioDecoder: typeof AudioDecoder, AudioContext: typeof AudioContext, webkitAudioContext: typeof window.webkitAudioContext }))
let outcome = 'unverified'
try {
await page.evaluate(async (input) => {
const listeners = new Map()
window.mbEvents = []
window.mbActions = []
window.mbHost = {
constructor: window.Artplayer,
option: { url: '', autoSize: false },
template: { $player: document.querySelector('.player') },
on(name, fn) {
const list = listeners.get(name) || []
list.push(fn)
listeners.set(name, list)
},
emit(name, ...args) {
window.mbEvents.push({ name, currentTime: window.mbCanvas?.currentTime, readyState: window.mbCanvas?.readyState })
for (const fn of listeners.get(name) || []) fn(...args)
},
}
const canvas = window.mbFactory({ muted: true })(window.mbHost)
window.mbCanvas = canvas
document.querySelector('.player').appendChild(canvas)
let source = input === 'webm' ? '/test/pattern.webm' : input === 'audio' ? '/test/audio-tone.m4a' : input === 'hls' ? '/mb-hls/master.m3u8' : input === 'trackless' ? '/mb-trackless.mp4' : '/test/pattern.mp4'
if (input === 'blob' || input === 'stream') {
const bytes = new Uint8Array(await (await fetch(source)).arrayBuffer())
window.mbInputBytes = bytes.length
source = input === 'blob'
? new Blob([bytes], { type: 'video/mp4' })
: new ReadableStream({
start(controller) {
for (let offset = 0; offset < bytes.length; offset += 4096) controller.enqueue(bytes.slice(offset, offset + 4096))
controller.close()
},
cancel() { window.mbStreamCancelled = true },
})
}
const load = canvas.engine.load.bind(canvas.engine)
canvas.engine.load = (source) => {
window.mbLoad = load(source)
return window.mbLoad
}
canvas.src = source
await window.mbLoad
document.querySelector('#play').onclick = () => canvas.play().catch(error => window.mbActions.push({ name: error.name, message: error.message }))
}, input)
const loaded = await page.evaluate(() => ({ error: window.mbCanvas.error, readyState: window.mbCanvas.readyState, dimensions: [window.mbCanvas.videoWidth, window.mbCanvas.videoHeight] }))
if (implementation.name === 'published-1.0.0' && ['stream', 'hls'].includes(input)) {
const messages = {
chromium: 'Failed to execute \'getReader\' on \'ReadableStream\': ReadableStreamDefaultReader constructor can only accept readable streams that are not yet locked to a reader',
firefox: 'ReadableStream.getReader: Cannot get a new reader for a readable stream already locked by another reader.',
webkit: 'ReadableStream is locked',
}
expect(loaded.error).toEqual({ code: 4, message: input === 'hls' ? 'Input has an unsupported or unrecognizable format.' : messages[browserName] })
expect(loaded.readyState).toBe(0)
expect(loaded.dimensions).toEqual([0, 0])
outcome = input === 'hls' ? 'historical-unsupported-hls-control' : 'historical-stream-lock-control'
}
else if (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') {
expect(browserName).toBe('webkit')
expect(capabilities).toEqual({ VideoDecoder: 'undefined', AudioDecoder: 'undefined', AudioContext: 'undefined', webkitAudioContext: 'undefined' })
expect(loaded.error).toEqual({ code: 4, message: 'undefined is not a constructor (evaluating \'new t\')' })
expect(loaded.readyState).toBeLessThan(4)
outcome = 'unsupported-capability-control'
}
else {
expect(loaded.error).toBeNull()
expect(loaded.readyState).toBe(4)
if (input === 'trackless') {
expect(loaded.dimensions).toEqual([0, 0])
expect(await page.evaluate(() => window.mbEvents.filter(item => item.name === 'video:loadedmetadata').length)).toBe(implementation.name === 'published-1.2.0' ? 2 : 1)
expect(await page.evaluate(() => window.mbEvents.filter(item => item.name === 'video:canplay').length)).toBe(1)
outcome = 'historical-trackless-readiness-control'
return
}
const readiness = await page.evaluate(() => window.mbEvents.map(item => item.name).filter(name => ['video:loadedmetadata', 'video:loadeddata', 'video:canplay', 'video:canplaythrough'].includes(name)))
const repeatedData = implementation.name === 'published-1.0.0' && input !== 'audio'
expect(readiness).toEqual(['video:loadedmetadata', 'video:loadeddata', ...(repeatedData ? ['video:loadeddata'] : []), 'video:canplay', 'video:canplaythrough'])
if (input === 'audio')
expect(loaded.dimensions).toEqual([0, 0])
else expect(loaded.dimensions[0]).toBeGreaterThan(0)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(0.15)
if (['audio', 'hls'].includes(input)) {
expect(await page.evaluate(() => Boolean(window.mbCanvas.engine.audio.audioSink))).toBe(true)
expect(await page.evaluate(() => window.mbCanvas.engine.audio.queuedNodes.size)).toBeGreaterThan(0)
}
await page.evaluate(() => window.mbCanvas.pause())
await page.evaluate(async () => {
const canvas = window.mbCanvas
window.mbSeekEventsStart = window.mbEvents.length
window.mbFrameClockSamples = []
const video = canvas.engine.video
if (video.videoSink) {
const times = new WeakMap()
const canvases = video.videoSink.canvases.bind(video.videoSink)
video.videoSink.canvases = (...args) => {
const iterator = canvases(...args)
const next = iterator.next.bind(iterator)
iterator.next = async (...values) => {
const result = await next(...values)
if (!result.done && result.value)
times.set(result.value.canvas, result.value.timestamp)
return result
}
return iterator
}
const draw = video.ctx.drawImage.bind(video.ctx)
video.ctx.drawImage = (...args) => {
const result = draw(...args)
if (times.has(args[0]))
window.mbFrameClockSamples.push({ frame: times.get(args[0]), audioClock: canvas.engine.audio.currentTime, paused: canvas.paused })
return result
}
}
const seek = canvas.engine.seek.bind(canvas.engine)
canvas.engine.seek = (time) => {
window.mbSeek = seek(time)
return window.mbSeek
}
window.mbSeekTarget = Math.min(0.5, canvas.duration / 2)
canvas.currentTime = window.mbSeekTarget
await window.mbSeek
})
expect(await page.evaluate(() => window.mbCanvas.seeking)).toBe(false)
expect(await page.evaluate(() => window.mbCanvas.paused)).toBe(true)
expect(await page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(0.2)
expect(await page.evaluate(() => window.mbCanvas.currentTime === window.mbSeekTarget)).toBe(true)
expect(await page.evaluate(() => window.mbEvents.slice(window.mbSeekEventsStart).map(item => item.name))).toEqual(['video:seeking', 'video:waiting', ...(repeatedData ? ['video:loadeddata'] : []), 'video:seeked'])
if (input !== 'audio') {
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.mbFrameClockSamples.filter(sample => !sample.paused).length)).toBeGreaterThan(2)
await page.evaluate(() => window.mbCanvas.pause())
}
if (input === 'hls') {
const tracks = await page.evaluate(async () => {
const canvas = window.mbCanvas
const initial = await canvas.getM3u8State()
const low = initial.levels.find(level => level.height === 90)
await canvas.switchM3u8Quality(low.id)
const quality = await canvas.getM3u8State()
const french = quality.audios.find(track => track.lang === 'fr')
await canvas.switchM3u8Audio(french.id)
const audio = await canvas.getM3u8State()
window.mbTrackTransitions = { initial: initial.currentLevel.height, quality: quality.currentLevel.height, videoMode: quality.videoMode, language: audio.currentAudio.lang, audioMode: audio.audioMode }
canvas.src = '/test/pattern.mp4'
await window.mbLoad
window.mbTrackTransitions.afterSourceSwitch = await canvas.getM3u8State()
return window.mbTrackTransitions
})
expect(tracks).toEqual({ initial: 180, quality: 90, videoMode: 'manual', language: 'fr', audioMode: 'manual', afterSourceSwitch: null })
}
expect(await page.evaluate(() => window.mbActions)).toEqual([])
outcome = 'native-input-playback-and-seek'
}
}
finally {
const state = await page.evaluate(async () => {
const canvas = window.mbCanvas
if (!canvas)
return null
const hls = canvas.getM3u8State ? await canvas.getM3u8State().catch(error => ({ error: error.message })) : null
return { events: window.mbEvents, actions: window.mbActions, dimensions: [canvas.width, canvas.height, canvas.videoWidth, canvas.videoHeight], currentTime: canvas.currentTime, duration: canvas.duration, error: canvas.error, readyState: canvas.readyState, audioState: canvas.engine.audio.audioContext?.state, frameClockSamples: window.mbFrameClockSamples, tracks: window.mbTrackTransitions, hls: hls && { levels: hls.levels?.map(({ id, height }) => ({ id, height })), audios: hls.audios?.map(({ id, lang }) => ({ id, lang })), error: hls.error }, inputBytes: window.mbInputBytes }
}).catch(error => ({ error: error.message }))
await page.evaluate(() => {
window.mbContext = window.mbCanvas?.engine.audio.audioContext
window.mbHost?.emit('destroy')
})
await expect.poll(() => page.evaluate(() => window.mbContext?.state || 'not-created')).toMatch(/^(?:closed|not-created)$/)
expect(await page.evaluate(() => window.mbPendingFrames.size)).toBe(0)
const cleanup = await page.evaluate(() => ({ audioContext: window.mbContext?.state || 'not-created', pendingFrames: window.mbPendingFrames.size, streamCancelled: window.mbStreamCancelled || false }))
await testInfo.attach('mediabunny-native-input', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), input, capabilities, outcome, state, cleanup, hlsManifest: input === 'hls' ? manifest : null, tracklessFixture: input === 'trackless' ? { parentSha256: hash(pattern), sha256: hash(trackless) } : null, scope: 'Actual published proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races.' }) })
}
})
}
}
+29
View File
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
// Derive a trackless container from the small, repository-owned MP4 fixture.
export function tracklessMp4(bytes) {
function boxes(bytes) {
const result = []
let offset = 0
while (offset < bytes.length) {
assert(offset + 8 <= bytes.length)
const size = bytes.readUInt32BE(offset)
assert(size >= 8 && offset + size <= bytes.length, 'Fixture requires bounded 32-bit boxes')
result.push({ type: bytes.toString('ascii', offset + 4, offset + 8), bytes: bytes.subarray(offset, offset + size) })
offset += size
}
return result
}
const top = boxes(bytes)
const ftyp = top.find(box => box.type === 'ftyp')
const moov = top.find(box => box.type === 'moov')
assert(ftyp && moov)
const children = boxes(moov.bytes.subarray(8))
assert(children.some(box => box.type === 'trak'))
const body = Buffer.concat(children.filter(box => box.type !== 'trak').map(box => box.bytes))
const header = Buffer.alloc(8)
header.writeUInt32BE(body.length + 8)
header.write('moov', 4)
return Buffer.concat([ftyp.bytes, header, body])
}
+159
View File
@@ -3,6 +3,32 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import { mbEnvironment, mbHistorical } from './helpers/mediabunny.js'
function deferred() {
let resolve
let reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
function timedEnvironment(implementation, option) {
const timers = new Map()
let sequence = 0
const env = mbEnvironment(implementation, {
setTimeout(fn, delay) {
const id = sequence++
timers.set(id, { fn, delay })
return id
},
clearTimeout(id) { timers.delete(id) },
})
env.eventNames.push('playing')
const canvas = env.factory(option)(env.art)
return { ...env, canvas, engine: canvas.engine, timers }
}
for (const implementation of await mbHistorical()) {
test(`MediaBunny historical ${implementation.name}: destroy still permits late load errors and scheduled events`, async () => {
const timers = new Map()
@@ -40,4 +66,137 @@ for (const implementation of await mbHistorical()) {
assert.deepEqual({ ...engine.error }, { code: 4, message: 'late controlled rejection' })
assert.equal(engine.networkState, 3)
})
test(`MediaBunny historical ${implementation.name}: pending play starts video after a newer pause`, async () => {
const { engine, emitted } = timedEnvironment(implementation)
const audio = deferred()
let starts = 0
engine.audio.play = () => audio.promise
engine.video.start = () => starts++
const playing = engine.play()
assert.equal(engine.paused, false)
engine.pause()
audio.resolve()
await playing
assert.equal(engine.paused, true)
assert.equal(starts, 1)
assert.deepEqual(emitted.map(item => item.name), ['video:pause', 'video:play', 'video:playing'])
})
test(`MediaBunny historical ${implementation.name}: pending play starts video after destroy`, async () => {
const { art, engine, emitted } = timedEnvironment(implementation)
const audio = deferred()
let starts = 0
engine.audio.play = () => audio.promise
engine.video.start = () => starts++
const playing = engine.play()
art.emit('destroy')
const cursor = emitted.length
audio.resolve()
await playing
assert.equal(starts, 1)
assert.deepEqual(emitted.slice(cursor).map(item => item.name), ['video:play', 'video:playing'])
assert.equal(engine.paused, true)
})
test(`MediaBunny historical ${implementation.name}: rejected audio play leaves false paused state and prevents retry`, async () => {
const { engine } = timedEnvironment(implementation)
const error = new Error('controlled audio resume rejection')
let attempts = 0
engine.audio.play = () => {
attempts++
return Promise.reject(error)
}
await assert.rejects(engine.play(), item => item === error)
assert.equal(engine.paused, false)
await engine.play()
assert.equal(attempts, 1)
})
test(`MediaBunny historical ${implementation.name}: pending seek emits and resumes playback after destroy`, async () => {
const { art, engine, canvas } = timedEnvironment(implementation)
const audio = deferred()
const events = []
for (const name of ['seeking', 'waiting', 'pause', 'seeked'])
canvas.events.addEventListener(name, () => events.push(name))
engine.paused = false
engine.audio.seek = () => audio.promise
engine.video.seek = () => Promise.resolve()
engine.play = () => {
events.push('resumed-after-destroy')
return Promise.resolve()
}
const seeking = engine.seek(1)
art.emit('destroy')
audio.resolve()
await seeking
assert.deepEqual(events, ['seeking', 'waiting', 'pause', 'seeked', 'resumed-after-destroy'])
assert.equal(engine.seeking, false)
})
test(`MediaBunny historical ${implementation.name}: successful load and destroy retain their timeout timer`, async () => {
const { art, engine, timers } = timedEnvironment(implementation, { loadTimeout: 1234 })
engine.performLoad = () => Promise.resolve()
await engine.load('controlled-input')
assert.equal(timers.size, 3)
assert.equal([...timers.values()].filter(item => item.delay === 1234).length, 1)
art.emit('destroy')
assert.equal(timers.size, 3)
for (const { fn } of timers.values()) fn()
timers.clear()
await Promise.resolve()
assert.equal(engine.error, null, 'Settled Promise.race observes the late timeout rejection without changing the successful result')
})
test(`MediaBunny historical ${implementation.name}: active timeout keeps its error when the underlying load rejects later`, async () => {
const { engine, timers } = timedEnvironment(implementation, { loadTimeout: 5678 })
const pending = deferred()
engine.performLoad = () => pending.promise
const loading = engine.load('controlled-input')
const timeout = [...timers.values()].find(item => item.delay === 5678)
timeout.fn()
await loading
assert.deepEqual({ ...engine.error }, { code: 4, message: 'Load timeout' })
const error = engine.error
pending.reject(new Error('late underlying rejection'))
await Promise.resolve()
await Promise.resolve()
assert.equal(engine.error, error)
timers.clear()
})
test(`MediaBunny historical ${implementation.name}: Range preflight rejects absent support, tolerates fetch errors and keeps its HLS version boundary`, async () => {
const { engine, canvas, globals } = timedEnvironment(implementation, { preflightRange: true })
const calls = []
const errors = []
const warnings = []
canvas.events.addEventListener('error', event => errors.push(event.detail.type))
globals.console = { ...console, warn: (...args) => warnings.push(args) }
globals.fetch = async (url, options) => {
calls.push({ url, options: { ...options } })
return { headers: { get: () => 'bytes' } }
}
assert.equal(await engine.video.preflight('https://example.invalid/media.mp4'), true)
assert.deepEqual(calls, [{ url: 'https://example.invalid/media.mp4', options: { method: 'HEAD' } }])
globals.fetch = async () => ({ headers: { get: () => null } })
assert.equal(await engine.video.preflight('https://example.invalid/media.mp4'), false)
assert.deepEqual(errors, ['RangeNotSupported'])
assert.equal(engine.error, null, 'Range refusal is an event, not engine.error state')
const failure = new Error('controlled HEAD rejection')
globals.fetch = async () => {
throw failure
}
assert.equal(await engine.video.preflight('https://example.invalid/media.mp4'), true)
assert.equal(warnings.length, 1)
assert.equal(warnings[0][1], failure)
let requests = 0
globals.fetch = async () => {
requests++
return { headers: { get: () => 'bytes' } }
}
assert.equal(await engine.video.preflight('https://example.invalid/media.m3u8'), true)
assert.equal(requests, implementation.name === 'published-1.0.0' ? 1 : 0)
assert.equal(await engine.video.preflight(new Blob([])), true)
assert.equal(requests, implementation.name === 'published-1.0.0' ? 1 : 0)
})
}