refactor(mediabunny): [PKG-MB-03] type inputs and cancel owned loads

This commit is contained in:
Harvey Zhao committed 2026-09-12 18:08:37 +08:00
1 parent 03451c029b
commit adba8a3d12
35 files changed
+11260 -3327

No files matched your search

+6
View File
@@ -2,6 +2,12 @@
Use the pinned Node/Yarn toolchain from `../refactor/toolchain-setup.md`.
`yarn test:mediabunny` runs historical lifecycle observations, candidate load cancellation,
and real SDK input parsing/track contracts; all are in `test:unit`. Candidate failures
can be reproduced against the frozen main with `ARTPLAYER_MB_BASELINE=1`. This does not
replace native playback or installed consumers. See `refactor/mb-validation.md` and the
proxy package's `ARCHITECTURE.md` for browser and artifact selection.
`yarn test:ads` runs source, verified npm 1.0.6 and the frozen unpublished 2.1.0
Ads bundle against controlled clock/host contracts. It is included in `test:unit`.
The helper uses the real option validator but does not simulate media decoding or layout.
+12 -6
View File
@@ -1,11 +1,11 @@
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 { mbBrowserImplementations } 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 implementations = await mbBrowserImplementations()
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))
@@ -98,7 +98,13 @@ for (const implementation of implementations) {
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\')' })
if (implementation.name.startsWith('candidate')) {
expect(loaded.error.code).toBe(4)
expect(loaded.error.message).toMatch(/^undefined is not a constructor \(evaluating 'new [\w$]+(?:\(\))?'\)$/)
}
else {
expect(loaded.error).toEqual({ code: 4, message: 'undefined is not a constructor (evaluating \'new t\')' })
}
expect(loaded.readyState).toBeLessThan(4)
outcome = 'unsupported-capability-control'
}
@@ -107,9 +113,9 @@ for (const implementation of implementations) {
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:loadedmetadata').length)).toBe(implementation.name === 'published-1.0.0' ? 1 : 2)
expect(await page.evaluate(() => window.mbEvents.filter(item => item.name === 'video:canplay').length)).toBe(1)
outcome = 'historical-trackless-readiness-control'
outcome = implementation.name.startsWith('candidate') ? 'candidate-trackless-readiness-gap' : '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)))
@@ -208,7 +214,7 @@ for (const implementation of implementations) {
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.' }) })
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: 'Identified 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. Candidate trackless readiness remains MB-READY-01, not release acceptance.' }) })
}
})
}
+66
View File
@@ -0,0 +1,66 @@
import { hash } from '../../refactor/scripts/releases.mjs'
import { mbCandidate } from '../helpers/mediabunny.js'
import { expect, test } from './fixtures.js'
const implementation = await mbCandidate()
for (const ending of ['destroy', 'source', 'timeout']) {
test(`MediaBunny ${implementation.name}: native pending stream cancelled by ${ending}`, async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=published')
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports;
${implementation.code}; window.mbFactory = module.exports.default || module.exports; })();` })
await page.evaluate(() => {
const listeners = new Map()
window.mbCancelled = []
window.mbEvents = []
window.mbHost = {
constructor: window.Artplayer,
option: { url: '', autoSize: false },
template: { $player: document.querySelector('.player') },
on(name, callback) {
const list = listeners.get(name) || []
list.push(callback)
listeners.set(name, list)
},
emit(name, ...args) {
window.mbEvents.push(name)
for (const callback of listeners.get(name) || []) callback(...args)
},
}
window.mbCanvas = window.mbFactory({ loadTimeout: 1000 })(window.mbHost)
window.mbStream = new ReadableStream({ cancel() {
window.mbCancelled.push('old')
} })
window.mbSettled = false
window.mbLoad = window.mbCanvas.engine.load(window.mbStream).then(() => window.mbSettled = true)
})
try {
await expect.poll(() => page.evaluate(() => window.mbStream.locked)).toBe(true)
expect(await page.evaluate(() => window.mbSettled)).toBe(false)
await page.evaluate((ending) => {
if (ending === 'destroy')
window.mbHost.emit('destroy')
if (ending === 'source') {
window.mbReplacement = new ReadableStream({ cancel() {
window.mbCancelled.push('new')
} })
window.mbNextLoad = window.mbCanvas.engine.load(window.mbReplacement)
}
}, ending)
await expect.poll(() => page.evaluate(() => window.mbSettled)).toBe(true)
expect(await page.evaluate(() => window.mbCancelled)).toEqual(['old'])
const state = await page.evaluate(() => ({ error: window.mbCanvas.error, readyState: window.mbCanvas.readyState, events: window.mbEvents, input: window.mbCanvas.engine.input === null }))
expect(state.input).toBe(true)
expect(state.readyState).toBe(0)
expect(state.events.filter(name => ['video:loadedmetadata', 'video:loadeddata', 'video:canplay'].includes(name))).toEqual([])
expect(state.error).toEqual(ending === 'timeout' ? { code: 4, message: 'Load timeout' } : null)
if (ending === 'source')
await expect.poll(() => page.evaluate(() => window.mbReplacement.locked)).toBe(true)
await page.evaluate(() => window.mbHost.emit('destroy'))
await expect.poll(() => page.evaluate(() => window.mbCancelled)).toEqual(ending === 'source' ? ['old', 'new'] : ['old'])
await testInfo.attach('mediabunny-load-cancellation', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), ending, state, cancellations: await page.evaluate(() => window.mbCancelled), scope: 'Real browser ReadableStream and actual SDK Input disposal before first media bytes; real timeout; controlled ArtPlayer host; does not assert decoder, late frame or audio-node cleanup.' }) })
}
finally {
await page.evaluate(() => window.mbHost.emit('destroy'))
}
})
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { hash } from '../../refactor/scripts/releases.mjs'
import { mbHistorical } from '../helpers/mediabunny.js'
import { mbBrowserImplementations } from '../helpers/mediabunny.js'
import { expect, test } from './fixtures.js'
const implementations = (await mbHistorical()).filter(item => item.name.startsWith('published'))
const implementations = await mbBrowserImplementations()
for (const implementation of implementations) {
test(`MediaBunny ${implementation.name}: native MP4 playback or exact Windows WebKit capability failure`, async ({ page, browserName }, testInfo) => {
+20
View File
@@ -1,6 +1,24 @@
import fs from 'node:fs'
import process from 'node:process'
import vm from 'node:vm'
import { verifyMbContract } from '../../refactor/scripts/mb-contract.mjs'
import { readMember } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from './load.js'
export async function mbCandidate() {
if (process.env.ARTPLAYER_MB_BASELINE === '1')
return (await mbHistorical()).find(item => item.name === 'frozen-workspace')
if (process.env.ARTPLAYER_MB_ARTIFACT)
return { name: 'candidate-artifact', code: fs.readFileSync(process.env.ARTPLAYER_MB_ARTIFACT, 'utf8') }
return { name: 'candidate-source', code: await compilePackage('artplayer-proxy-mediabunny', 'umd') }
}
export async function mbBrowserImplementations() {
const candidate = await mbCandidate()
return process.env.ARTPLAYER_MB_BROWSER_CANDIDATE === '1'
? [candidate]
: [...(await mbHistorical()).filter(item => item.name.startsWith('published')), candidate]
}
export async function mbHistorical() {
const { baseline, archives, sources } = await verifyMbContract()
@@ -63,6 +81,8 @@ export function mbEnvironment({ code }, option = {}) {
Blob,
Event,
URL,
AbortController,
queueMicrotask,
console,
performance,
setTimeout: option.setTimeout || setTimeout,
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Use actual SDK input parsing and explicit track selection contracts.
import test from 'node:test'
import { loadModules } from './helpers/load.js'
const base = 'packages/artplayer-proxy-mediabunny/src/'
const { isHlsSource, normalizeSource, createInput, resolveDuration, selectPlaybackTracks, getHlsState } = await loadModules({
isHlsSource: { file: `${base}input`, name: 'isHlsSource' },
normalizeSource: { file: `${base}input`, name: 'normalizeSource' },
createInput: { file: `${base}input`, name: 'createInput' },
resolveDuration: { file: `${base}tracks`, name: 'resolveDuration' },
selectPlaybackTracks: { file: `${base}tracks`, name: 'selectPlaybackTracks' },
getHlsState: { file: `${base}hls-state`, name: 'getHlsState' },
})
test('MediaBunny input detection preserves URL suffix, query/hash, casing and non-string boundaries', () => {
for (const source of ['live.m3u8', 'LIVE.M3U8?token=x', 'live.m3u8#segment']) assert.equal(isHlsSource(source), true)
for (const source of ['live.m3u8.mp4', 'file.mp4?name=live.m3u8&x=1', new URL('https://example.com/live.m3u8'), null, new Blob()]) assert.equal(isHlsSource(source), false)
})
test('MediaBunny inputs preserve falsy source handling and SDK validation ownership', () => {
for (const source of [null, undefined, false, 0]) assert.equal(createInput(source), null)
for (const source of [{ invalid: true }, 42, true]) {
assert.equal(normalizeSource(source), source)
assert.throws(() => createInput(source), { message: 'options.source must be a Source or SourceRef.' })
}
const source = normalizeSource('https://example.com/movie.mp4')
assert.equal(normalizeSource(source), source)
const input = createInput(source)
input.dispose()
})
for (const sourceType of ['blob', 'stream']) {
test(`MediaBunny ${sourceType} uses one actual SDK input for metadata, duration and track selection`, async () => {
const bytes = fs.readFileSync(new URL('./browser/media/pattern.mp4', import.meta.url))
const source = sourceType === 'blob'
? new Blob([bytes])
: new ReadableStream({ start(controller) {
for (let offset = 0; offset < bytes.length; offset += 1024) controller.enqueue(bytes.subarray(offset, offset + 1024))
controller.close()
} })
const input = createInput(source)
try {
const media = await selectPlaybackTracks(input, source)
assert.equal(media.input, input)
assert.equal(media.videoTrack.displayWidth, 320)
assert.equal(media.videoTrack.displayHeight, 180)
assert.equal(media.audioTrack, null)
assert.equal(media.duration, 8)
assert.equal(media.isLive, false)
assert.equal(media.videoMode, 'manual')
assert.equal(media.audioMode, 'manual')
assert.equal(await getHlsState(media), null)
}
finally { input.dispose() }
})
}
for (const scenario of ['metadata', 'computed', 'live', 'unknown', 'trackless']) {
test(`MediaBunny duration preserves ${scenario} handling without scanning live or trackless inputs`, async () => {
const calls = []
const track = scenario === 'trackless' ? null : { isLive: async () => scenario === 'live' }
const duration = await resolveDuration({
input: {
getDurationFromMetadata: async (tracks, option) => {
assert.deepEqual(tracks, [track])
assert.deepEqual(option, { skipLiveWait: true })
calls.push('metadata')
return scenario === 'metadata' ? 12 : null
},
computeDuration: async (tracks, option) => {
assert.deepEqual(tracks, [track])
assert.deepEqual(option, { skipLiveWait: true })
calls.push('compute')
return scenario === 'unknown' ? null : 18
},
},
videoTrack: track,
audioTrack: null,
})
assert.equal(duration, scenario === 'metadata' ? 12 : scenario === 'computed' ? 18 : scenario === 'live' ? Infinity : Number.NaN)
assert.deepEqual(calls, scenario === 'trackless' ? [] : ['computed', 'unknown'].includes(scenario) ? ['metadata', 'compute'] : ['metadata'])
})
}
test('MediaBunny chooses pairable audio from primary video instead of an unrelated primary audio track', async () => {
const paired = { id: 2 }
const videoTrack = { isLive: async () => false, getPrimaryPairableAudioTrack: async () => paired }
const input = {
getPrimaryVideoTrack: async () => videoTrack,
getPrimaryAudioTrack: async () => { throw new Error('Unrelated audio must not be selected') },
getDurationFromMetadata: async (tracks) => {
assert.deepEqual(tracks, [videoTrack, paired])
return 5
},
}
const media = await selectPlaybackTracks(input, 'live.m3u8')
assert.equal(media.audioTrack, paired)
assert.equal(media.videoMode, 'auto')
assert.equal(media.audioMode, 'auto')
assert.equal(media.isHls, true)
})
test('MediaBunny audio-only input selects primary audio and retains live detection', async () => {
const audioTrack = { isLive: async () => true }
const media = await selectPlaybackTracks({ getPrimaryVideoTrack: async () => null, getPrimaryAudioTrack: async () => audioTrack, getDurationFromMetadata: async () => null }, 'live.m3u8')
assert.equal(media.videoTrack, null)
assert.equal(media.audioTrack, audioTrack)
assert.equal(media.duration, Infinity)
assert.equal(media.isLive, true)
})
test('MediaBunny HLS state keeps actual track identity, pairability, bitrate fallback and language aliases', async () => {
const audio = { id: 7, getName: async () => null, getLanguageCode: async () => 'fr', getAverageBitrate: async () => null, getBitrate: async () => 128000 }
const video = {
id: 3,
getName: async () => 'Main',
getDisplayHeight: async () => 180,
getAverageBitrate: async () => 0,
getBitrate: async () => {
throw new Error('Zero is a valid average bitrate')
},
getPairableAudioTracks: async () => [audio],
}
const media = { input: { getVideoTracks: async () => [video] }, isHls: true, videoTrack: video, audioTrack: audio, videoMode: 'manual', audioMode: 'auto' }
const state = await getHlsState(media)
assert.equal(state.currentLevel, state.levels[0])
assert.equal(state.currentAudio, state.audios[0])
assert.equal(state.currentLevel.track, video)
assert.equal(state.currentLevel.bitrate, 0)
assert.equal(state.currentAudio.track, audio)
assert.equal(state.currentAudio.name, null)
assert.equal(state.currentAudio.lang, 'fr')
assert.equal(state.currentAudio.language, 'fr')
assert.equal(state.currentAudio.bitrate, 128000)
assert.equal(state.videoMode, 'manual')
assert.equal(state.audioMode, 'auto')
assert.equal(await getHlsState(null), null)
})
+272
View File
@@ -0,0 +1,272 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Compare candidate cancellation with the frozen implementation.
import test from 'node:test'
import { mbCandidate, mbEnvironment } from './helpers/mediabunny.js'
const implementation = await mbCandidate()
const flush = () => new Promise(resolve => setImmediate(resolve))
function deferred() {
let resolve, reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
function environment(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('waiting', 'loadstart', 'loadeddata', 'canplay')
const canvas = env.factory(option)(env.art)
const errors = []
const warnings = []
env.globals.console = { error: (...args) => errors.push(args), warn: (...args) => warnings.push(args), log() {}, debug() {} }
function fire(delay) {
for (const [id, timer] of [...timers]) {
if (timer.delay === delay) {
timers.delete(id)
timer.fn()
}
}
}
return { ...env, canvas, engine: canvas.engine, timers, fire, errors, warnings }
}
test('MediaBunny candidate destroy settles pending load, drops deferred events and ignores late failure', async () => {
const env = environment({ loadTimeout: 100 })
const pending = deferred()
env.engine.performLoad = () => pending.promise
let settled = false
const loading = env.engine.load('movie.mp4').then(() => settled = true)
env.art.emit('destroy')
await flush()
assert.equal(settled, true, 'Cancellation must not wait for the underlying operation')
assert.equal(env.timers.size, 0)
const cursor = env.emitted.length
pending.reject(new Error('late failure'))
await loading
await flush()
assert.equal(env.engine.error, null)
assert.equal(env.emitted.length, cursor)
})
test('MediaBunny candidate source replacement settles old load without waiting for its result', async () => {
const env = environment({ loadTimeout: 100 })
const pending = deferred()
const sources = []
env.engine.performLoad = (src) => {
sources.push(src)
return src === 'old' ? pending.promise : Promise.resolve()
}
let settled = false
const old = env.engine.load('old').then(() => settled = true)
await env.engine.load('new')
await flush()
assert.equal(settled, true)
assert.deepEqual(sources, ['old', 'new'])
assert.equal([...env.timers.values()].filter(timer => timer.delay === 100).length, 0)
env.fire(0)
assert.deepEqual(env.emitted.map(event => event.name), ['video:waiting', 'video:loadstart'])
pending.reject(new Error('obsolete'))
await old
await flush()
assert.equal(env.engine.error, null)
env.art.emit('destroy')
})
test('MediaBunny candidate clears successful load deadline while keeping its normal deferred notifications', async () => {
const env = environment({ loadTimeout: 100 })
env.engine.performLoad = async () => {}
await env.engine.load('movie.mp4')
assert.deepEqual([...env.timers.values()].map(timer => timer.delay), [0, 0])
env.fire(0)
assert.deepEqual(env.emitted.map(event => event.name), ['video:waiting', 'video:loadstart'])
env.art.emit('destroy')
assert.equal(env.timers.size, 0)
})
test('MediaBunny candidate keeps active timeout error and suppresses a late operation failure', async () => {
const env = environment({ loadTimeout: 100 })
const pending = deferred()
env.engine.performLoad = () => pending.promise
const loading = env.engine.load('movie.mp4')
env.fire(100)
await loading
assert.deepEqual({ ...env.engine.error }, { code: 4, message: 'Load timeout' })
assert.equal(env.engine.networkState, 3)
assert.equal(env.timers.size, 0)
pending.reject(new Error('late failure'))
await flush()
assert.deepEqual(env.emitted.map(event => event.name), ['video:error'])
assert.equal(env.engine.error.message, 'Load timeout')
env.art.emit('destroy')
})
for (const ending of ['destroy', 'source', 'timeout']) {
test(`MediaBunny candidate ${ending} aborts pending HEAD and suppresses stale Range errors`, async () => {
const env = environment({ preflightRange: true, loadTimeout: 100 })
const response = deferred()
let request
env.globals.fetch = (url, init) => {
request = { url, init }
return response.promise
}
const loading = env.engine.load('movie.mp4')
assert.equal(request.init.method, 'HEAD')
if (ending === 'destroy') {
env.art.emit('destroy')
}
else if (ending === 'source') {
env.engine.audio.handleNoAudioTrack = () => {}
await env.engine.load(null)
}
else {
env.fire(100)
}
await flush()
assert.equal(request.init.signal?.aborted, true)
const cursor = env.emitted.length
response.resolve({ headers: { get: () => 'none' } })
await loading
await flush()
assert.equal(env.emitted.length, cursor)
assert.equal(env.warnings.length, 0)
if (ending === 'timeout')
assert.equal(env.engine.error.message, 'Load timeout')
else assert.equal(env.engine.error, null)
env.art.emit('destroy')
assert.equal(env.timers.size, 0)
})
}
for (const ending of ['destroy', 'source', 'timeout']) {
test(`MediaBunny candidate ${ending} disposes the real SDK Input while its first stream read is pending`, async () => {
const env = environment({ loadTimeout: 100 })
let cancellations = 0
const stream = new ReadableStream({ cancel() {
cancellations++
} })
let settled = false
const loading = env.engine.load(stream).then(() => settled = true)
await flush()
assert.equal(stream.locked, true, 'The SDK must have started reading the real stream')
if (ending === 'destroy')
env.art.emit('destroy')
else if (ending === 'source')
await env.engine.load(null)
else env.fire(100)
await flush()
assert.equal(settled, true)
assert.equal(cancellations, 1)
assert.equal(env.engine.input, null)
assert.equal(env.engine.media, null)
await loading
env.art.emit('destroy')
assert.equal(cancellations, 1)
assert.equal(env.timers.size, 0)
})
}
test('MediaBunny candidate Range retains supported, missing, network-failure and HLS bypass behavior', async () => {
const env = environment({ preflightRange: true })
const requests = []
env.globals.fetch = async (url, init) => {
requests.push({ url, init })
if (url === 'network')
throw new Error('network failure')
return { headers: { get: () => url === 'supported' ? 'bytes' : null } }
}
assert.equal(await env.engine.video.preflight('supported'), true)
assert.equal(await env.engine.video.preflight('missing'), false)
assert.equal(env.emitted.at(-1).args[0].detail.type, 'RangeNotSupported')
assert.equal(env.engine.error, null)
assert.equal(await env.engine.video.preflight('network'), true)
assert.equal(env.warnings.length, 1)
assert.equal(await env.engine.video.preflight('LIVE.M3U8?x=1'), true)
assert.equal(await env.engine.video.preflight(new Blob()), true)
assert.deepEqual(requests.map(({ url, init }) => ({ url, init: { ...init } })), ['supported', 'missing', 'network'].map(url => ({ url, init: { method: 'HEAD' } })))
env.art.emit('destroy')
})
test('MediaBunny candidate has no load side effects after destruction', async () => {
const env = environment()
let calls = 0
env.engine.performLoad = async () => calls++
env.art.emit('destroy')
await env.engine.load('later.mp4')
assert.equal(calls, 0)
assert.equal(env.timers.size, 0)
})
for (const failure of [false, true]) {
test(`MediaBunny candidate suppresses stale HEAD ${failure ? 'rejection' : 'response'} without AbortController`, async () => {
const env = environment({ preflightRange: true })
env.globals.AbortController = undefined
const response = deferred()
env.globals.fetch = () => response.promise
let settled = false
const loading = env.engine.load('movie.mp4').then(() => settled = true)
env.art.emit('destroy')
await flush()
assert.equal(settled, true)
const cursor = env.emitted.length
if (failure)
response.reject(new Error('obsolete'))
else response.resolve({ headers: { get: () => null } })
await loading
await flush()
assert.equal(env.emitted.length, cursor)
assert.equal(env.warnings.length, 0)
})
}
for (const timeout of [0, -1, Number.NaN, Infinity, '100']) {
test(`MediaBunny candidate keeps ${String(timeout)} timeout disabled`, async () => {
const env = environment({ loadTimeout: timeout })
env.engine.performLoad = async () => {}
await env.engine.load('movie.mp4')
assert.deepEqual([...env.timers.values()].map(timer => timer.delay), [0, 0])
env.art.emit('destroy')
})
}
test('MediaBunny candidate releases the real SDK input after decoder setup failure and ignores late metadata', async () => {
const env = environment()
const pendingAudio = deferred()
let metadata
let disposals = 0
env.engine.video.load = async (media) => {
const dispose = media.input.dispose.bind(media.input)
media.input.dispose = () => {
disposals++
dispose()
}
throw new Error('decoder setup failed')
}
env.engine.audio.load = (media, onMetadata) => {
metadata = onMetadata
return pendingAudio.promise
}
await env.engine.load(new Blob([fs.readFileSync(new URL('./browser/media/pattern.mp4', import.meta.url))]))
assert.equal(disposals, 1)
assert.equal(env.engine.input, null)
assert.equal(env.engine.error.message, 'decoder setup failed')
const cursor = env.emitted.length
metadata()
pendingAudio.resolve()
await flush()
assert.equal(env.emitted.length, cursor)
assert.equal(env.engine.readyState, 0)
env.art.emit('destroy')
assert.equal(disposals, 1)
})