Files
ArtPlayer/refactor/scripts/jassub-failures.test.mjs

240 lines
11 KiB
JavaScript

import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Frozen failure observations use the repository runner.
import test from 'node:test'
import { jassubEnvironment, jassubHistorical } from '../../test/helpers/jassub.js'
const observations = []
const implementations = await jassubHistorical()
function environment(implementation) {
// Expose only VM platform constructors for controlled faults; vendor methods stay unchanged.
const code = `${implementation.code}\nObject.defineProperty(module.exports.default || module.exports, '__reviewPlatform', { value: { createCanvas: () => document.createElement('canvas'), setGlobal: (name, value) => { globalThis[name] = value }, ErrorEvent, Video: HTMLVideoElement } });`
const env = jassubEnvironment({ ...implementation, code })
env.platform = env.factory.__reviewPlatform
return env
}
function trackListeners(video) {
const listeners = new Map()
const add = video.addEventListener
const remove = video.removeEventListener
video.addEventListener = function (type, callback, ...options) {
if (!listeners.has(type))
listeners.set(type, new Set())
listeners.get(type).add(callback)
return add.call(this, type, callback, ...options)
}
video.removeEventListener = function (type, callback, ...options) {
listeners.get(type)?.delete(callback)
return remove.call(this, type, callback, ...options)
}
return () => [...listeners.values()].reduce((count, entries) => count + entries.size, 0)
}
function frames(env) {
const pending = new Map()
const cancelled = []
let next = 0
env.platform.Video.prototype.requestVideoFrameCallback = function (callback) {
pending.set(++next, { video: this, callback })
return next
}
env.platform.Video.prototype.cancelVideoFrameCallback = function (id) {
cancelled.push(id)
pending.delete(id)
}
return {
pending,
cancelled,
deliver(video, metadata) {
const entry = [...pending].find(([, row]) => row.video === video)
assert(entry, 'must deliver an actually queued frame callback')
const [id, row] = entry
pending.delete(id)
row.callback(0, metadata)
},
}
}
for (const implementation of implementations) {
test(`JASSUB failure baseline ${implementation.name}: fallback ratechange forwards the Event instead of the numeric media rate`, async () => {
const env = environment(implementation)
const { instance } = env.factory()(env.art)
await env.ready()
instance.setRate(2)
await env.flush()
assert.equal(env.workers[0].messages.at(-1).rate, 2)
env.art.video.playbackRate = 1.5
const event = new Event('ratechange')
env.art.video.dispatchEvent(event)
await env.flush()
const message = env.workers[0].messages.at(-1)
assert.equal(message.target, 'video')
assert.equal(message.rate, event)
assert.notEqual(message.rate, env.art.video.playbackRate)
observations.push({ implementation: implementation.name, case: 'rate-event-payload', mediaRate: 1.5, forwardedType: message.rate.type, directNumericRatePreserved: true })
instance.destroy()
})
test(`JASSUB failure baseline ${implementation.name}: direct destroy followed by art destroy throws on the removed container`, async () => {
const env = environment(implementation)
const active = trackListeners(env.art.video)
const { instance } = env.factory()(env.art)
await env.ready()
assert.equal(active(), 7)
instance.destroy()
assert.equal(active(), 0)
assert.equal(env.workers[0].terminated, 1)
assert.equal(env.parent.children.length, 1)
assert.throws(() => env.emit('destroy'), { code: 'ERR_ASSERTION' })
assert.throws(() => instance.destroy(), { code: 'ERR_ASSERTION' })
assert.equal(env.workers[0].terminated, 1)
observations.push({ implementation: implementation.name, case: 'repeated-destroy', throws: true, workerTerminations: 1 })
})
test(`JASSUB failure baseline ${implementation.name}: setVideo across parents makes destroy throw before releasing the worker`, async () => {
const env = environment(implementation)
const { instance } = env.factory()(env.art)
await env.ready()
const secondParent = new env.parent.constructor()
const secondVideo = secondParent.appendChild(new env.platform.Video())
const active = trackListeners(secondVideo)
instance.setVideo(secondVideo)
assert.equal(active(), 7)
assert.throws(() => instance.destroy(), { code: 'ERR_ASSERTION' })
assert.notEqual(instance._destroyed, true)
assert.equal(env.workers[0].terminated, 0)
assert.equal(env.parent.children.length, 2)
assert.equal(active(), 7)
observations.push({ implementation: implementation.name, case: 'set-video-parent', workerTerminations: 0, listenersRetained: active(), originalContainerRetained: true })
instance.setVideo(env.art.video)
instance.destroy()
})
test(`JASSUB failure baseline ${implementation.name}: a supplied canvas makes the adapter throw after allocating the worker`, async () => {
const env = environment(implementation)
const active = trackListeners(env.art.video)
const canvas = env.parent.appendChild(env.platform.createCanvas())
assert.throws(() => env.factory({ canvas })(env.art), { name: 'TypeError', message: /reading 'style'/ })
await env.flush()
assert.equal(env.workers.length, 1)
assert.equal(env.workers[0].terminated, 0)
assert.equal(env.workers[0].messages[0].target, 'init')
assert.equal(env.listeners.has('destroy'), false)
assert.equal(canvas.parentNode, env.parent)
assert.equal(active(), 7)
observations.push({ implementation: implementation.name, case: 'custom-canvas', workerTerminations: 0, listenersRetained: active(), hostDestroyHandlerRegistered: false, initSent: true })
env.workers[0].terminate()
})
test(`JASSUB failure baseline ${implementation.name}: Worker construction failure leaves the already inserted container and video listeners`, async () => {
const env = environment(implementation)
const active = trackListeners(env.art.video)
const failure = new DOMException('Controlled Worker construction failure', 'SecurityError')
env.platform.setGlobal('Worker', class {
constructor() { throw failure }
})
assert.throws(() => env.factory()(env.art), error => error === failure)
await env.flush()
assert.equal(env.workers.length, 0)
assert.equal(env.listeners.has('destroy'), false)
assert.equal(env.parent.children.length, 2)
assert.equal(env.parent.children[1].className, 'JASSUB')
assert.equal(active(), 7)
observations.push({ implementation: implementation.name, case: 'worker-construction-failure', originalErrorPreserved: true, insertedContainerRetained: true, listenersRetained: active() })
})
test(`JASSUB failure baseline ${implementation.name}: a previous video's queued frame drives the replacement video and creates a second loop`, async () => {
const env = environment(implementation)
const clock = frames(env)
const oldVideo = env.art.video
const { instance } = env.factory()(env.art)
await env.ready()
assert.equal(clock.pending.size, 1)
const newVideo = env.createVideo()
newVideo.currentTime = 9
instance.setVideo(newVideo)
await env.flush()
assert.equal(clock.pending.size, 2)
assert.equal(clock.cancelled.length, 0)
clock.deliver(oldVideo, { mediaTime: 123, width: 640, height: 360 })
await env.flush()
assert.equal(env.workers[0].messages.filter(item => item.target === 'demand').at(-1).time, 123)
assert.equal([...clock.pending.values()].filter(row => row.video === newVideo).length, 2)
observations.push({ implementation: implementation.name, case: 'old-video-frame', replacementCurrentTime: newVideo.currentTime, staleDemandTime: 123, replacementPendingCallbacks: 2, cancelledCallbacks: 0 })
instance.destroy()
})
test(`JASSUB containment baseline ${implementation.name}: destroy before ready terminates and detaches, but initialization continues after termination`, async () => {
const env = environment(implementation)
const active = trackListeners(env.art.video)
const { instance } = env.factory()(env.art)
const worker = env.workers[0]
const post = worker.postMessage
const afterTermination = []
worker.postMessage = function (message) {
if (this.terminated)
afterTermination.push(message.target)
return post.call(this, message)
}
let settled = false
instance._loaded.then(() => {
settled = true
})
instance.setTrack('[Script Info]')
instance.destroy()
await env.flush()
assert.equal(worker.terminated, 1)
assert.equal(env.parent.children.length, 1)
assert.equal(active(), 0)
assert.deepEqual(afterTermination, ['init'])
assert.equal(settled, false)
assert.equal(worker.messages.some(item => item.target === 'setTrack'), false)
observations.push({ implementation: implementation.name, case: 'destroy-before-ready', workerTerminations: 1, listenersRetained: 0, postAfterTermination: afterTermination, loadedSettledWithinFixtureFlush: settled })
})
test(`JASSUB containment baseline ${implementation.name}: Worker errors reach the instance and explicit host destroy still releases resources`, async () => {
const env = environment(implementation)
const active = trackListeners(env.art.video)
const { instance } = env.factory()(env.art)
const failure = new Error('Controlled Worker script load failure')
const errors = []
instance.addEventListener('error', event => errors.push(event.error))
env.workers[0].onerror(new env.platform.ErrorEvent('error', { error: failure }))
await env.flush()
assert.deepEqual(errors, [failure])
assert.equal(env.errors.at(-1), failure)
assert.equal(env.workers[0].terminated, 0)
assert.equal(active(), 7)
env.emit('destroy')
assert.equal(env.workers[0].terminated, 1)
assert.equal(env.parent.children.length, 1)
assert.equal(active(), 0)
observations.push({ implementation: implementation.name, case: 'worker-error', exactErrorForwarded: true, automaticallyTerminated: false, explicitHostCleanup: true })
})
test(`JASSUB containment baseline ${implementation.name}: the outstanding frame after destroy does no work and does not rearm`, async () => {
const env = environment(implementation)
const clock = frames(env)
const { instance } = env.factory()(env.art)
await env.ready()
instance.destroy()
await env.flush()
assert.equal(clock.pending.size, 1)
assert.equal(clock.cancelled.length, 0)
const before = env.workers[0].messages.length
clock.deliver(env.art.video, { mediaTime: 123, width: 640, height: 360 })
await env.flush()
assert.equal(env.workers[0].messages.length, before)
assert.equal(clock.pending.size, 0)
observations.push({ implementation: implementation.name, case: 'frame-after-destroy', activelyCancelled: false, callbackDoesNoWork: true, callbackRearms: false })
})
}
test.after(() => {
if (process.env.ARTPLAYER_JASSUB_FAILURE_REPORT)
fs.writeFileSync(process.env.ARTPLAYER_JASSUB_FAILURE_REPORT, `${JSON.stringify(observations, null, 2)}\n`)
})