mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
279 lines
13 KiB
JavaScript
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`)
|