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

555 lines
20 KiB
JavaScript

import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Verify the actual coordinator with controlled asynchronous operations.
import test from 'node:test'
import { mbCandidate, mbEnvironment } from './helpers/mediabunny.js'
import { tracklessMp4 } from './helpers/trackless-mp4.js'
const implementation = await mbCandidate()
const flush = () => new Promise(resolve => setImmediate(resolve))
const media = new Blob([fs.readFileSync(new URL('./browser/media/pattern.mp4', import.meta.url))])
function deferred() {
let resolve, reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
function environment() {
const env = mbEnvironment(implementation)
for (const name of ['playing', 'seeking', 'seeked', 'waiting', 'loadstart', 'loadeddata', 'canplay', 'canplaythrough', 'durationchange', 'progress', 'ended']) {
if (!env.eventNames.includes(name))
env.eventNames.push(name)
}
const canvas = env.factory()(env.art)
const engine = canvas.engine
const calls = []
for (const name of env.eventNames) canvas.events.addEventListener(name, () => calls.push(name))
engine.audio.play = async () => calls.push('audio:play')
engine.audio.pause = () => calls.push('audio:pause')
engine.audio.seek = async time => calls.push(`audio:seek:${time}`)
engine.video.start = () => calls.push('video:start')
engine.video.stop = () => calls.push('video:stop')
engine.video.seek = async time => calls.push(`video:seek:${time}`)
env.globals.console = { log() {}, warn() {}, error() {}, debug() {} }
return { ...env, canvas, engine, calls }
}
test('MediaBunny coordinator preserves ordinary play, seek-resume and synchronous pause ordering', async () => {
const env = environment()
await env.engine.play()
assert.deepEqual(env.calls, ['audio:play', 'video:start', 'play', 'playing'])
env.calls.length = 0
await env.engine.seek(3)
assert.deepEqual(env.calls, ['seeking', 'waiting', 'audio:pause', 'video:stop', 'pause', 'audio:seek:3', 'video:seek:3', 'seeked', 'audio:play', 'video:start', 'play', 'playing'])
env.calls.length = 0
assert.equal(env.engine.pause(), undefined)
assert.deepEqual(env.calls, ['audio:pause', 'video:stop', 'pause'])
env.art.emit('destroy')
})
for (const operation of ['pause', 'destroy', 'source']) {
test(`MediaBunny coordinator discards pending play after ${operation}`, async () => {
const env = environment()
const audio = deferred()
env.engine.audio.play = () => audio.promise
const playing = env.engine.play()
if (operation === 'pause') {
env.engine.pause()
}
else if (operation === 'destroy') {
env.art.emit('destroy')
}
else {
env.engine.performLoad = async () => {}
await env.engine.load('new.mp4')
}
env.calls.length = 0
audio.resolve()
await playing
assert.equal(env.engine.paused, true)
assert.equal(env.calls.includes('video:start'), false)
assert.equal(env.calls.includes('play'), false)
assert.equal(env.calls.includes('playing'), false)
env.art.emit('destroy')
})
}
test('MediaBunny coordinator restores paused state after a play rejection and allows retry', async () => {
const env = environment()
const error = new Error('resume denied')
let attempts = 0
env.engine.audio.play = async () => {
if (++attempts === 1)
throw error
}
await assert.rejects(env.engine.play(), value => value === error)
assert.equal(env.engine.paused, true)
await env.engine.play()
assert.equal(attempts, 2)
assert.equal(env.engine.paused, false)
assert.equal(env.calls.filter(name => name === 'video:start').length, 1)
env.art.emit('destroy')
})
test('MediaBunny coordinator delegates each new play intent to the audio owner and starts video once', async () => {
const env = environment()
const audio = deferred()
let attempts = 0
env.engine.audio.play = () => {
attempts++
return audio.promise
}
const first = env.engine.play()
env.engine.pause()
const second = env.engine.play()
audio.resolve()
await Promise.all([first, second])
assert.equal(attempts, 2)
assert.equal(env.engine.paused, false)
assert.equal(env.calls.filter(name => name === 'video:start').length, 1)
assert.equal(env.calls.filter(name => name === 'play').length, 1)
env.art.emit('destroy')
})
test('MediaBunny coordinator stops the play event sequence when a listener pauses', async () => {
const env = environment()
env.canvas.events.addEventListener('play', () => env.engine.pause())
await env.engine.play()
assert.equal(env.engine.paused, true)
assert.equal(env.calls.includes('playing'), false)
env.art.emit('destroy')
})
test('MediaBunny coordinator honors an explicit pause while a playing seek is pending', async () => {
const env = environment()
await env.engine.play()
const video = deferred()
env.engine.video.seek = () => video.promise
const seeking = env.engine.seek(4)
env.engine.pause()
env.calls.length = 0
video.resolve()
await seeking
assert.equal(env.engine.paused, true)
assert.equal(env.engine.seeking, false)
assert.deepEqual(env.calls.filter(name => ['seeked', 'video:start', 'play'].includes(name)), ['seeked'])
env.art.emit('destroy')
})
test('MediaBunny coordinator keeps the newest seek active when an earlier seek finishes first', async () => {
const env = environment()
const first = deferred()
const second = deferred()
env.engine.video.seek = time => time === 1 ? first.promise : second.promise
const a = env.engine.seek(1)
const b = env.engine.seek(2)
first.resolve()
await a
assert.equal(env.engine.seeking, true)
assert.equal(env.calls.includes('seeked'), false)
second.resolve()
await b
assert.equal(env.engine.seeking, false)
assert.equal(env.calls.filter(name => name === 'seeked').length, 1)
env.art.emit('destroy')
})
test('MediaBunny coordinator drops an older seek result after the newest result and preserves playing intent', async () => {
const env = environment()
await env.engine.play()
const first = deferred()
const second = deferred()
env.engine.video.seek = time => time === 1 ? first.promise : second.promise
const a = env.engine.seek(1)
const b = env.engine.seek(2)
env.calls.length = 0
second.resolve()
await b
assert.equal(env.engine.paused, false)
first.resolve()
await a
assert.equal(env.calls.filter(name => name === 'seeked').length, 1)
assert.equal(env.calls.filter(name => name === 'video:start').length, 1)
env.art.emit('destroy')
})
for (const operation of ['destroy', 'source']) {
test(`MediaBunny coordinator prevents pending seek completion and resume after ${operation}`, async () => {
const env = environment()
await env.engine.play()
const video = deferred()
env.engine.video.seek = () => video.promise
const seeking = env.engine.seek(4)
if (operation === 'destroy') {
env.art.emit('destroy')
}
else {
env.engine.performLoad = async () => {}
await env.engine.load('new.mp4')
}
env.calls.length = 0
video.resolve()
await seeking
assert.equal(env.calls.includes('video:start'), false)
assert.equal(env.calls.includes('audio:play'), false)
assert.equal(env.calls.includes('seeked'), false)
env.art.emit('destroy')
})
}
test('MediaBunny coordinator does not continue old seek after a seeking listener requests another position', async () => {
const env = environment()
let replacement
let first = true
env.canvas.events.addEventListener('seeking', () => {
if (first) {
first = false
replacement = env.engine.seek(7)
}
})
await env.engine.seek(2)
await replacement
assert.equal(env.calls.includes('audio:seek:2'), false)
assert.equal(env.calls.includes('video:seek:2'), false)
assert.equal(env.calls.filter(name => name === 'seeked').length, 1)
env.art.emit('destroy')
})
test('MediaBunny coordinator waits for pending seek when play is requested, without a resume deadlock', { timeout: 3000 }, async () => {
const env = environment()
const video = deferred()
env.engine.video.seek = () => video.promise
const seeking = env.engine.seek(3)
const playing = env.engine.play()
await flush()
assert.equal(env.calls.includes('audio:play'), false)
video.resolve()
await Promise.all([seeking, playing])
assert.equal(env.engine.paused, false)
assert.equal(env.calls.filter(name => name === 'video:start').length, 1)
env.art.emit('destroy')
})
test('MediaBunny coordinator resets seeking after an active failure and permits another seek', async () => {
const env = environment()
const error = new Error('seek failed')
let attempts = 0
env.engine.video.seek = async () => {
if (++attempts === 1)
throw error
}
await assert.rejects(env.engine.seek(2), value => value === error)
assert.equal(env.engine.seeking, false)
await env.engine.seek(3)
assert.equal(attempts, 2)
assert.equal(env.engine.seeking, false)
env.art.emit('destroy')
})
test('MediaBunny coordinator replay after ended seeks to zero then starts normally', async () => {
const env = environment()
env.engine.ended = true
await env.engine.play()
assert.equal(env.engine.ended, false)
assert.equal(env.engine.paused, false)
assert.deepEqual(env.calls, ['seeking', 'waiting', 'audio:seek:0', 'video:seek:0', 'seeked', 'audio:play', 'video:start', 'play', 'playing'])
env.art.emit('destroy')
})
test('MediaBunny coordinator preserves a resume-play rejection from the seek Promise', async () => {
const env = environment()
await env.engine.play()
const failure = new Error('resume failed')
env.engine.audio.play = async () => {
throw failure
}
await assert.rejects(env.engine.seek(3), error => error === failure)
assert.equal(env.engine.paused, true)
assert.equal(env.engine.seeking, false)
env.art.emit('destroy')
})
test('MediaBunny coordinator keeps original play rejection when cleanup fails and still stops video', async () => {
const env = environment()
const failure = new Error('resume failed')
env.engine.audio.play = async () => {
throw failure
}
env.engine.audio.pause = () => {
throw new Error('cleanup failed')
}
await assert.rejects(env.engine.play(), error => error === failure)
assert.equal(env.calls.includes('video:stop'), true)
assert.equal(env.engine.paused, true)
env.engine.audio.pause = () => {}
env.art.emit('destroy')
})
test('MediaBunny coordinator attempts both decoder cleanups when audio teardown throws', () => {
const env = environment()
const failure = new Error('audio teardown failed')
let videoDestroyed = 0
env.engine.audio.destroy = () => {
throw failure
}
env.engine.video.destroy = () => videoDestroyed++
assert.throws(() => env.art.emit('destroy'), error => error === failure)
assert.equal(videoDestroyed, 1)
assert.equal(env.canvas.events.listeners.size, 0)
assert.doesNotThrow(() => env.engine.destroy())
assert.equal(videoDestroyed, 1)
})
test('MediaBunny coordinator does not resume if a seeked listener pauses', async () => {
const env = environment()
await env.engine.play()
env.canvas.events.addEventListener('seeked', () => env.engine.pause())
env.calls.length = 0
await env.engine.seek(3)
assert.equal(env.engine.paused, true)
assert.equal(env.calls.includes('video:start'), false)
env.art.emit('destroy')
})
test('MediaBunny coordinator settles canceled play before audio resume returns and observes its late rejection', async () => {
const env = environment()
const audio = deferred()
env.engine.audio.play = () => audio.promise
let settled = false
const playing = env.engine.play().then(() => settled = true)
env.art.emit('destroy')
await flush()
assert.equal(settled, true)
audio.reject(new Error('late resume failure'))
await playing
await flush()
assert.equal(env.calls.includes('video:start'), false)
})
test('MediaBunny coordinator ignores stale seek failure after a newer seek succeeds', async () => {
const env = environment()
const first = deferred()
env.engine.video.seek = time => time === 1 ? first.promise : Promise.resolve()
const a = env.engine.seek(1)
await env.engine.seek(2)
first.reject(new Error('obsolete seek error'))
await a
await flush()
assert.equal(env.engine.error, null)
assert.equal(env.engine.seeking, false)
assert.equal(env.calls.includes('error'), false)
env.art.emit('destroy')
})
test('MediaBunny synchronous currentTime setter observes an active seek rejection after one error event', async () => {
const env = environment()
env.engine.video.seek = async () => {
throw new Error('seek rejected')
}
assert.equal(Reflect.set(env.canvas, 'currentTime', 3), true)
await flush()
assert.equal(env.engine.error.message, 'seek rejected')
assert.equal(env.engine.seeking, false)
assert.equal(env.calls.filter(name => name === 'error').length, 1)
env.art.emit('destroy')
})
async function loadingEnvironment() {
const env = environment()
const video = deferred()
const audio = deferred()
const metadata = {}
env.engine.video.load = (media, callback) => {
// This fixture represents a supported decoder whose metadata/completion are delayed.
env.engine.video.videoSink = {}
metadata.video = callback
return video.promise
}
env.engine.audio.load = (media, callback) => {
metadata.audio = callback
return audio.promise
}
const loading = env.engine.load(media)
await flush()
assert.equal(typeof metadata.video, 'function')
assert.equal(typeof metadata.audio, 'function')
return { ...env, video, audio, metadata, loading }
}
test('MediaBunny coordinator waits for both metadata participants and publishes readiness once', async () => {
const env = await loadingEnvironment()
env.metadata.video()
assert.equal(env.calls.includes('loadedmetadata'), false)
env.metadata.audio()
env.metadata.video()
env.metadata.audio()
env.video.resolve()
env.audio.resolve()
await env.loading
assert.deepEqual(env.calls.filter(name => ['loadedmetadata', 'durationchange', 'loadeddata', 'canplay', 'canplaythrough'].includes(name)), ['loadedmetadata', 'durationchange', 'loadeddata', 'canplay', 'canplaythrough'])
assert.equal(env.engine.readyState, 4)
env.art.emit('destroy')
})
for (const event of ['loadedmetadata', 'loadeddata']) {
test(`MediaBunny coordinator stops the old readiness sequence when ${event} starts another source`, async () => {
const env = await loadingEnvironment()
let replacement
env.canvas.events.addEventListener(event, () => {
env.engine.performLoad = async () => {}
replacement = env.engine.load('new.mp4')
})
env.metadata.video()
env.metadata.audio()
env.video.resolve()
env.audio.resolve()
await env.loading
await replacement
const cursor = env.calls.indexOf(event)
assert(cursor >= 0)
assert.equal(env.calls.slice(cursor + 1).includes('canplay'), false)
if (event === 'loadedmetadata')
assert.equal(env.calls.slice(cursor + 1).includes('durationchange'), false)
assert.equal(env.engine.readyState, 0)
env.art.emit('destroy')
})
}
test('MediaBunny coordinator keeps failed decoder setup from becoming ready through a later metadata callback', async () => {
const env = await loadingEnvironment()
env.video.reject(new Error('decoder unavailable'))
await env.loading
const cursor = env.calls.length
env.metadata.audio()
env.audio.resolve()
await flush()
assert.equal(env.engine.error.message, 'decoder unavailable')
assert.equal(env.engine.readyState, 0)
assert.deepEqual(env.calls.slice(cursor), [])
env.art.emit('destroy')
})
test('MediaBunny coordinator rejects a real trackless container before decoder or AudioContext setup', async () => {
const env = environment()
const bytes = tracklessMp4(fs.readFileSync(new URL('./browser/media/pattern.mp4', import.meta.url)))
let setups = 0
env.engine.video.load = async () => setups++
env.engine.audio.load = async () => setups++
await env.engine.load(new Blob([bytes]))
assert.equal(setups, 0)
assert.deepEqual({ ...env.engine.error }, { code: 4, message: 'Input has no audio or video tracks.' })
assert.equal(env.engine.readyState, 0)
assert.equal(env.calls.includes('loadedmetadata'), false)
assert.equal(env.calls.includes('canplay'), false)
env.art.emit('destroy')
})
for (const [method, query] of [['selectHlsQuality', 'getVideoTracks'], ['selectHlsAudio', 'getAudioTracks']]) {
for (const ending of ['source', 'destroy', 'active']) {
test(`MediaBunny ${method} ${ending} query rejection respects source ownership`, async () => {
const env = environment()
const pending = deferred()
const failure = new Error('track query failed')
env.engine.input = { [query]: () => pending.promise }
env.engine.media = { isHls: true }
let replaced = 0
env.engine.replaceTracks = async () => replaced++
const selecting = env.engine[method]('auto')
if (ending === 'source') {
env.engine.performLoad = async () => {}
await env.engine.load('new.mp4')
}
else if (ending === 'destroy') {
env.art.emit('destroy')
}
pending.reject(failure)
if (ending === 'active')
await assert.rejects(selecting, error => error === failure)
else await selecting
assert.equal(replaced, 0)
assert.equal(env.engine.error, null)
env.art.emit('destroy')
})
}
test(`MediaBunny ${method} retains active replacement rejection after media identity changes`, async () => {
const env = environment()
const track = { id: 1, canBePairedWith: () => true }
env.engine.input = { [query]: async () => [track] }
env.engine.media = { isHls: true, videoTrack: track, audioTrack: track }
const failure = new Error('replacement failed')
env.engine.replaceTracks = async () => {
env.engine.media = { ...env.engine.media }
throw failure
}
await assert.rejects(env.engine[method](1), error => error === failure)
env.art.emit('destroy')
})
}
function replacementEnvironment() {
const env = environment()
const track = { isLive: async () => false }
const input = { getDurationFromMetadata: async () => 8 }
env.engine.media = { input, videoTrack: track, audioTrack: null, isHls: true, videoMode: 'auto', audioMode: 'auto', duration: 8 }
// The already selected media has a working video decoder before a replacement starts.
env.engine.video.videoSink = {}
env.engine.video.load = async () => env.calls.push('video:load')
env.engine.audio.load = async () => env.calls.push('audio:load')
return env
}
test('MediaBunny track replacement preserves normal pause, readiness, seek and resume ordering', async () => {
const env = replacementEnvironment()
await env.engine.play()
env.calls.length = 0
await env.engine.replaceTracks({ videoMode: 'manual' })
assert.deepEqual(env.calls, ['audio:pause', 'video:stop', 'pause', 'seeking', 'waiting', 'video:load', 'audio:load', 'video:seek:0', 'audio:seek:0', 'loadedmetadata', 'durationchange', 'progress', 'loadeddata', 'canplay', 'canplaythrough', 'seeked', 'audio:play', 'video:start', 'play', 'playing'])
assert.equal(env.engine.readyState, 4)
assert.equal(env.engine.media.videoMode, 'manual')
env.art.emit('destroy')
})
test('MediaBunny track replacement honors pause during delayed decoder setup', async () => {
const env = replacementEnvironment()
await env.engine.play()
const loading = deferred()
env.engine.video.load = () => loading.promise
const replacing = env.engine.replaceTracks({ videoMode: 'manual' })
await flush()
env.engine.pause()
env.calls.length = 0
loading.resolve()
await replacing
assert.equal(env.engine.paused, true)
assert.equal(env.calls.includes('video:start'), false)
assert.equal(env.calls.includes('seeked'), true)
env.art.emit('destroy')
})
for (const ending of ['source', 'destroy']) {
test(`MediaBunny track replacement cannot seek or publish readiness after ${ending}`, async () => {
const env = replacementEnvironment()
const loading = deferred()
env.engine.video.load = () => loading.promise
const replacing = env.engine.replaceTracks({ videoMode: 'manual' })
await flush()
if (ending === 'destroy') {
env.art.emit('destroy')
}
else {
env.engine.performLoad = async () => {}
await env.engine.load('new.mp4')
}
env.calls.length = 0
loading.resolve()
await replacing
await flush()
assert.deepEqual(env.calls.filter(name => !['waiting', 'loadstart'].includes(name)), [])
env.art.emit('destroy')
})
}