refactor(mediabunny): [PKG-MB-07] own HLS selection intent and menu lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-12 20:51:31 +08:00
1 parent ccee9157a7
commit 8a2b56e829
27 files changed
+4217 -680

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { hash } from '../../refactor/scripts/releases.mjs'
import { mbCandidate } from '../helpers/mediabunny.js'
import { expect, test } from './fixtures.js'
const implementation = await mbCandidate()
const manifest = JSON.parse(fs.readFileSync(new URL('./media/hls/manifest.json', import.meta.url)))
const media = new Map(Object.entries(manifest.files).map(([name, expected]) => {
const bytes = fs.readFileSync(new URL(`./media/hls/${name}`, import.meta.url))
assert.equal(hash(bytes), expected.sha256)
return [name, bytes]
}))
for (const core of ['published', 'candidate']) {
for (const scenario of ['menus', 'topology', 'stale-source', 'stale-destroy', 'latest']) {
test(`MediaBunny ${implementation.name}: ${core} core native HLS ${scenario}`, async ({ page, browserName }, testInfo) => {
await page.route('**/mb-ui-hls/**', async (route) => {
const name = new URL(route.request().url()).pathname.split('/').at(-1)
const body = media.get(name)
await route.fulfill({ status: body ? 200 : 404, body: body || 'Missing fixture', contentType: name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t' })
})
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports;
${implementation.code}; window.mbFactory = module.exports.default || module.exports; })();` })
await page.evaluate(() => {
window.mbEvents = []
window.mbErrors = []
window.mbWrites = []
window.mbOption = { m3u8: { quality: { control: true, setting: true }, audio: { control: true, setting: true } } }
window.mbFlush = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
window.art = new window.Artplayer({ container: '.player', url: '/mb-ui-hls/master.m3u8', muted: true, setting: true, proxy: window.mbFactory(window.mbOption) })
window.mbCanvas = window.art.video
for (const event of ['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'seeked', 'error'])
window.art.on(`video:${event}`, () => window.mbEvents.push(event))
document.querySelector('#play').onclick = () => window.art.play().catch(error => window.mbErrors.push(error.message))
})
const capabilities = await page.evaluate(() => ({ AudioContext: typeof AudioContext, webkitAudioContext: typeof window.webkitAudioContext, AudioDecoder: typeof AudioDecoder, VideoDecoder: typeof VideoDecoder, coreVersion: window.Artplayer.version }))
let outcome = 'unverified'
let result
try {
if (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') {
expect(browserName).toBe('webkit')
await expect.poll(() => page.evaluate(() => window.mbCanvas.error?.code)).toBe(4)
expect(await page.evaluate(() => window.art.isReady)).toBe(false)
expect(await page.locator('.art-control-mediabunny-quality').count()).toBe(0)
outcome = 'unsupported-capability-control'
return
}
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await expect(page.locator('.art-control-mediabunny-quality .art-selector-value')).toHaveText('180P')
await expect(page.locator('.art-control-mediabunny-audio .art-selector-value')).toHaveText('English')
await page.evaluate(() => {
for (const name of ['controls', 'setting']) {
const registry = window.art[name]
const update = registry.update.bind(registry)
registry.update = (option) => {
if (option.name?.startsWith('mediabunny-'))
window.mbWrites.push({ surface: name, name: option.name })
return update(option)
}
}
})
if (scenario === 'menus') {
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(0.2)
await page.evaluate(() => {
window.art.pause()
window.mbEvents.length = 0
})
const quality = page.locator('.art-control-mediabunny-quality')
await quality.hover()
await quality.locator('.art-selector-item').filter({ hasText: /^90P$/ }).click()
await expect.poll(() => page.evaluate(() => window.mbCanvas.getM3u8State().then(state => state?.currentLevel?.height))).toBe(90)
await expect(page.locator('.art-control-mediabunny-quality .art-selector-value')).toHaveText('90P')
await expect.poll(() => page.evaluate(() => Object.fromEntries(['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'seeked'].map(name => [name, window.mbEvents.filter(event => event === name).length])))).toEqual({ loadedmetadata: 1, loadeddata: 1, canplay: 1, canplaythrough: 1, seeked: 1 })
await page.evaluate(() => {
const menu = window.art.setting.find('mediabunny-audio')
window.art.setting.show = true
window.art.setting.render(menu.selector)
menu.selector.find(item => item.html === 'French').$item.click()
})
await expect.poll(() => page.evaluate(() => window.mbCanvas.getM3u8State().then(state => state?.currentAudio?.lang))).toBe('fr')
await expect(page.locator('.art-control-mediabunny-audio .art-selector-value')).toHaveText('French')
await expect.poll(() => page.evaluate(() => window.mbEvents.filter(event => event === 'seeked').length)).toBe(2)
await page.evaluate(() => {
window.art.setting.show = false
})
await quality.hover()
await quality.locator('.art-selector-item').filter({ hasText: /^Auto$/ }).click()
await expect.poll(() => page.evaluate(() => window.mbCanvas.getM3u8State().then(state => state?.videoMode))).toBe('auto')
await expect(page.locator('.art-control-mediabunny-quality .art-selector-value')).toHaveText('180P')
await expect.poll(() => page.evaluate(() => Object.fromEntries(['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'seeked'].map(name => [name, window.mbEvents.filter(event => event === name).length])))).toEqual({ loadedmetadata: 3, loadeddata: 3, canplay: 3, canplaythrough: 3, seeked: 3 })
result = await page.evaluate(async () => {
const state = await window.mbCanvas.getM3u8State()
const quality = window.art.controls.cache.get('mediabunny-quality').option
const audio = window.art.setting.find('mediabunny-audio')
return { currentLevel: state.currentLevel.id, qualitySelected: quality.selector.filter(item => item.default).map(item => item.value), currentAudio: state.currentAudio.id, audioSelected: audio.selector.filter(item => item.default).map(item => item.value), paired: state.currentLevel.track.canBePairedWith(state.currentAudio.track), errors: window.mbErrors }
})
expect(result.qualitySelected).toEqual([result.currentLevel])
expect(result.audioSelected).toEqual([result.currentAudio])
expect(result.paired).toBe(true)
expect(result.errors).toEqual([])
}
else if (scenario === 'topology') {
await page.evaluate(() => {
window.mbOption.m3u8.quality.control = false
window.mbOption.m3u8.audio.control = false
window.art.emit('restart')
})
await expect(page.locator('.art-control-mediabunny-quality')).toHaveCount(0)
await expect(page.locator('.art-control-mediabunny-audio')).toHaveCount(0)
expect(await page.evaluate(() => Boolean(window.art.setting.find('mediabunny-quality') && window.art.setting.find('mediabunny-audio')))).toBe(true)
await page.evaluate(async () => {
window.mbOption.m3u8.quality.control = true
window.mbOption.m3u8.audio.control = true
await window.mbCanvas.engine.load('/mb-ui-hls/video-only.m3u8')
})
await expect(page.locator('.art-control-mediabunny-quality')).toHaveCount(1)
await expect(page.locator('.art-control-mediabunny-audio')).toHaveCount(0)
expect(await page.evaluate(() => Boolean(window.art.setting.find('mediabunny-audio')))).toBe(false)
await page.evaluate(() => window.mbCanvas.engine.load('/test/pattern.mp4'))
await expect(page.locator('[class*="art-control-mediabunny-"]')).toHaveCount(0)
expect(await page.evaluate(() => Boolean(window.art.setting.find('mediabunny-quality') || window.art.setting.find('mediabunny-audio')))).toBe(false)
result = { settingsSurviveControlDisable: true, audioRemovedForVideoOnly: true, allRemovedForMp4: true }
}
else if (scenario.startsWith('stale-')) {
await page.evaluate(() => {
const engine = window.mbCanvas.engine
const getState = engine.getHlsState.bind(engine)
let first = true
engine.getHlsState = async () => {
const state = await getState()
if (first) {
first = false
return new Promise((resolve) => {
window.mbRelease = () => resolve(state)
})
}
return state
}
window.art.emit('restart')
})
await expect.poll(() => page.evaluate(() => typeof window.mbRelease)).toBe('function')
result = await page.evaluate(async (scenario) => {
if (scenario === 'stale-source')
await window.mbCanvas.engine.load('/test/pattern.mp4')
else window.art.destroy(false)
const before = window.mbWrites.length
window.mbRelease()
await window.mbFlush()
return { before, after: window.mbWrites.length, menus: document.querySelectorAll('[class*="art-control-mediabunny-"]').length }
}, scenario)
expect(result.after).toBe(result.before)
expect(result.menus).toBe(0)
}
else {
await page.evaluate(async () => {
const engine = window.mbCanvas.engine
const input = engine.input
const getTracks = input.getVideoTracks.bind(input)
const tracks = await getTracks()
window.mbDesired = tracks.find(track => track.id !== engine.media.videoTrack.id).id
let calls = 0
input.getVideoTracks = () => {
calls++
if (calls === 1)
return new Promise((resolve) => { window.mbReleaseOlder = () => resolve(tracks) })
if (calls === 2)
return new Promise((resolve) => { window.mbReleaseNewer = () => resolve(tracks) })
return getTracks()
}
window.mbOlder = window.mbCanvas.switchM3u8Quality(engine.media.videoTrack.id)
window.mbNewer = window.mbCanvas.switchM3u8Quality(window.mbDesired)
window.mbReleaseOlder()
await window.mbOlder
window.mbReleaseNewer()
await window.mbNewer
})
result = await page.evaluate(async () => {
const state = await window.mbCanvas.getM3u8State()
return { desired: window.mbDesired, selected: state.currentLevel.id, ready: window.mbCanvas.readyState, events: window.mbEvents }
})
expect(result.selected).toBe(result.desired)
expect(result.ready).toBe(4)
}
outcome = 'native-hls-ui-and-selection-observed'
}
finally {
const state = await page.evaluate(() => {
window.mbRelease?.()
window.mbReleaseOlder?.()
window.mbReleaseNewer?.()
window.mbContext = window.mbCanvas.engine.audio.audioContext
window.art.destroy(false)
return { events: window.mbEvents, errors: window.mbErrors, writes: window.mbWrites, error: window.mbCanvas.error, destroyed: window.mbCanvas.engine.destroyed }
})
await expect.poll(() => page.evaluate(() => window.mbContext?.state || 'not-created')).toMatch(/^(?:closed|not-created)$/)
await testInfo.attach('mediabunny-hls-ui', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), core, scenario, capabilities, outcome, result, state, scope: 'Actual old/candidate core, SDK HLS parsing and native decoding; rendered control/settings clicks, topology and controlled state/query delays. Not installed-package or long-run/device acceptance.' }) })
}
})
}
}
+539
View File
@@ -0,0 +1,539 @@
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 metadataBefore = env.handlers.get('video:loadedmetadata').length
env.art.emit('destroy')
assert.equal(env.canvas.engine.destroyed, true)
assert.equal(env.art.mediabunny, undefined)
assert.equal(env.handlers.get('video:loadedmetadata').length, metadataBefore - 1)
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.equal(removed.length, 5)
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')
})