mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-22 05:11:20 -08:00
refactor(danmuku): [PKG-DANMUKU-03] own input loading parsing and configuration
This commit is contained in:
1 parent
d3c91af92c
commit
0b2efb07f1
26 files changed
+9167
-503
No files matched your search
@@ -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 & 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) })
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"> "quote" 'apostrophe' <tag> & &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> & <', 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, '')
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user