mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 10:56:15 -08:00
273 lines
9.8 KiB
JavaScript
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)
|
|
})
|