import process from 'node:process' import { hash } from '../../refactor/scripts/releases.mjs' import { browserCandidate } from '../helpers/browser-candidate.js' import { expect, test } from './fixtures.js' let candidate let provenance test.beforeAll(async () => { ;({ code: candidate, provenance } = await browserCandidate('artplayer-plugin-danmuku', process.env.ARTPLAYER_DANMUKU_ARTIFACT)) }) test.beforeEach(async ({ browserName }, testInfo) => { await testInfo.attach('danmuku-selected-input', { contentType: 'application/json', body: JSON.stringify({ browserName, provenance }) }) }) 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: provenance.file || provenance.kind, 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: 'FALLBACK' })) 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: ' A & B TOP' })) 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) }) }) }