Files
ArtPlayer/test/mediabunny-hls.test.js

540 lines
20 KiB
JavaScript

import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Actual proxy, controlled SDK/UI interleavings and immutable old artifact controls.
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 state(selected = 1) {
const levels = [
{ id: 1, index: 0, name: '720P', height: 720, bitrate: 2000 },
{ id: 2, index: 1, name: '1080P', height: 1080, bitrate: 4000 },
]
const audios = [
{ id: 3, index: 0, name: 'English', lang: 'en', language: 'en', bitrate: 128 },
{ id: 4, index: 1, name: 'French', lang: 'fr', language: 'fr', bitrate: 128 },
]
return { levels, audios, currentLevel: levels.find(track => track.id === selected), currentAudio: audios[0], videoMode: 'auto', audioMode: 'auto' }
}
function environment(option = { m3u8: { quality: { control: true, setting: true }, audio: { control: true, setting: true } } }) {
const env = mbEnvironment(implementation)
const calls = []
const warnings = []
const notices = []
const controls = new Map()
const settings = new Map()
env.globals.console = { ...console, warn: (...args) => warnings.push(args), error() {} }
const registry = (cache, kind) => ({
cache,
find: name => cache.get(name),
update(menu) {
calls.push([kind, 'update', menu.name])
cache.set(menu.name, menu)
},
remove(name) {
calls.push([kind, 'remove', name])
cache.delete(name)
},
})
env.art.controls = registry(controls, 'control')
env.art.setting = registry(settings, 'setting')
env.art.notice = {
get show() { return notices.at(-1) },
set show(value) { notices.push(value) },
}
env.art.off = (name, callback) => {
env.handlers.set(name, (env.handlers.get(name) || []).filter(item => item !== callback))
}
const canvas = env.factory(option)(env.art)
canvas.engine.getHlsState = async () => state()
const refresh = async () => {
await Promise.all((env.handlers.get('video:loadedmetadata') || []).map(callback => callback()))
}
return { ...env, canvas, option, controls, settings, calls, warnings, notices, refresh }
}
test('MediaBunny HLS retains normal menu names, ordering, defaults and notices', async () => {
const env = environment()
await env.refresh()
const quality = env.controls.get('mediabunny-quality')
assert.equal(quality.html, '720P')
assert.deepEqual(Array.from(quality.selector, item => [item.value, item.default]), [[2, false], [1, true], ['auto', false]])
assert.equal(env.settings.get('mediabunny-quality').width, 200)
assert.equal(env.settings.get('mediabunny-audio').html, 'Audio')
const selected = []
env.canvas.engine.selectHlsQuality = async value => selected.push(value)
assert.equal(await quality.onSelect(quality.selector[0]), '1080P')
assert.deepEqual(selected, [2])
assert.deepEqual(env.notices, ['Quality: 1080P'])
env.art.emit('destroy')
})
test('MediaBunny disabled HLS integration installs no menu callbacks', () => {
const env = environment({})
assert.equal(env.handlers.get('restart'), undefined)
assert.equal(env.controls.size, 0)
env.art.emit('destroy')
})
test('MediaBunny HLS clears all menus for a settled non-HLS source', async () => {
const env = environment()
await env.refresh()
env.canvas.engine.getHlsState = async () => null
await env.refresh()
assert.equal(env.controls.size + env.settings.size, 0)
env.art.emit('destroy')
})
for (const surface of ['control', 'setting']) {
test(`MediaBunny HLS removes disabled ${surface} while preserving the other surface`, async () => {
const env = environment()
await env.refresh()
env.option.m3u8.quality[surface] = false
env.option.m3u8.audio[surface] = false
await env.refresh()
assert.equal(env[surface === 'control' ? 'controls' : 'settings'].size, 0)
assert.equal(env[surface === 'control' ? 'settings' : 'controls'].size, 2)
env.art.emit('destroy')
})
}
for (const ending of ['newer', 'source', 'destroy']) {
test(`MediaBunny HLS pending state cannot paint after ${ending}`, async () => {
const env = environment()
const pending = deferred()
env.canvas.engine.getHlsState = () => pending.promise
const refresh = env.refresh()
if (ending === 'newer') {
env.canvas.engine.getHlsState = async () => state(2)
await env.refresh()
}
else if (ending === 'source') {
env.canvas.engine.loadSeq++
}
else {
env.art.emit('destroy')
}
pending.resolve(state(1))
await refresh
if (ending === 'newer')
assert.equal(env.controls.get('mediabunny-quality').html, '1080P')
else assert.equal(env.controls.size + env.settings.size, 0)
env.art.emit('destroy')
})
}
for (const event of ['video:loadstart', 'video:error', 'destroy']) {
test(`MediaBunny HLS ${event} clears rendered menus immediately`, async () => {
const env = environment()
await env.refresh()
env.art.emit(event)
assert.equal(env.controls.size + env.settings.size, 0)
env.art.emit('destroy')
})
}
for (const ending of ['menu', 'source', 'destroy']) {
test(`MediaBunny stale HLS selection callback is inert after ${ending}`, async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
const calls = []
env.canvas.engine.selectHlsQuality = async value => calls.push(value)
if (ending === 'menu')
await env.refresh()
else if (ending === 'source')
env.canvas.engine.loadSeq++
else env.art.emit('destroy')
assert.equal(await menu.onSelect(menu.selector[0]), '1080P')
assert.deepEqual(calls, [])
assert.deepEqual(env.notices, [])
env.art.emit('destroy')
})
}
for (const ending of ['newer', 'source', 'destroy']) {
test(`MediaBunny pending HLS selection cannot notify after ${ending}`, async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
const pending = deferred()
env.canvas.engine.selectHlsQuality = () => pending.promise
const selecting = menu.onSelect(menu.selector[0])
if (ending === 'newer') {
env.canvas.engine.selectHlsQuality = async () => {}
await menu.onSelect(menu.selector[1])
}
else if (ending === 'source') {
env.canvas.engine.loadSeq++
}
else {
env.art.emit('destroy')
}
const notices = env.notices.slice()
pending.resolve()
await selecting
assert.deepEqual(env.notices, notices)
env.art.emit('destroy')
})
}
test('MediaBunny background HLS refresh failure is observed without an unhandled rejection', async () => {
const env = environment()
const failure = new Error('state unavailable')
env.canvas.engine.getHlsState = async () => {
throw failure
}
await env.refresh()
assert.equal(env.warnings.length, 1)
assert.equal(env.warnings[0][1], failure)
env.art.emit('destroy')
})
test('MediaBunny current HLS selection failure retains its Promise rejection without success notice', async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
const failure = new Error('selection unavailable')
env.canvas.engine.selectHlsQuality = async () => {
throw failure
}
await assert.rejects(menu.onSelect(menu.selector[0]), error => error === failure)
assert.deepEqual(env.notices, [])
env.art.emit('destroy')
})
test('MediaBunny same-label levels retain the actually selected ID and one highlight', async () => {
const env = environment()
env.option.m3u8.quality.getName = () => 'Same'
env.canvas.engine.getHlsState = async () => state(2)
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
assert.deepEqual(Array.from(menu.selector, item => [item.value, item.default]), [[2, true], ['auto', false]])
env.art.emit('destroy')
})
test('MediaBunny HLS single audio topology removes only audio menus', async () => {
const env = environment()
await env.refresh()
const single = state()
single.audios.length = 1
env.canvas.engine.getHlsState = async () => single
await env.refresh()
assert.deepEqual([...env.controls.keys()], ['mediabunny-quality'])
assert.deepEqual([...env.settings.keys()], ['mediabunny-quality'])
env.art.emit('destroy')
})
test('MediaBunny HLS name callback destroy cannot recreate controls or settings', async () => {
const env = environment()
env.option.m3u8.quality.getName = (level) => {
env.art.emit('destroy')
return level.name
}
await env.refresh()
assert.equal(env.controls.size + env.settings.size, 0)
})
test('MediaBunny HLS control update reentry cannot write later settings or audio menus', async () => {
const env = environment()
const update = env.art.controls.update
env.art.controls.update = (menu) => {
update(menu)
env.art.emit('destroy')
}
await env.refresh()
assert.equal(env.controls.size + env.settings.size, 0)
})
test('MediaBunny HLS destruction removes its refresh listeners and preserves proxy teardown', async () => {
const env = environment()
await env.refresh()
const metadataListener = env.handlers.get('video:loadedmetadata').at(-1)
env.art.emit('destroy')
assert.equal(env.canvas.engine.destroyed, true)
assert.equal(env.art.mediabunny, undefined)
assert.equal(env.handlers.get('video:loadedmetadata').includes(metadataListener), false)
assert.equal(env.handlers.get('restart')?.length || 0, 0)
})
function selectionEnvironment() {
const env = environment({})
const pairs = []
const videos = [1, 2].map(id => ({ id, canBePairedWith: () => true }))
const audios = [3, 4].map(id => ({ id, canBePairedWith: () => true }))
env.canvas.engine.input = { getVideoTracks: async () => videos, getAudioTracks: async () => audios }
env.canvas.engine.media = { isHls: true, videoTrack: videos[0], audioTrack: audios[0], videoMode: 'auto', audioMode: 'auto' }
env.canvas.engine.replaceTracks = async (tracks) => {
pairs.push(tracks)
env.canvas.engine.media = { ...env.canvas.engine.media, ...tracks }
}
return { ...env, engine: env.canvas.engine, videos, audios, pairs }
}
for (const [method, query, field, ids] of [['selectHlsQuality', 'getVideoTracks', 'videoTrack', [1, 2]], ['selectHlsAudio', 'getAudioTracks', 'audioTrack', [3, 4]]]) {
test(`MediaBunny ${method} honors the latest request when the older query resolves first`, async () => {
const env = selectionEnvironment()
const first = deferred()
const second = deferred()
const values = await env.engine.input[query]()
let queries = 0
env.engine.input[query] = () => (++queries === 1 ? first.promise : second.promise)
const older = env.engine[method](ids[0])
const newer = env.engine[method](ids[1])
first.resolve(values)
await flush()
second.resolve(values)
await Promise.all([older, newer])
assert.deepEqual(env.pairs.map(pair => pair[field].id), [ids[1]])
env.art.emit('destroy')
})
}
test('MediaBunny later audio intent supersedes an unresolved quality query', async () => {
const env = selectionEnvironment()
const pending = deferred()
env.engine.input.getVideoTracks = () => pending.promise
const older = env.engine.selectHlsQuality(2)
const newer = env.engine.selectHlsAudio(4)
pending.resolve(env.videos)
await Promise.all([older, newer])
assert.equal(env.pairs.length, 1)
assert.equal(env.pairs[0].audioTrack.id, 4)
assert.equal(env.pairs[0].videoTrack.id, 1)
env.art.emit('destroy')
})
test('MediaBunny HLS selection survives its own loadedmetadata refresh without a duplicate notice', async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
env.canvas.engine.selectHlsQuality = async () => {
env.canvas.engine.getHlsState = async () => state(2)
await env.refresh()
}
await menu.onSelect(menu.selector[0])
assert.equal(env.controls.get('mediabunny-quality').html, '1080P')
assert.deepEqual(env.notices, ['Quality: 1080P'])
env.art.emit('destroy')
})
test('MediaBunny HLS selection invalidates an older state read before it can paint', async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
const read = deferred()
const switchTrack = deferred()
env.canvas.engine.getHlsState = () => read.promise
const updating = env.refresh()
env.canvas.engine.selectHlsQuality = () => switchTrack.promise
const selecting = menu.onSelect(menu.selector[0])
const writes = env.calls.length
read.resolve(state())
await updating
assert.equal(env.calls.length, writes)
env.canvas.engine.getHlsState = async () => state(2)
switchTrack.resolve()
await selecting
assert.equal(env.controls.get('mediabunny-quality').html, '1080P')
env.art.emit('destroy')
})
test('MediaBunny failed HLS selection restores highlight from actual tracks before rejecting', async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
for (const item of menu.selector) item.default = item.value === 2
const failure = new Error('track failed')
env.canvas.engine.selectHlsQuality = async () => {
throw failure
}
await assert.rejects(menu.onSelect(menu.selector[0]), error => error === failure)
assert.deepEqual(Array.from(env.controls.get('mediabunny-quality').selector).filter(item => item.default).map(item => item.value), [1])
env.art.emit('destroy')
})
test('MediaBunny stale HLS selection rejection is observed without disturbing the current source', async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
const pending = deferred()
env.canvas.engine.selectHlsQuality = () => pending.promise
const selecting = menu.onSelect(menu.selector[0])
env.canvas.engine.loadSeq++
pending.reject(new Error('old source closed'))
await selecting
assert.equal(env.notices.length + env.warnings.length, 0)
env.art.emit('destroy')
})
test('MediaBunny HLS clears all owned surfaces even when one registry cleanup fails', async () => {
const env = environment()
await env.refresh()
const failure = new Error('control cleanup failed')
const remove = env.art.controls.remove
env.art.controls.remove = (name) => {
remove(name)
throw failure
}
env.art.emit('destroy')
assert.equal(env.controls.size + env.settings.size, 0)
assert.equal(env.canvas.engine.destroyed, true)
assert.equal(env.handlers.get('restart').length, 0)
assert.equal(env.warnings[0][1], failure)
})
test('MediaBunny HLS failing listener removal still attempts remaining removals and proxy teardown', async () => {
const env = environment()
const off = env.art.off
const removed = []
env.art.off = (name, callback) => {
removed.push(name)
off(name, callback)
if (name === 'restart')
throw new Error('off failed')
}
env.art.emit('destroy')
assert.deepEqual(removed.slice(0, 5), ['video:loadedmetadata', 'restart', 'video:loadstart', 'video:error', 'destroy'])
assert.equal(env.canvas.engine.destroyed, true)
assert.equal(env.warnings.length, 1)
})
test('MediaBunny runtime HLS disable clears menus on the next refresh', async () => {
const env = environment()
await env.refresh()
env.option.m3u8 = undefined
await env.refresh()
assert.equal(env.controls.size + env.settings.size, 0)
env.art.emit('destroy')
})
test('MediaBunny HLS custom labels retain Auto text while highlighting the actual selected track', async () => {
const env = environment()
env.option.m3u8.audio = { control: true, setting: true, title: 'Language', auto: 'Automatic', getName: track => track.lang }
await env.refresh()
const menu = env.controls.get('mediabunny-audio')
assert.equal(menu.html, 'en')
assert.deepEqual(Array.from(menu.selector, item => [item.html, item.default]), [['en', true], ['fr', false], ['Automatic', false]])
const values = []
env.canvas.engine.selectHlsAudio = async value => values.push(value)
await menu.onSelect(menu.selector[2])
assert.deepEqual(values, ['auto'])
assert.deepEqual(env.notices, ['Language: Automatic'])
assert.equal(env.controls.get('mediabunny-audio').html, 'en')
env.art.emit('destroy')
})
for (const kind of ['quality', 'audio']) {
test(`MediaBunny ${kind} pairing fallback preserves a coherent pair and resets only the counterpart mode`, async () => {
const env = selectionEnvironment()
env.engine.media.videoMode = 'manual'
env.engine.media.audioMode = 'manual'
if (kind === 'quality') {
env.videos[1].canBePairedWith = () => false
env.videos[1].getPrimaryPairableAudioTrack = async () => env.audios[1]
await env.engine.selectHlsQuality(2)
assert.deepEqual([env.pairs[0].videoTrack.id, env.pairs[0].audioTrack.id, env.pairs[0].videoMode, env.pairs[0].audioMode], [2, 4, 'manual', 'auto'])
}
else {
env.audios[1].canBePairedWith = () => false
env.audios[1].getPrimaryPairableVideoTrack = async () => env.videos[1]
await env.engine.selectHlsAudio(4)
assert.deepEqual([env.pairs[0].videoTrack.id, env.pairs[0].audioTrack.id, env.pairs[0].videoMode, env.pairs[0].audioMode], [2, 4, 'auto', 'manual'])
}
env.art.emit('destroy')
})
}
test('MediaBunny latest selection still applies when an earlier replacement changes media during its query', async () => {
const env = selectionEnvironment()
const replacing = deferred()
const query = deferred()
env.engine.replaceTracks = async (tracks) => {
env.pairs.push(tracks)
if (env.pairs.length === 1)
await replacing.promise
env.engine.media = { ...env.engine.media, ...tracks }
}
const older = env.engine.selectHlsQuality(1)
await flush()
env.engine.input.getVideoTracks = () => query.promise
const latest = env.engine.selectHlsQuality(2)
replacing.resolve()
await older
query.resolve(env.videos)
await latest
assert.equal(env.engine.media.videoTrack.id, 2)
env.art.emit('destroy')
})
test('MediaBunny obsolete selection query rejection cannot replace the newer selection result', async () => {
const env = selectionEnvironment()
const query = deferred()
env.engine.input.getVideoTracks = () => query.promise
const older = env.engine.selectHlsQuality(2)
await env.engine.selectHlsAudio(4)
query.reject(new Error('obsolete query'))
await older
assert.equal(env.engine.media.audioTrack.id, 4)
env.art.emit('destroy')
})
test('MediaBunny Auto audio resolves against the current video when media changes during pairing', async () => {
const env = selectionEnvironment()
const pending = deferred()
env.videos[0].getPrimaryPairableAudioTrack = () => pending.promise
env.videos[1].getPrimaryPairableAudioTrack = async () => env.audios[1]
const selecting = env.engine.selectHlsAudio('auto')
await flush()
env.engine.media = { ...env.engine.media, videoTrack: env.videos[1] }
pending.resolve(env.audios[0])
await selecting
assert.equal(env.pairs[0].videoTrack.id, 2)
assert.equal(env.pairs[0].audioTrack.id, 4)
env.art.emit('destroy')
})
for (const ending of ['resolve', 'reject']) {
test(`MediaBunny state ${ending} is discarded when the source generation changes before media replacement`, async () => {
const env = environment({})
delete env.canvas.engine.getHlsState
const query = deferred()
env.canvas.engine.media = { isHls: true, input: { getVideoTracks: () => query.promise }, videoTrack: null }
const reading = env.canvas.engine.getHlsState()
env.canvas.engine.loadSeq++
if (ending === 'resolve')
query.resolve([])
else query.reject(new Error('obsolete state'))
assert.equal(await reading, null)
env.art.emit('destroy')
})
}
test('MediaBunny selection failure superseded during UI recovery does not reject after the newer intent', async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
const recovery = deferred()
env.canvas.engine.getHlsState = () => recovery.promise
env.canvas.engine.selectHlsQuality = async () => {
throw new Error('first selection failed')
}
const older = menu.onSelect(menu.selector[0]).then(value => ({ value }), error => ({ error }))
await flush()
env.canvas.engine.getHlsState = async () => state()
env.canvas.engine.selectHlsQuality = async () => {}
await menu.onSelect(menu.selector[1])
recovery.resolve(state())
assert.equal((await older).error, undefined)
assert.deepEqual(env.notices, ['Quality: 720P'])
env.art.emit('destroy')
})