Files
ArtPlayer/test/danmuku-failures.test.js

279 lines
13 KiB
JavaScript

import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Historical failures are explicit Node observations.
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { danmukuEnvironment, danmukuHistorical, deferred } from './helpers/danmuku.js'
const implementations = await danmukuHistorical()
const cache = fileURLToPath(new URL('../refactor/.cache/danmuku02-controlled/', import.meta.url))
fs.mkdirSync(cache, { recursive: true })
const evidenceDirectory = fs.mkdtempSync(path.join(cache, 'run-'))
function isolated(implementation, scenario) {
const program = `
import fs from 'node:fs';
import vm from 'node:vm';
import { danmukuEnvironment } from './test/helpers/danmuku.js';
const implementation = JSON.parse(fs.readFileSync(0, 'utf8'));
const scenario = ${JSON.stringify(scenario)};
let settled = false;
let env;
process.on('uncaughtExceptionMonitor', (error, origin) => {
process.stdout.write(JSON.stringify({ stage: 'fatal', origin, error: error.message, settled, errors: env?.events.filter(e => e.name === 'artplayerPluginDanmuku:error').length }) + '\\n');
});
if (scenario === 'heatmap-zero' || scenario === 'heatmap-positive') {
env = danmukuEnvironment(implementation, { heatmapWidth: 99 });
env.factory({ danmuku: [], heatmap: scenario === 'heatmap-positive' ? { sampling: 1 } : true })(env.art);
const sandbox = vm.createContext({ run: () => env.art.emit('ready') });
process.stdout.write('heatmap-ready\\n');
try {
vm.runInContext('run()', sandbox, { timeout: 100 });
process.stdout.write(JSON.stringify({ stage: 'complete' }) + '\\n');
} catch (error) {
if (error.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw error;
process.stdout.write(JSON.stringify({ stage: 'bounded-timeout', code: error.code }) + '\\n');
}
process.exit(0);
}
if (scenario === 'beforeVisible-reject') {
env = danmukuEnvironment(implementation);
const plugin = env.factory({ danmuku: [], beforeVisible: () => Promise.reject(new Error('beforeVisible-original')) })(env.art);
await plugin.emit({ text: 'visible', time: 10 });
env.art.playing = true;
env.art.emit('video:play');
// Browser rAF ignores a callback's returned Promise. Do not observe it here.
env.frame();
} else {
env = danmukuEnvironment(implementation, { fetch: async () => {
if (scenario === 'fetch-reject') throw new Error('fetch-original');
return { text: async () => { throw new Error('text-original'); } };
} });
const plugin = env.factory({ danmuku: [] })(env.art);
plugin.load('/controlled.xml').then(() => settled = true, () => settled = true);
}
setTimeout(() => process.stdout.write('unexpected-survival\\n'), 200);
`
const child = spawnSync(process.execPath, ['--unhandled-rejections=strict', '--input-type=module', '-e', program], {
cwd: fileURLToPath(new URL('../', import.meta.url)),
input: JSON.stringify(implementation),
encoding: 'utf8',
timeout: 3000,
maxBuffer: 2 * 1024 * 1024,
})
const evidence = { implementation: implementation.name, scenario, status: child.status, signal: child.signal, errorCode: child.error?.code ?? null, stdout: child.stdout, stderr: child.stderr }
fs.writeFileSync(path.join(evidenceDirectory, `${implementation.name}-${scenario}.json`), `${JSON.stringify(evidence, null, 2)}\n`)
assert.equal(child.error, undefined, `Isolated process infrastructure failed: ${child.error?.message}`)
return evidence
}
for (const implementation of implementations) {
for (const scenario of ['fetch-reject', 'text-reject']) {
test(`danmuku ${implementation.name}: historical ${scenario} crashes the isolated process while public load stays pending`, () => {
const evidence = isolated(implementation, scenario)
assert.equal(evidence.status, 1)
const record = JSON.parse(evidence.stdout.trim())
assert.equal(record.stage, 'fatal')
assert.equal(record.origin, 'unhandledRejection')
assert.equal(record.settled, false)
assert.equal(record.errors, 0)
assert.equal(record.error, scenario === 'fetch-reject' ? 'fetch-original' : 'text-original')
})
}
test(`danmuku ${implementation.name}: historical beforeVisible rejection is an unobserved animation callback Promise`, () => {
const evidence = isolated(implementation, 'beforeVisible-reject')
assert.equal(evidence.status, 1)
const record = JSON.parse(evidence.stdout.trim())
assert.equal(record.error, 'beforeVisible-original')
assert.equal(record.origin, 'unhandledRejection')
assert.equal(record.errors, 0)
})
test(`danmuku ${implementation.name}: historical heatmap width 99 blocks at default sampling zero but sampling 1 completes`, () => {
const zero = isolated(implementation, 'heatmap-zero')
const positive = isolated(implementation, 'heatmap-positive')
assert.equal(zero.status, 0)
assert.equal(positive.status, 0)
assert.match(zero.stdout, /heatmap-ready\n\{"stage":"bounded-timeout","code":"ERR_SCRIPT_EXECUTION_TIMEOUT"\}/u)
assert.match(positive.stdout, /heatmap-ready\n\{"stage":"complete"\}/u)
})
for (const event of ['error', 'empty-xml']) {
test(`danmuku ${implementation.name}: historical Bilibili ${event} leaves loading pending and parser worker alive`, async () => {
const env = danmukuEnvironment(implementation, event === 'empty-xml' ? { fetch: async () => ({ text: async () => '' }) } : {})
const plugin = env.factory({ danmuku: [] })(env.art)
let settled = false
plugin.load('/controlled.xml').then(() => settled = true, () => settled = true)
await env.flush()
assert.equal(env.workers.length, 2)
const worker = env.workers[1]
assert.equal(worker.messages.length, 1)
if (event === 'error') {
assert.equal(worker.onerror, undefined)
worker.fail(new Error('parser worker failure'))
}
else {
assert.equal(worker.messages[0].message.xml, '')
// Execute the actual Blob, not a reimplementation of its empty-input branch.
await worker.execute(worker.messages[0].message)
}
await env.flush()
assert.equal(settled, false)
env.destroy()
assert.equal(worker.terminated, false, 'Parser worker is not owned by the Danmuku destroy path')
})
}
test(`danmuku ${implementation.name}: historical successful parser leaves its Blob URL allocated`, async () => {
const env = danmukuEnvironment(implementation)
const plugin = env.factory({ danmuku: [] })(env.art)
const pending = plugin.load('/controlled.xml')
await env.flush()
const worker = env.workers[1]
await worker.execute(worker.messages[0].message)
const internal = await pending
assert.equal(internal.queue.length, 1)
assert.equal(internal.queue[0].text, 'xml')
assert.equal(worker.terminated, true)
assert.equal(env.urls.has(worker.url), true)
assert.equal(env.revoked.includes(worker.url), false)
env.destroy()
})
test(`danmuku ${implementation.name}: historical parser worker completion emits new data after player destruction`, async () => {
const env = danmukuEnvironment(implementation)
const plugin = env.factory({ danmuku: [] })(env.art)
const pending = plugin.load('/controlled.xml')
await env.flush()
const worker = env.workers[1]
env.destroy()
const count = env.events.length
assert.equal(worker.terminated, false)
await worker.execute(worker.messages[0].message)
const internal = await pending
assert.equal(internal.queue[0].text, 'xml')
assert.deepEqual(env.events.slice(count).map(event => event.name), ['artplayerPluginDanmuku:loaded'])
assert.equal(worker.terminated, true)
})
test(`danmuku ${implementation.name}: historical overlapping replacement loads let the oldest completion overwrite newer data`, async () => {
const env = danmukuEnvironment(implementation)
const plugin = env.factory({ danmuku: [] })(env.art)
const first = deferred()
const second = deferred()
const jobs = [first, second]
// Change an additional scalar because the historical function-only config bug is separate.
plugin.config({ danmuku: () => jobs.shift().promise, opacity: 0.9 })
const older = plugin.load()
const newer = plugin.load()
second.resolve([{ text: 'newer', time: 10 }])
const internal = await newer
assert.deepEqual(Array.from(internal.queue, item => item.text), ['newer'])
first.resolve([{ text: 'older', time: 10 }])
await older
assert.deepEqual(Array.from(internal.queue, item => item.text), ['older'])
env.destroy()
})
test(`danmuku ${implementation.name}: historical asynchronous input mutates queue and emits loaded after destroy`, async () => {
const env = danmukuEnvironment(implementation)
const plugin = env.factory({ danmuku: [] })(env.art)
const input = deferred()
const pending = plugin.load(() => input.promise)
env.destroy()
const count = env.events.length
input.resolve([{ text: 'late', time: 10 }])
const internal = await pending
assert.deepEqual(Array.from(internal.queue, item => item.text), ['late'])
assert.deepEqual(env.events.slice(count).map(event => event.name), ['artplayerPluginDanmuku:loaded'])
})
test(`danmuku ${implementation.name}: historical pending beforeVisible allocates DOM and posts to a terminated worker after destroy`, async () => {
const visible = deferred()
const env = danmukuEnvironment(implementation)
const plugin = env.factory({ danmuku: [], beforeVisible: () => visible.promise })(env.art)
await plugin.emit({ text: 'late visible', time: 10 })
env.art.playing = true
env.art.emit('video:play')
env.frame()
await env.flush()
assert.equal(env.art.template.$danmuku.children.length, 0)
env.destroy()
visible.resolve(true)
await env.flush()
assert.equal(env.art.template.$danmuku.children.length, 1)
assert.equal(env.workers[0].messages.length, 1)
assert.equal(env.workers[0].messages[0].afterTerminate, true)
assert.equal(env.events.filter(event => event.name === 'artplayerPluginDanmuku:visible').length, 0)
})
test(`danmuku ${implementation.name}: historical worker response continuation still mutates state after destroy`, async () => {
const env = danmukuEnvironment(implementation)
const plugin = env.factory({ danmuku: [] })(env.art)
await plugin.emit({ text: 'pending position', time: 10 })
env.art.playing = true
env.art.emit('video:play')
const frame = env.frame()
await env.flush()
const worker = env.workers[0]
const { id } = worker.messages[0].message
const internal = plugin.show()
// Delivery starts before terminate; its awaiting rAF continuation runs afterward.
// This does not require a native Worker to dispatch a new event after terminate.
worker.deliver({ id, result: 10 })
env.destroy()
assert.equal(internal.queue[0].$state, 'wait')
await frame
assert.equal(internal.queue[0].$state, 'ready')
assert.equal(internal.queue[0].$ref, null)
assert.equal(env.frames.size, 0)
assert.equal(env.events.filter(event => event.name === 'artplayerPluginDanmuku:visible').length, 0)
})
test(`danmuku ${implementation.name}: historical play and playing schedule duplicate loops and overwrite pending worker response handlers`, async () => {
const env = danmukuEnvironment(implementation)
const plugin = env.factory({ danmuku: [] })(env.art)
await plugin.emit({ text: 'two loops', time: 10 })
env.art.playing = true
env.art.emit('video:play')
env.art.emit('video:playing')
assert.equal(env.frames.size, 2)
let firstSettled = false
env.frame().then(() => firstSettled = true)
await env.flush()
env.tick(1)
const second = env.frame()
await env.flush()
const worker = env.workers[0]
assert.equal(worker.messages.length, 2)
const firstId = worker.messages[0].message.id
const secondId = worker.messages[1].message.id
assert.notEqual(firstId, secondId)
worker.deliver({ id: firstId, result: 10 })
await env.flush()
assert.equal(firstSettled, false)
worker.deliver({ id: secondId, result: 10 })
await second
assert.equal(firstSettled, false)
assert.equal(env.events.filter(event => event.name === 'artplayerPluginDanmuku:visible').length, 1)
env.destroy()
})
test(`danmuku ${implementation.name}: historical duplicate start then pause cancels only the latest scheduled frame`, () => {
const env = danmukuEnvironment(implementation)
env.factory({ danmuku: [] })(env.art)
env.art.emit('video:play')
env.art.emit('video:playing')
env.art.emit('video:pause')
assert.equal(env.frames.size, 1)
env.destroy()
assert.equal(env.frames.size, 1)
})
}
process.stdout.write(`Danmuku controlled subprocess evidence: ${evidenceDirectory}\n`)