refactor(danmuku): [PKG-DANMUKU-03] own input loading parsing and configuration

This commit is contained in:
Harvey Zhao committed 2026-09-13 18:35:28 +08:00
1 parent d3c91af92c
commit 0b2efb07f1
26 files changed
+9167 -503

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
import fs from 'node:fs'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
let candidate
test.beforeAll(async () => {
candidate = process.env.ARTPLAYER_DANMUKU_ARTIFACT
? fs.readFileSync(process.env.ARTPLAYER_DANMUKU_ARTIFACT, 'utf8')
: await compilePackage('artplayer-plugin-danmuku', 'umd')
})
async function setup(page, core, testInfo) {
const external = []
const origin = new URL(testInfo.project.use.baseURL).origin
await page.route('**/*', (route) => {
if (new URL(route.request().url()).origin !== origin) {
external.push(route.request().url())
return route.abort()
}
return route.continue()
})
await page.goto(`/test/player.html?core=${core}`)
await page.evaluate(() => {
const evidence = window.inputEvidence = { urls: [], revoked: [], workers: [], events: [] }
const create = URL.createObjectURL.bind(URL)
const revoke = URL.revokeObjectURL.bind(URL)
URL.createObjectURL = (blob) => {
const url = create(blob)
evidence.urls.push(url)
return url
}
URL.revokeObjectURL = (url) => {
evidence.revoked.push(url)
return revoke(url)
}
const NativeWorker = window.Worker
window.Worker = class extends NativeWorker {
constructor(url, options) {
super(url, options)
this.observation = { url: String(url), terminated: 0, replies: [] }
evidence.workers.push(this.observation)
this.addEventListener('message', event => this.observation.replies.push(event.data))
}
terminate() {
this.observation.terminated++
return super.terminate()
}
}
})
await page.addScriptTag({ content: candidate })
await page.evaluate(() => {
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true })
for (const name of ['show', 'hide', 'config', 'reset', 'loaded', 'error', 'destroy']) {
window.art.on(`artplayerPluginDanmuku:${name}`, value => window.inputEvidence.events.push({ name, count: Array.isArray(value) ? value.length : undefined, error: name === 'error' ? String(value) : undefined }))
}
window.art.plugins.add(window.artplayerPluginDanmuku({ danmuku: [], heatmap: false }))
window.inputPlugin = window.art.plugins.artplayerPluginDanmuku
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await testInfo.attach('danmuku-input-build', { contentType: 'application/json', body: JSON.stringify({ core, artifact: process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'in-memory source UMD', sha256: hash(candidate), worker: 'Native Blob Worker with observation only', media: '/test/pattern.mp4' }) })
return external
}
for (const core of ['published', 'candidate']) {
test(`Danmuku input native Worker startup failure falls back locally (${core})`, async ({ page }, testInfo) => {
const external = await setup(page, core, testInfo)
await page.route('**/test/fallback.xml', route => route.fulfill({ contentType: 'text/xml', body: '<i><d p="0,5,25,255,123,0,u,1">FALLBACK</d></i>' }))
const result = await page.evaluate(async () => {
const NativeWorker = window.Worker
const createdBefore = window.inputEvidence.urls.length
const workersBefore = window.inputEvidence.workers.length
window.Worker = class extends NativeWorker {
constructor(url, options) {
const fault = URL.createObjectURL(new Blob(['throw new Error("controlled parser startup failure")'], { type: 'application/javascript' }))
super(fault, options)
URL.revokeObjectURL(fault)
}
}
const internal = await window.inputPlugin.load('/test/fallback.xml')
const result = { queue: internal.queue.map(({ text, time, mode }) => ({ text, time, mode })), workers: window.inputEvidence.workers.slice(workersBefore), urls: window.inputEvidence.urls.slice(createdBefore), revoked: window.inputEvidence.revoked, errors: window.inputEvidence.events.filter(event => event.name === 'error') }
window.art.destroy()
return result
})
expect(result.queue).toEqual([{ text: 'FALLBACK', time: 0, mode: 1 }])
expect(result.workers).toHaveLength(1)
expect(result.workers[0].terminated).toBe(1)
expect(result.urls).toHaveLength(2)
for (const url of result.urls)
expect(result.revoked.filter(item => item === url)).toHaveLength(1)
expect(result.errors).toEqual([])
expect(external).toEqual([])
await testInfo.attach('danmuku-native-worker-fault', { contentType: 'application/json', body: JSON.stringify({ ...result, fault: 'Test replaces only the newly created native parser Worker script with a thrown startup Error; fetch, fallback parser and error dispatch remain real.' }) })
})
test(`Danmuku input native parser cleanup and rejected fetch (${core})`, async ({ page }, testInfo) => {
const external = await setup(page, core, testInfo)
await page.route('**/test/input.xml', route => route.fulfill({ contentType: 'text/xml', body: '<i><d p="0,1,25,255,123,0,user,456"> A &amp; B </d><d p="2,4,20,16777215,124,0,u,789">TOP</d></i>' }))
await page.route('**/test/empty.xml', route => route.fulfill({ contentType: 'text/xml', body: '' }))
await page.route('**/test/rejected.xml', route => route.abort('failed'))
const result = await page.evaluate(async () => {
const plugin = window.inputPlugin
const start = window.inputEvidence.workers.length
const internal = await plugin.load('/test/input.xml')
const parsed = internal.queue.map(({ text, time, mode, color, userID, rowID }) => ({ text, time, mode, color, userID, rowID }))
const emptyIdentity = await plugin.load('/test/empty.xml') === internal
let rejection
try {
await plugin.load('/test/rejected.xml')
}
catch (error) { rejection = String(error) }
const parserWorkers = window.inputEvidence.workers.slice(start)
const beforeDestroy = internal.queue.length
window.art.destroy()
return { parsed, emptyIdentity, beforeDestroy, rejection, parserWorkers, ...window.inputEvidence }
})
expect(result.parsed).toEqual([{ text: 'A & B', time: 0, mode: 0, color: '#ff', userID: 'user', rowID: 456 }, { text: 'TOP', time: 2, mode: 2, color: '#ffffff', userID: 'u', rowID: 789 }])
expect(result.emptyIdentity).toBe(true)
expect(result.beforeDestroy).toBe(2)
expect(result.rejection).toBeTruthy()
expect(result.events.filter(event => event.name === 'error')).toHaveLength(1)
expect(result.parserWorkers.length).toBeGreaterThanOrEqual(1)
for (const worker of result.parserWorkers) {
expect(worker.terminated).toBe(1)
expect(result.revoked.filter(url => url === worker.url)).toHaveLength(1)
expect(worker.replies).toHaveLength(1)
expect(Array.isArray(worker.replies[0].danmus)).toBe(true)
}
expect(external).toEqual([])
await testInfo.attach('danmuku-native-parser', { contentType: 'application/json', body: JSON.stringify(result) })
})
test(`Danmuku input replacement append and destruction (${core})`, async ({ page }, testInfo) => {
const external = await setup(page, core, testInfo)
const result = await page.evaluate(async () => {
const plugin = window.inputPlugin
const internal = plugin.config({ danmuku: [] })
const initialEvents = window.inputEvidence.events.map(event => event.name)
let resolveOld
plugin.config({ danmuku: () => new Promise(resolve => resolveOld = resolve) })
const old = plugin.load()
plugin.config({ danmuku: [{ text: 'new', time: 0 }] })
const latestIdentity = await plugin.load() === internal
resolveOld([{ text: 'obsolete' }])
const oldIdentity = await old === internal
let resolveA
let resolveB
const a = plugin.load(() => new Promise(resolve => resolveA = resolve))
const b = plugin.load(() => new Promise(resolve => resolveB = resolve))
resolveB([{ text: 'append B' }])
await b
resolveA([{ text: 'append A' }])
await a
const queue = internal.queue.map(item => ({ text: item.text, time: item.time }))
let resolveLate
const late = plugin.load(() => new Promise(resolve => resolveLate = resolve))
const loadedBefore = window.inputEvidence.events.filter(event => event.name === 'loaded').length
window.art.destroy()
resolveLate([{ text: 'after destroy' }])
const destroyedIdentity = await late === internal
return { initialEvents, latestIdentity, oldIdentity, destroyedIdentity, queue, finalQueue: internal.queue.map(item => item.text), loadedBefore, loadedAfter: window.inputEvidence.events.filter(event => event.name === 'loaded').length, events: window.inputEvidence.events }
})
expect(result.initialEvents.slice(0, 4)).toEqual(['show', 'config', 'reset', 'loaded'])
expect(result.latestIdentity && result.oldIdentity && result.destroyedIdentity).toBe(true)
expect(result.queue.map(item => item.text)).toEqual(['new', 'append B', 'append A'])
expect(result.queue[0].time).toBe(0)
expect(result.finalQueue).toEqual(['new', 'append B', 'append A'])
expect(result.loadedAfter).toBe(result.loadedBefore)
expect(result.events.filter(event => event.name === 'error')).toEqual([])
expect(external).toEqual([])
await testInfo.attach('danmuku-native-input-ownership', { contentType: 'application/json', body: JSON.stringify(result) })
})
test(`Danmuku config callback replacement and validation (${core})`, async ({ page }, testInfo) => {
const external = await setup(page, core, testInfo)
const result = await page.evaluate(async () => {
const plugin = window.inputPlugin
const receiver = []
const next = function (item) {
receiver.push(this === plugin.option)
return item.text !== 'excluded'
}
const internal = plugin.config({ filter: next })
const count = window.inputEvidence.events.filter(event => event.name === 'config').length
const unchangedIdentity = plugin.config({ filter: next, margin: [...plugin.option.margin] }) === internal
const unchangedCount = window.inputEvidence.events.filter(event => event.name === 'config').length
const previous = plugin.option
let error
try {
plugin.config({ speed: 'invalid' })
}
catch (failure) { error = String(failure) }
await plugin.emit({ text: 'excluded', time: 0 })
const input = { text: 'included', time: 0 }
const emittedIdentity = await plugin.emit(input) === internal
const result = { unchangedIdentity, count, unchangedCount, unchangedOption: plugin.option === previous, filterIdentity: plugin.option.filter === next, error, receiver, emittedIdentity, input, queue: internal.queue.map(item => ({ text: item.text, time: item.time })) }
window.art.destroy()
return result
})
expect(result.unchangedIdentity && result.unchangedOption && result.filterIdentity && result.emittedIdentity).toBe(true)
expect(result.unchangedCount).toBe(result.count)
expect(result.error).toBeTruthy()
expect(result.receiver).toEqual([true, true])
expect(result.queue).toEqual([{ text: 'included', time: 0 }])
expect(result.input).toMatchObject({ time: 0, mode: 0, color: '#FFFFFF', style: {} })
expect(external).toEqual([])
await testInfo.attach('danmuku-native-config', { contentType: 'application/json', body: JSON.stringify(result) })
})
}
+355
View File
@@ -0,0 +1,355 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Candidate compatibility and failure tests use the Node runner.
import test from 'node:test'
import { setImmediate as nextTurn } from 'node:timers/promises'
import { danmukuCandidate, danmukuCandidateEnvironment } from './helpers/danmuku-candidate.js'
import { deferred } from './helpers/danmuku.js'
const implementation = await danmukuCandidate()
const prefix = `Danmuku ${implementation.name}`
const names = env => env.events.map(event => event.name)
const loaded = env => env.events.filter(event => event.name === 'artplayerPluginDanmuku:loaded')
const failures = env => env.events.filter(event => event.name === 'artplayerPluginDanmuku:error')
function fixture(option = { danmuku: [] }) {
const env = danmukuCandidateEnvironment(implementation)
const plugin = env.factory(option)(env.art)
return { env, plugin }
}
test(`${prefix}: synchronous empty initialization and load retain method return identity and getter surface`, async () => {
const { env, plugin } = fixture()
assert.equal(typeof plugin.then, 'undefined')
assert.deepEqual(Object.keys(plugin), ['name', 'emit', 'load', 'config', 'hide', 'show', 'reset', 'mount', 'option', 'isHide', 'isStop'])
assert.deepEqual(names(env), ['artplayerPluginDanmuku:show', 'artplayerPluginDanmuku:config', 'artplayerPluginDanmuku:reset', 'artplayerPluginDanmuku:loaded'])
const internal = plugin.show()
assert.notEqual(internal, plugin)
assert.equal(internal.name, undefined)
const start = env.events.length
const promise = plugin.load()
assert.equal(typeof promise.then, 'function')
assert.deepEqual(names(env).slice(start), ['artplayerPluginDanmuku:reset', 'artplayerPluginDanmuku:loaded'], 'Empty arrays finish the visible work before the first await')
assert.equal(await promise, internal)
const emit = plugin.emit
assert.equal(await emit({ text: 'bound', time: 12 }), internal)
assert.equal(plugin.hide(), internal)
assert.equal(plugin.reset(), internal)
env.destroy()
})
test(`${prefix}: nonempty array load inserts the first row synchronously and resolves to internal state`, async () => {
const { env, plugin } = fixture()
const internal = plugin.show()
const count = loaded(env).length
const pending = plugin.load([{ text: 'first', time: 1 }, { text: 'second', time: 2 }])
assert.deepEqual(Array.from(internal.queue, row => row.text), ['first'])
assert.equal(loaded(env).length, count)
assert.equal(await pending, internal)
assert.deepEqual(Array.from(internal.queue, row => row.text), ['first', 'second'])
assert.equal(loaded(env).at(-1).args[0], internal.queue)
env.destroy()
})
test(`${prefix}: explicit targets append and omitted target replaces without changing configured input`, async () => {
const { env, plugin } = fixture()
const configured = [{ text: 'configured', time: 2 }]
plugin.config({ danmuku: configured })
const internal = await plugin.load()
await plugin.load([{ text: 'append', time: 3 }])
assert.equal(plugin.option.danmuku, configured)
assert.deepEqual(Array.from(internal.queue, row => row.text), ['configured', 'append'])
await plugin.load(undefined)
assert.deepEqual(Array.from(internal.queue, row => row.text), ['configured'])
env.destroy()
})
test(`${prefix}: independent asynchronous appends survive reversed completion and a replacement`, async () => {
const { env, plugin } = fixture()
const first = deferred()
const second = deferred()
const a = plugin.load(() => first.promise)
const b = plugin.load(() => second.promise)
plugin.config({ danmuku: [{ text: 'replacement', time: 1 }] })
const internal = await plugin.load()
second.resolve([{ text: 'second completed first', time: 2 }])
assert.equal(await b, internal)
first.resolve([{ text: 'first completed last', time: 3 }])
assert.equal(await a, internal)
assert.deepEqual(Array.from(internal.queue, row => row.text), ['replacement', 'second completed first', 'first completed last'])
env.destroy()
})
test(`${prefix}: replacement supersedes only the older replacement and settles it before its input completes`, async () => {
const { env, plugin } = fixture()
const first = deferred()
const second = deferred()
const jobs = [first, second]
plugin.config({ danmuku: () => jobs.shift().promise })
const internal = plugin.show()
let oldSettled = false
const a = plugin.load().then((value) => {
oldSettled = true
return value
})
const b = plugin.load()
await env.flush()
assert.equal(oldSettled, true, 'Cancellation must settle the public Promise without awaiting a stale source')
assert.equal(await a, internal)
second.resolve([{ text: 'current', time: 1 }])
await b
const count = env.events.length
first.resolve([{ text: 'obsolete', time: 2 }])
await env.flush()
assert.deepEqual(Array.from(internal.queue, row => row.text), ['current'])
assert.equal(env.events.length, count)
env.destroy()
})
test(`${prefix}: destroyed pending replacement and appends settle without late state or error events`, async () => {
const { env, plugin } = fixture()
const replacement = deferred()
const append = deferred()
plugin.config({ danmuku: () => replacement.promise })
const internal = plugin.show()
const pending = [plugin.load(), plugin.load(() => append.promise)]
let settled = false
const all = Promise.all(pending).then((results) => {
settled = true
return results
})
env.destroy()
await env.flush()
assert.equal(settled, true, 'Destroy settles all owned loads without depending on their unresolved inputs')
assert.deepEqual(await all, [internal, internal])
const count = env.events.length
replacement.resolve([{ text: 'late', time: 1 }])
append.reject(new Error('late append failure'))
await nextTurn()
assert.equal(internal.queue.length, 0)
assert.equal(env.events.length, count)
assert.equal(failures(env).length, 0)
})
test(`${prefix}: reset event reentry can replace input without an older load clearing or adding rows`, async () => {
const { env, plugin } = fixture()
plugin.config({ danmuku: [{ text: 'old', time: 1 }] })
let nested
let entered = false
env.art.on('artplayerPluginDanmuku:reset', () => {
if (entered)
return
entered = true
plugin.config({ danmuku: [{ text: 'new', time: 2 }] })
nested = plugin.load()
})
const count = loaded(env).length
const internal = await plugin.load()
await nested
assert.deepEqual(Array.from(internal.queue, row => row.text), ['new'])
assert.equal(loaded(env).length, count + 1)
env.destroy()
})
for (const action of ['replace', 'destroy']) {
test(`${prefix}: filter synchronous ${action} reentry prevents the obsolete row from being pushed`, async () => {
const { env, plugin } = fixture()
let nested
plugin.config({
danmuku: [{ text: 'old1', time: 1 }, { text: 'old2', time: 2 }],
filter(row) {
if (row.text === 'old1') {
if (action === 'destroy') {
env.destroy()
}
else {
plugin.config({ danmuku: [{ text: 'new', time: 3 }] })
nested = plugin.load()
}
}
return true
},
})
const count = loaded(env).length
const internal = await plugin.load()
await nested
assert.deepEqual(Array.from(internal.queue, row => row.text), action === 'destroy' ? [] : ['new'])
assert.equal(loaded(env).length, count + (action === 'destroy' ? 0 : 1))
if (action !== 'destroy')
env.destroy()
})
}
test(`${prefix}: loader receiver, filter receiver, input mutation and extra fields retain the public behavior`, async () => {
let loaderReceiver = 'unset'
let filterReceiver
const { env, plugin } = fixture({
danmuku: [],
filter() {
filterReceiver = this
return true
},
})
const row = { text: 'zero', time: 0, id: 'custom-id', extension: { value: 1 } }
const internal = await plugin.load(function () {
loaderReceiver = this
return [row]
})
assert.equal(loaderReceiver, undefined)
assert.equal(filterReceiver, plugin.option)
assert.equal(row.time, 0, 'Accepted defect correction preserves an explicit zero timestamp')
assert.equal(row.mode, 0)
assert.equal(row.color, '#FFFFFF')
assert.deepEqual(Object.keys(row.style), [])
assert.equal(internal.queue[0].extension, row.extension)
assert.equal(internal.queue[0].id, row.id)
const missing = { text: 'default time' }
const negative = { text: 'negative', time: -2 }
await plugin.emit(missing)
await plugin.emit(negative)
assert.equal(missing.time, 10.5)
assert.equal(negative.time, 0)
env.destroy()
})
test(`${prefix}: invalid later rows preserve earlier insertions and reject with the single emitted original error`, async () => {
const { env, plugin } = fixture()
const internal = plugin.show()
let caught
const loadedCount = loaded(env).length
await assert.rejects(plugin.load([{ text: 'valid', time: 1 }, { text: 123 }]), (error) => {
caught = error
return error.message.includes('text')
})
assert.deepEqual(Array.from(internal.queue, row => row.text), ['valid'])
assert.equal(failures(env).length, 1)
assert.equal(failures(env)[0].args[0], caught)
assert.equal(loaded(env).length, loadedCount)
env.destroy()
})
test(`${prefix}: function configuration changes are applied while structurally equal plain values remain silent`, () => {
const { env, plugin } = fixture()
const original = plugin.option
const filter = () => false
const before = env.events.length
plugin.config({ filter })
assert.equal(plugin.option.filter, filter)
assert.notEqual(plugin.option, original)
assert.deepEqual(names(env).slice(before), ['artplayerPluginDanmuku:show', 'artplayerPluginDanmuku:config'])
const updated = plugin.option
const count = env.events.length
plugin.config({ filter, margin: [10, '25%'], points: [], OPACITY: {} })
assert.equal(env.events.length, count)
assert.equal(plugin.option, updated)
env.destroy()
})
test(`${prefix}: invalid configuration cannot replace or partially mutate a valid option`, () => {
const { env, plugin } = fixture()
const original = plugin.option
const count = env.events.length
assert.throws(() => plugin.config({ opacity: 0.2, speed: 'invalid', visible: false }), /speed/iu)
assert.equal(plugin.option, original)
assert.equal(plugin.option.opacity, 1)
assert.equal(plugin.option.visible, true)
assert.equal(plugin.isHide, false)
assert.equal(env.events.length, count)
env.destroy()
})
test(`${prefix}: initial Promise source is accepted and successful completion reports loaded`, async () => {
const input = deferred()
const { env, plugin } = fixture({ danmuku: input.promise })
assert.equal(typeof plugin.then, 'undefined')
assert.equal(loaded(env).length, 0)
input.resolve([{ text: 'initial promise', time: 0 }])
await env.flush()
assert.equal(loaded(env).length, 1)
assert.equal(loaded(env)[0].args[0][0].text, 'initial promise')
assert.equal(loaded(env)[0].args[0][0].time, 0)
env.destroy()
})
test(`${prefix}: ignored constructor load rejection is locally observed while explicit load still rejects`, async () => {
const error = new Error('source failed')
const { env, plugin } = fixture({ danmuku: () => Promise.reject(error) })
await nextTurn()
assert.equal(failures(env).length, 1)
assert.equal(failures(env)[0].args[0], error)
await assert.rejects(plugin.load(), failure => failure === error)
assert.equal(failures(env).length, 2)
assert.equal(failures(env)[1].args[0], error)
env.destroy()
})
for (const event of ['show', 'config', 'loaded']) {
test(`${prefix}: synchronous initial ${event} destruction leaves a complete facade without later Worker or setting creation`, async () => {
const env = danmukuCandidateEnvironment(implementation)
const destroy = () => env.destroy()
env.art.on(`artplayerPluginDanmuku:${event}`, destroy)
const before = [...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0)
const plugin = env.factory({ danmuku: [], heatmap: true })(env.art)
assert.equal(env.art.isDestroy, true)
assert.equal(typeof plugin.then, 'undefined')
assert.deepEqual(Object.keys(plugin), ['name', 'emit', 'load', 'config', 'hide', 'show', 'reset', 'mount', 'option', 'isHide', 'isStop'])
assert.equal(plugin.name, 'artplayerPluginDanmuku')
for (const key of ['option', 'isHide', 'isStop']) {
const descriptor = Object.getOwnPropertyDescriptor(plugin, key)
assert.equal(typeof descriptor.get, 'function')
assert.equal(descriptor.set, undefined)
assert.equal(descriptor.enumerable, true)
assert.equal(descriptor.configurable, true)
}
assert.equal(plugin.option.heatmap, true)
assert.equal(plugin.isStop, true)
assert.equal(plugin.isHide, false)
assert.equal(plugin.mount('#external'), undefined)
assert.equal(plugin.mount(), undefined)
assert.equal(env.workers.length, event === 'loaded' ? 1 : 0)
assert(env.workers.every(worker => worker.terminated), 'A Worker created before loaded reentry must be terminated')
assert.equal(env.art.template.$controlsCenter.children.length, 0)
assert.equal(env.art.template.$player.children.length, 0)
assert.equal(env.controls.length, 0)
assert.equal(env.proxies.length, 0)
assert.equal(env.frames.size, 0)
assert.equal(env.timers.size, 0)
assert.equal([...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0), before, 'Only the pre-existing user listener remains')
await env.flush()
assert.equal(env.consoleWarnings.length, 0)
})
}
test(`${prefix}: invalid initial configuration rolls back the early destroy listener before throwing`, () => {
const env = danmukuCandidateEnvironment(implementation)
const existingDestroy = () => {}
env.art.on('destroy', existingDestroy)
const before = [...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0)
assert.throws(() => env.factory({ danmuku: [], speed: 'invalid' })(env.art), /speed/iu)
assert.equal([...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0), before)
assert.deepEqual(env.listeners.get('destroy'), [existingDestroy])
assert.equal(env.workers.length, 0)
assert.equal(env.art.template.$controlsCenter.children.length, 0)
assert.equal(env.art.template.$player.children.length, 0)
assert.equal(env.controls.length, 0)
assert.equal(env.proxies.length, 0)
assert.equal(env.frames.size, 0)
assert.equal(env.timers.size, 0)
})
test(`${prefix}: initial scheduler Worker construction failure preserves the error and rolls back subscriptions`, () => {
const error = new Error('initial scheduler Worker failed')
const env = danmukuCandidateEnvironment(implementation, { initialWorkerError: error })
const existingDestroy = () => {}
env.art.on('destroy', existingDestroy)
const before = [...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0)
assert.throws(() => env.factory({ danmuku: [] })(env.art), failure => failure === error)
assert.equal([...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0), before)
assert.deepEqual(env.listeners.get('destroy'), [existingDestroy])
assert.equal(env.workers.length, 0)
assert.equal(env.art.template.$controlsCenter.children.length, 0)
assert.equal(env.art.template.$player.children.length, 0)
assert.equal(env.controls.length, 0)
assert.equal(env.proxies.length, 0)
assert.equal(env.frames.size, 0)
assert.equal(env.timers.size, 0)
})
+403
View File
@@ -0,0 +1,403 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Candidate parser failures require strict Node rejection checks.
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import vm from 'node:vm'
import { danmukuCandidate, danmukuCandidateEnvironment } from './helpers/danmuku-candidate.js'
import { danmukuEnvironment, danmukuHistorical, deferred } from './helpers/danmuku.js'
const implementation = await danmukuCandidate()
const historical = await danmukuHistorical()
const prefix = 'artplayerPluginDanmuku:'
const xml = `<i>
<d p="1.25,1,25,16777215,123,0,user-a,42"> &quot;quote&quot; &apos;apostrophe&apos; &lt;tag&gt; &amp; &amp;lt; </d>
<d p="2,2,18,15,124,1,user-b,43">second\nline</d>
<d p="3,3,19,0,125,2,user-c,44">third</d>
<d p="4,4,20,255,126,3,user-d,45">bottom</d>
<d p="5,5,21,65535,127,4,user-e,46">top</d>
<d p="6,9,22,255,128,5,user-f,47">unknown mode</d>
<d p="1,2,3">short attributes</d>
</i>`
const expectedRows = [
{ text: '"quote" \'apostrophe\' <tag> & &lt;', time: 1.25, mode: 0, fontSize: 25, color: '#ffffff', timestamp: 123, pool: 0, userID: 'user-a', rowID: 42 },
{ text: 'second\nline', time: 2, mode: 0, fontSize: 18, color: '#f', timestamp: 124, pool: 1, userID: 'user-b', rowID: 43 },
{ text: 'third', time: 3, mode: 0, fontSize: 19, color: '#0', timestamp: 125, pool: 2, userID: 'user-c', rowID: 44 },
{ text: 'bottom', time: 4, mode: 2, fontSize: 20, color: '#ff', timestamp: 126, pool: 3, userID: 'user-d', rowID: 45 },
{ text: 'top', time: 5, mode: 1, fontSize: 21, color: '#ffff', timestamp: 127, pool: 4, userID: 'user-e', rowID: 46 },
{ text: 'unknown mode', time: 6, mode: 0, fontSize: 22, color: '#ff', timestamp: 128, pool: 5, userID: 'user-f', rowID: 47 },
]
function rows(owner) {
return Array.from(owner.queue, item => Object.fromEntries(Object.keys(expectedRows[0]).map(key => [key, item[key]])))
}
function events(env, name) {
return env.events.filter(event => event.name === `${prefix}${name}`)
}
async function setup(t, settings = {}, configure = () => {}) {
const env = danmukuCandidateEnvironment(implementation, settings)
const plugin = env.factory({ danmuku: [] })(env.art)
t.after(() => {
if (!env.art.isDestroy)
env.destroy()
})
await env.flush()
// Faults belong to the parser; the built inline layout Worker reads the same global.
configure(env)
return { env, plugin }
}
function parserWorker(env) {
const workers = env.workers.filter(worker => env.urls.has(worker.url))
assert.equal(workers.length, 1, 'Exactly one actual parser Blob Worker is expected')
return workers[0]
}
function released(env, worker) {
assert.equal(worker.terminated, true)
assert.deepEqual(env.revoked, [worker.url])
for (const name of ['message', 'error', 'messageerror']) {
assert.equal(worker[`on${name}`] == null, true, `${name} property detached`)
assert.equal((worker.listeners.get(name) || []).length, 0, `${name} listener detached`)
}
}
function dispatch(worker, name, event) {
worker[`on${name}`]?.(event)
for (const listener of [...worker.listeners.get(name) || []])
listener(event)
}
test('danmuku parser: real candidate and frozen release Blob scripts preserve XML fields, entities and mode mapping', { timeout: 10000 }, async (t) => {
for (const candidate of [...historical, implementation]) {
const env = candidate === implementation
? danmukuCandidateEnvironment(candidate, { fetch: async () => ({ text: async () => xml }) })
: danmukuEnvironment(candidate, { fetch: async () => ({ text: async () => xml }) })
t.after(() => env.destroy())
const plugin = env.factory({ danmuku: [] })(env.art)
await env.flush()
const pending = plugin.load('/fields.xml')
await env.flush()
const worker = parserWorker(env)
assert.equal(worker.messages.length, 1)
assert.equal(worker.messages[0].message.xml, xml)
await worker.execute(worker.messages[0].message)
const owner = await pending
assert.deepEqual(rows(owner), expectedRows, candidate.name)
if (candidate === implementation)
released(env, worker)
}
})
for (const input of ['', '<i></i>', '<i><d p="1,2,3">invalid fields</d></i>']) {
test(`danmuku parser: empty result settles and releases resources for ${JSON.stringify(input)}`, { timeout: 3000 }, async (t) => {
const { env, plugin } = await setup(t, { fetch: async () => ({ text: async () => input }) })
const loaded = events(env, 'loaded').length
const pending = plugin.load('/empty.xml')
await env.flush()
const worker = parserWorker(env)
await worker.execute(worker.messages[0].message)
const owner = await pending
assert.equal(owner.queue.length, 0)
assert.equal(events(env, 'loaded').length, loaded + 1)
assert.equal(events(env, 'error').length, 0)
released(env, worker)
})
}
for (const stage of ['fetch', 'text']) {
test(`danmuku parser: ${stage} rejection preserves public error identity and emits once`, { timeout: 3000 }, async (t) => {
const failure = new Error(`${stage}-original`)
const { env, plugin } = await setup(t, { fetch: async () => {
if (stage === 'fetch')
throw failure
return { text: async () => {
throw failure
} }
} })
const loaded = events(env, 'loaded').length
await assert.rejects(plugin.load('/failed.xml'), error => error === failure)
assert.deepEqual(events(env, 'error').map(event => event.args), [[failure]])
assert.equal(events(env, 'loaded').length, loaded)
assert.equal(env.urls.size, 0)
assert.equal(env.workers.length, 1, 'Network failure must not allocate the parser worker')
assert.deepEqual(env.revoked, [])
})
}
for (const stage of ['url', 'constructor', 'postMessage', 'error', 'messageerror']) {
test(`danmuku parser: ${stage} failure falls back to the same XML parser and releases acquired resources`, { timeout: 3000 }, async (t) => {
const failure = new Error(`${stage}-original`)
const { env, plugin } = await setup(t, { fetch: async () => ({ text: async () => xml }) }, (env) => {
const NativeWorker = env.context.Worker
if (stage === 'url') {
env.context.URL.createObjectURL = () => {
throw failure
}
}
else if (stage === 'constructor') {
env.context.Worker = class {
constructor() { throw failure }
}
}
else if (stage === 'postMessage') {
env.context.Worker = class extends NativeWorker {
postMessage() { throw failure }
}
}
})
const pending = plugin.load('/fallback.xml')
await env.flush()
if (stage === 'error' || stage === 'messageerror') {
let prevented = 0
dispatch(parserWorker(env), stage, { error: failure, message: failure.message, preventDefault() {
prevented++
} })
if (stage === 'error')
assert.equal(prevented, 1, 'Only the recovered parser Worker error is prevented locally')
}
const owner = await pending
assert.deepEqual(rows(owner), expectedRows)
assert.equal(events(env, 'error').length, 0, 'Successful fallback is not a public load error')
assert.equal(env.consoleErrors.length, 1)
assert.equal(env.consoleErrors[0][0], 'Error parsing Bilibili Danmu:')
assert.equal(env.consoleErrors[0][1], failure)
if (stage === 'url') {
assert.equal(env.urls.size, 0)
assert.deepEqual(env.revoked, [])
}
else if (stage === 'constructor') {
assert.equal(env.urls.size, 1)
assert.deepEqual(env.revoked, [...env.urls.keys()])
}
else {
released(env, parserWorker(env))
}
})
}
test('danmuku parser: ignores unrelated response IDs and disposes only after its matching result', { timeout: 3000 }, async (t) => {
const { env, plugin } = await setup(t)
let settled = false
const pending = plugin.load('/correlated.xml')
pending.then(() => settled = true, () => settled = true)
await env.flush()
const worker = parserWorker(env)
const request = worker.messages[0].message
worker.deliver({ id: `${request.id}-unrelated`, danmus: [] })
await env.flush()
assert.equal(settled, false)
assert.equal(worker.terminated, false)
assert.deepEqual(env.revoked, [])
await worker.execute(request)
const owner = await pending
assert.equal(owner.queue[0].text, 'xml')
released(env, worker)
})
test('danmuku parser: a matching malformed response rejects once and cannot later publish data', { timeout: 3000 }, async (t) => {
const { env, plugin } = await setup(t)
const pending = plugin.load('/malformed.xml')
let rejectedError
const rejected = assert.rejects(pending, (error) => {
rejectedError = error
return error.name === 'Error' && error.message === 'Invalid Bilibili Danmu worker response'
})
await env.flush()
const worker = parserWorker(env)
const { id } = worker.messages[0].message
worker.deliver({ id, danmus: { text: 'not an array' } })
await rejected
assert.deepEqual(events(env, 'error').map(event => event.args), [[rejectedError]])
released(env, worker)
const count = env.events.length
worker.deliver({ id, danmus: [{ text: 'late' }] })
await env.flush()
assert.equal(env.events.length, count)
})
for (const outcome of ['success', 'protocol-error', 'destroy']) {
test(`danmuku parser: ${outcome} attempts every resource cleanup when termination and URL revocation throw`, { timeout: 3000 }, async (t) => {
const terminateError = new Error('terminate-original')
const revokeError = new Error('revoke-second')
let terminateCalls = 0
const { env, plugin } = await setup(t, {}, (env) => {
const NativeWorker = env.context.Worker
const revoke = env.context.URL.revokeObjectURL
env.context.Worker = class extends NativeWorker {
terminate() {
terminateCalls++
super.terminate()
throw terminateError
}
}
env.context.URL.revokeObjectURL = (url) => {
revoke(url)
throw revokeError
}
})
const owner = plugin.show()
const pending = plugin.load('/cleanup.xml')
let rejectedError
const observed = outcome === 'destroy'
? pending
: assert.rejects(pending, (error) => {
rejectedError = error
return outcome === 'success'
? error === terminateError
: error.message === 'Invalid Bilibili Danmu worker response'
})
await env.flush()
const worker = parserWorker(env)
if (outcome === 'destroy')
env.destroy()
else worker.deliver({ id: worker.messages[0].message.id, danmus: outcome === 'success' ? [] : null })
const result = await observed
assert.equal(terminateCalls, 1)
released(env, worker)
if (outcome === 'destroy') {
assert.equal(result, owner)
assert.equal(events(env, 'error').length, 0)
}
else {
assert.deepEqual(events(env, 'error').map(event => event.args), [[rejectedError]])
}
})
}
test('danmuku parser: fallback parser failure rejects its original error once and releases the allocated URL', { timeout: 3000 }, async (t) => {
const workerError = new Error('worker unavailable')
const parserError = new Error('fallback parser original')
const { env, plugin } = await setup(t, { fetch: async () => ({ text: async () => xml }) }, (env) => {
env.context.Worker = class {
constructor() { throw workerError }
}
env.context.parserError = parserError
vm.runInContext('String.prototype.matchAll = function () { throw parserError }', env.context)
})
await assert.rejects(plugin.load('/fallback-failed.xml'), error => error === parserError)
assert.deepEqual(events(env, 'error').map(event => event.args), [[parserError]])
assert.equal(env.consoleErrors[0][1], workerError)
assert.equal(env.urls.size, 1)
assert.deepEqual(env.revoked, [...env.urls.keys()])
})
test('danmuku parser: successful parsing removes its abort listener before subsequent cancellation', { timeout: 3000 }, async (t) => {
const subscriptions = []
const { env, plugin } = await setup(t, {}, (env) => {
env.context.AbortController = class extends AbortController {
constructor() {
super()
const { signal } = this
const add = signal.addEventListener.bind(signal)
const remove = signal.removeEventListener.bind(signal)
signal.addEventListener = (name, callback, ...rest) => {
subscriptions.push({ name, callback, signal, removed: 0 })
add(name, callback, ...rest)
}
signal.removeEventListener = (name, callback, ...rest) => {
const entry = subscriptions.find(item => item.name === name && item.callback === callback && item.signal === signal)
assert(entry, 'Only an owned abort listener can be removed')
entry.removed++
remove(name, callback, ...rest)
}
}
}
})
const pending = plugin.load('/signal.xml')
await env.flush()
const worker = parserWorker(env)
assert.equal(subscriptions.length, 1)
assert.equal(subscriptions[0].name, 'abort')
await worker.execute(worker.messages[0].message)
await pending
assert.equal(subscriptions[0].removed, 1)
released(env, worker)
env.destroy()
assert.equal(subscriptions[0].removed, 1)
assert.deepEqual(env.revoked, [worker.url])
})
for (const stage of ['fetch', 'text', 'worker']) {
for (const abortController of [true, false]) {
test(`danmuku parser: destroy during ${stage} settles silently with AbortController ${abortController}`, { timeout: 3000 }, async (t) => {
const gate = deferred()
const { env, plugin } = await setup(t, { fetch: async () => {
if (stage === 'fetch')
return gate.promise
return { text: () => stage === 'text' ? gate.promise : Promise.resolve(xml) }
} }, (env) => {
if (!abortController) {
env.context.AbortController = undefined
env.context.window.AbortController = undefined
}
})
const owner = plugin.show()
const pending = plugin.load('/cancel.xml')
await env.flush()
const worker = stage === 'worker' ? parserWorker(env) : undefined
env.destroy()
const count = env.events.length
assert.equal(await pending, owner)
assert.equal(events(env, 'error').length, 0)
if (abortController)
assert.equal(env.fetchCalls[0].init.signal.aborted, true)
if (worker) {
released(env, worker)
worker.deliver({ id: worker.messages[0].message.id, danmus: [{ text: 'late' }] })
}
else {
assert.equal(env.urls.size, 0)
gate.resolve(stage === 'fetch' ? { text: async () => xml } : xml)
}
await env.flush()
assert.equal(env.events.length, count, 'Cancelled parser cannot emit a late loaded/error event')
assert.equal(owner.queue.length, 0)
assert.equal(env.workers.length, stage === 'worker' ? 2 : 1)
})
}
}
for (const stage of ['fetch', 'text']) {
test(`danmuku parser: strict subprocess observes automatic ${stage} failure while public load still rejects`, () => {
const program = `
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { danmukuCandidateEnvironment } from './test/helpers/danmuku-candidate.js';
const implementation = JSON.parse(fs.readFileSync(0, 'utf8'));
const failure = new Error(${JSON.stringify(`${stage}-original`)});
const env = danmukuCandidateEnvironment(implementation, { fetch: async () => {
if (${JSON.stringify(stage)} === 'fetch') throw failure;
return { text: async () => { throw failure; } };
} });
const plugin = env.factory({ danmuku: '/automatic.xml' })(env.art);
await env.flush();
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(env.events.filter(event => event.name === '${prefix}error').map(event => event.args), [[failure]]);
assert.deepEqual(env.consoleWarnings, [['Failed to load initial danmuku:', failure]]);
await assert.rejects(plugin.load('/public.xml'), error => error === failure);
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(env.events.filter(event => event.name === '${prefix}error').map(event => event.args), [[failure], [failure]]);
assert.equal(env.consoleWarnings.length, 1);
assert.equal(env.workers.length, 1);
assert.equal(env.urls.size, 0);
env.destroy();
process.stdout.write('automatic-observed-public-rejected-once\\n');
`
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: 5000,
maxBuffer: 1024 * 1024,
})
assert.equal(child.error, undefined, child.error?.message)
assert.equal(child.signal, null)
assert.equal(child.status, 0, child.stderr)
assert.equal(child.stdout, 'automatic-observed-public-rejected-once\n')
assert.equal(child.stderr, '')
})
}
+56
View File
@@ -0,0 +1,56 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { build } from 'esbuild'
import { ensureArchive, readMember, refactorDir } from '../../refactor/scripts/releases.mjs'
import { getEntryFile } from '../../scripts/projects.js'
import { danmukuEnvironment } from './danmuku.js'
const workspace = fileURLToPath(new URL('../../', import.meta.url))
export async function danmukuCandidate() {
const coreRelease = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/releases.json'), 'utf8')).releases.find(item => item.name === 'artplayer')
const coreCode = readMember(await ensureArchive(coreRelease), 'package/dist/artplayer.js').toString()
const artifact = process.env.ARTPLAYER_DANMUKU_ARTIFACT
if (artifact)
return { name: path.basename(artifact), code: fs.readFileSync(path.resolve(artifact), 'utf8'), coreCode }
const project = path.join(workspace, 'packages/artplayer-plugin-danmuku')
const compiled = await build({ entryPoints: [getEntryFile(project)], bundle: true, write: false, format: 'cjs', platform: 'browser', target: 'es2020', plugins: [{ name: 'danmuku-controlled-assets', setup(build) {
build.onResolve({ filter: /\?(?:worker|raw|inline)/ }, args => ({ path: path.resolve(args.resolveDir, args.path), namespace: 'controlled-asset' }))
build.onLoad({ filter: /.*/, namespace: 'controlled-asset' }, (args) => {
if (args.path.includes('?worker'))
return { contents: 'export default Worker', loader: 'js' }
return { contents: `export default ${JSON.stringify(fs.readFileSync(args.path.split('?')[0], 'utf8'))}`, loader: 'js' }
})
} }] })
return { name: 'candidate-source', code: compiled.outputFiles[0].text, coreCode }
}
export function danmukuCandidateEnvironment(implementation, settings = {}) {
// Inject before module evaluation: source worker imports capture the constructor.
const initialWorker = settings.initialWorkerError
? { ...implementation, code: `globalThis.Worker = class { constructor() { throw globalThis.__danmukuCandidateWorkerError } };\n${implementation.code}` }
: implementation
const env = danmukuEnvironment(initialWorker, settings)
if (settings.initialWorkerError)
env.context.__danmukuCandidateWorkerError = settings.initialWorkerError
const consoleWarnings = []
env.context.console.warn = (...args) => consoleWarnings.push(args)
env.context.AbortController = AbortController
env.context.AbortSignal = AbortSignal
env.context.window.AbortController = AbortController
env.context.window.AbortSignal = AbortSignal
env.context.Worker.prototype.removeEventListener = function (name, callback) {
this.listeners.set(name, (this.listeners.get(name) || []).filter(listener => listener !== callback))
}
const fetchCalls = []
env.context.fetch = async (url, init) => {
env.requests.push(url)
fetchCalls.push({ url, init })
if (settings.fetch)
return settings.fetch(url, init)
return { ok: true, status: 200, text: async () => '<i><d p="10,1,25,16777215,1,0,u,1">xml</d></i>' }
}
return { ...env, fetchCalls, consoleWarnings }
}