mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-19 11:21:20 -08:00
refactor(mediabunny): [PKG-MB-03] type inputs and cancel owned loads
This commit is contained in:
1 parent
03451c029b
commit
adba8a3d12
35 files changed
+11260
-3327
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.' }) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user