Files
ArtPlayer/test/mediabunny-load.test.js
T

273 lines
9.8 KiB
JavaScript

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)
})