Files
ArtPlayer/test/canvas-lifecycle.test.js

378 lines
14 KiB
JavaScript

import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Run the same candidate assertions against frozen source to establish failures.
import test from 'node:test'
import { canvasCandidate, canvasEnvironment, canvasHistorical } from './helpers/canvas.js'
const implementation = await canvasCandidate()
const listenerCount = env => [...env.listeners.values()].reduce((count, set) => count + set.size, 0)
test('Canvas rolls back private media when initial subtitle track insertion fails', async () => {
const env = await canvasEnvironment(implementation)
const failure = new Error('Initial track insertion failed')
env.video.appendChild = () => {
throw failure
}
assert.throws(() => env.factory()(env.art), error => error === failure)
assert.equal(listenerCount(env), 0)
assert.equal(env.timers.size, 0)
assert.equal(env.frames.size, 0)
assert.equal(env.video.parentNode, undefined)
assert.equal(env.video.src, undefined)
assert.equal(env.video.paused, true)
assert.deepEqual([env.canvas.width, env.canvas.height], [0, 0])
})
function deferred() {
let resolve, reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
test('Canvas candidate preserves native canvas methods, live video forwarding and normal resize/callback/event order', async () => {
const observations = []
for (const source of [...await canvasHistorical(), implementation]) {
const env = await canvasEnvironment(source)
const calls = []
const canvas = env.factory((ctx, video) => calls.push(ctx === env.context && video === env.video))(env.art)
canvas.src = 'movie.mp4'
await canvas.play()
env.art.emit('video:loadedmetadata')
env.art.emit('resize')
await env.flush()
observations.push({ same: canvas === env.canvas, url: env.video.src, paused: env.video.paused, native: canvas.toDataURL(), callbacks: calls, size: [canvas.width, canvas.height], padding: canvas.style.padding, events: env.emitted.map(event => event.name) })
}
for (const value of observations.slice(1)) assert.deepEqual(value, observations[0])
})
for (const operation of ['pause', 'destroy', 'source']) {
test(`Canvas candidate discards and closes bitmap that resolves after ${operation}`, async () => {
const pending = deferred()
let closed = 0
const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending.promise })
const canvas = env.factory()(env.art)
env.art.emit('video:play')
if (operation === 'pause')
env.art.emit('video:pause')
else if (operation === 'destroy')
env.art.destroy()
else canvas.src = 'replacement.mp4'
pending.resolve({ close() {
closed++
} })
await env.flush()
assert.equal(closed, 1)
assert.equal(env.draws.length, 0)
assert.equal(env.frames.size, 0)
assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:draw').length, 0)
})
}
test('Canvas candidate serializes repeated play and resize while discarding outdated frame results', async () => {
const pending = [deferred(), deferred()]
let count = 0
let closed = 0
const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending[count++].promise })
env.factory()(env.art)
env.art.emit('video:play')
env.art.emit('video:play')
env.art.emit('resize')
assert.equal(count, 1)
pending[0].resolve({ close() {
closed++
} })
await env.flush()
assert.equal(env.draws.length, 0)
assert.equal(count, 2)
const latest = { close() {
closed++
} }
pending[1].resolve(latest)
await env.flush()
assert.deepEqual(env.draws.map(args => args[0]), [latest])
assert.equal(closed, 2)
assert.equal(env.frames.size, 1)
env.art.destroy()
assert.equal(env.frames.size, 0)
})
test('Canvas candidate closes bitmap on draw error and preserves the original error before retry', async () => {
const failure = new Error('draw failed')
let closed = 0
const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.resolve({ close() {
closed++
} }), onDraw() {
throw failure
} })
env.factory()(env.art)
env.art.emit('video:play')
await env.flush()
assert.equal(closed, 1)
assert.equal(env.emitted.at(-1).args[0], failure)
assert.equal(env.frames.size, 1)
env.art.destroy()
})
test('Canvas candidate contains callback exceptions and stops reentrant destroy before draw event or RAF', async () => {
for (const throws of [false, true]) {
const env = await canvasEnvironment(implementation)
env.factory(() => {
env.art.destroy()
if (throws)
throw new Error('callback destroyed then failed')
})(env.art)
env.art.emit('video:play')
await env.flush()
assert.equal(env.frames.size, 0)
assert.equal(env.emitted.filter(event => event.name.startsWith('artplayerProxyCanvas:')).length, 0)
}
})
test('Canvas candidate destroy before deferred setup removes every host handler and timer', async () => {
const env = await canvasEnvironment(implementation)
const canvas = env.factory()(env.art)
const play = canvas.play
env.art.destroy()
canvas.src = 'late.mp4'
await play()
env.flushTimers()
assert.equal(env.proxies.length, 0)
assert.equal(env.timers.size, 0)
assert.equal(listenerCount(env), 0)
assert.notEqual(env.video.src, 'late.mp4')
assert.equal(env.video.paused, true)
})
test('Canvas candidate mounts and owns backing media, releases subscriptions and media on destroy', async () => {
const env = await canvasEnvironment(implementation)
env.factory()(env.art)
env.flushTimers()
assert.deepEqual(env.art.template.$player.children, [env.video])
assert.equal(env.proxies.length, 5)
env.art.destroy()
assert.deepEqual(env.art.template.$player.children, [])
assert.equal(env.proxies.length, 0)
assert.equal(listenerCount(env), 0)
assert.equal(env.video.paused, true)
assert.equal(env.calls.filter(call => call.name === 'load').length, 1)
})
test('Canvas candidate late registration returns inert canvas without resources', async () => {
const env = await canvasEnvironment(implementation)
env.art.destroy()
assert.equal(env.factory()(env.art), env.canvas)
env.flushTimers()
assert.equal(env.timers.size, 0)
assert.equal(listenerCount(env), 0)
assert.equal(env.proxies.length, 0)
})
test('Canvas candidate setup rollback preserves failure and removes partially registered handler', async () => {
const env = await canvasEnvironment(implementation)
const on = env.art.on
const failure = new Error('registration failed')
env.art.on = (...args) => {
on(...args)
throw failure
}
assert.throws(() => env.factory()(env.art), error => error === failure)
assert.equal(listenerCount(env), 0)
assert.equal(env.timers.size, 0)
})
test('Canvas candidate zero dimensions defer drawing and keep finite geometry until metadata', async () => {
const env = await canvasEnvironment(implementation)
env.video.videoWidth = 0
env.video.videoHeight = 0
env.factory()(env.art)
env.art.emit('resize')
await env.flush()
assert.equal(env.draws.length, 0)
assert(Number.isFinite(env.canvas.width) && Number.isFinite(env.canvas.height))
env.video.videoWidth = 320
env.video.videoHeight = 180
env.art.emit('video:loadedmetadata')
env.art.emit('resize')
await env.flush()
assert.equal(env.draws.length, 1)
})
test('Canvas candidate missing 2D context reports a capability error and does not spin a frame loop', async () => {
const env = await canvasEnvironment(implementation, { noContext: true })
env.factory()(env.art)
env.art.emit('video:play')
await env.flush()
assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 1)
assert.equal(env.frames.size, 0)
})
test('Canvas candidate RAF zero is cancelled and paused seek draws once without restarting playback', async () => {
const env = await canvasEnvironment(implementation, { firstFrame: 0 })
env.factory()(env.art)
env.art.emit('video:play')
await env.flush()
assert(env.frames.has(0))
env.art.emit('video:pause')
assert.equal(env.frames.size, 0)
const before = env.draws.length
env.art.emit('video:seeked')
await env.flush()
assert.equal(env.draws.length, before + 1)
assert.equal(env.frames.size, 0)
})
test('Canvas candidate waits for a decoded frame before requesting bitmap and then resumes drawing', async () => {
let allocated = 0
const env = await canvasEnvironment(implementation, { createImageBitmap: () => {
allocated++
return Promise.resolve({ close() {} })
} })
env.video.readyState = 1
env.factory()(env.art)
env.art.emit('video:play')
await env.flush()
assert.equal(allocated, 0)
env.video.readyState = 4
env.art.emit('resize')
await env.flush()
assert.equal(allocated, 1)
env.art.destroy()
})
test('Canvas candidate ignores late bitmap rejection after terminal disposal', async () => {
const pending = deferred()
const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending.promise })
env.factory()(env.art)
env.art.emit('video:play')
env.art.destroy()
pending.reject(new Error('late failure'))
await env.flush()
assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 0)
assert.equal(env.frames.size, 0)
})
test('Canvas candidate preserves active callback error identity and closes bitmap before invoking it', async () => {
let closed = false
const failure = new Error('callback failure')
const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.resolve({ close() {
closed = true
} }) })
env.factory(() => {
assert.equal(closed, true)
throw failure
})(env.art)
env.art.emit('resize')
await env.flush()
assert.equal(env.emitted.at(-1).args[0], failure)
assert.equal(env.frames.size, 0)
})
test('Canvas candidate deferred partial media registration failure rolls back native and host subscriptions', async () => {
const env = await canvasEnvironment(implementation)
const proxy = env.art.proxy
const failure = new Error('partial media registration')
env.art.proxy = (...args) => {
proxy(...args)
throw failure
}
env.factory()(env.art)
env.flushTimers()
assert.equal(env.proxies.length, 0)
assert.equal(listenerCount(env), 0)
assert.equal(env.video.parentNode, null)
assert.equal(env.emitted.at(-1).args[0], failure)
})
test('Canvas candidate handles destruction reentered before host registration returns', async () => {
const env = await canvasEnvironment(implementation)
const on = env.art.on
env.art.on = (name, handler) => {
if (name === 'destroy') {
env.art.isDestroy = true
handler()
}
return on(name, handler)
}
env.factory()(env.art)
assert.equal(listenerCount(env), 0)
assert.equal(env.timers.size, 0)
})
test('Canvas candidate attempts remaining cleanup when a host unsubscribe throws', async () => {
const env = await canvasEnvironment(implementation)
env.factory()(env.art)
env.flushTimers()
const off = env.art.off
const failure = new Error('unsubscribe failure')
env.art.off = (...args) => {
off(...args)
throw failure
}
assert.throws(() => env.art.destroy(), error => error === failure)
assert.equal(listenerCount(env), 0)
assert.equal(env.proxies.length, 0)
assert.equal(env.video.parentNode, null)
assert.equal(env.canvas.width, 0)
})
test('Canvas candidate drops bitmap errors from frames invalidated by a native seeking transition', async () => {
const pending = deferred()
const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending.promise })
env.factory()(env.art)
env.art.emit('video:play')
env.video.readyState = 1
env.video.seeking = true
pending.reject(new Error('old native frame unavailable'))
await env.flush()
assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 0)
env.art.destroy()
})
test('Canvas candidate defers only initial unavailable bitmap errors, without trusting zero frame counters as a global capability gate', async () => {
let frames = 0
const failure = Object.assign(new Error('first frame unavailable'), { name: 'InvalidStateError' })
const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.reject(failure) })
env.video.getVideoPlaybackQuality = () => ({ totalVideoFrames: frames })
env.factory()(env.art)
env.art.emit('resize')
await env.flush()
assert.equal(env.draws.length, 0)
assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 0)
frames = 1
env.art.emit('resize')
await env.flush()
assert.equal(env.emitted.at(-1).args[0], failure)
const direct = await canvasEnvironment(implementation)
direct.video.getVideoPlaybackQuality = () => ({ totalVideoFrames: 0 })
direct.factory()(direct.art)
direct.art.emit('resize')
await direct.flush()
assert.equal(direct.draws.length, 1)
})
test('Canvas candidate never classifies a user callback InvalidStateError as a missing native first frame', async () => {
const failure = Object.assign(new Error('callback invalid state'), { name: 'InvalidStateError' })
const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.resolve({ close() {} }) })
env.video.getVideoPlaybackQuality = () => ({ totalVideoFrames: 0 })
env.factory(() => {
throw failure
})(env.art)
env.art.emit('resize')
await env.flush()
assert.equal(env.emitted.at(-1).args[0], failure)
})
test('Canvas candidate preserves historical JavaScript calls with a falsy callback', async () => {
for (const source of [...await canvasHistorical(), implementation]) {
for (const option of [undefined, null, false, 0, '']) {
const env = await canvasEnvironment(source)
env.factory(option)(env.art)
env.art.emit('resize')
await env.flush()
assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 0, `${source.name}: ${String(option)}`)
assert.equal(env.emitted.at(-1).name, 'artplayerProxyCanvas:draw')
}
}
})