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, scenario, testInfo) { const origin = new URL(testInfo.project.use.baseURL).origin await page.route('**/*', (route) => { return new URL(route.request().url()).origin === origin ? route.continue() : route.abort() }) await page.goto(`/test/player.html?core=${core}`) await page.addScriptTag({ content: candidate }) await page.evaluate(({ scenario }) => { window.resourcePlayers = [] window.resourcePlugins = [] window.resourceOwners = [] window.resourceMessages = [] window.resourceSends = [] window.resourceErrors = [] window.resourceWorkers = [] window.resourceTimers = new Set() const nativeSetTimeout = window.setTimeout.bind(window) const nativeClearTimeout = window.clearTimeout.bind(window) window.setTimeout = (callback, delay, ...args) => { const id = nativeSetTimeout(() => { window.resourceTimers.delete(id) callback(...args) }, delay) if (window.captureNextResourceTimer && delay === 1000) { window.resourceTimers.add(id) window.captureNextResourceTimer = false } return id } window.clearTimeout = (id) => { window.resourceTimers.delete(id) nativeClearTimeout(id) } const NativeWorker = window.Worker window.Worker = class extends NativeWorker { constructor(...args) { super(...args) this.record = { terminated: 0 } window.resourceWorkers.push(this.record) } terminate() { this.record.terminated++ super.terminate() } } const originalError = console.error console.error = (...args) => { window.resourceErrors.push(args.map(arg => arg?.message || String(arg))) originalError(...args) } window.sendGate = new Promise((resolve, reject) => { window.resolveSend = resolve window.rejectSend = reject }) window.createResourcePlayer = (index, extra = {}) => { const container = index === 0 ? document.querySelector('.player') : document.body.appendChild(document.createElement('div')) container.style.cssText = 'width:640px;height:360px' const mount = document.body.appendChild(document.createElement('div')) mount.id = `resource-mount-${index}` const art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true, fullscreenWeb: true, setting: true }) window.resourcePlayers[index] = art art.on('artplayerPluginDanmuku:visible', item => window.resourceMessages.push(item.text)) const option = { danmuku: [], mount, heatmap: scenario === 'heatmap' ? { sampling: 0 } : false, lockTime: 10, margin: [10, 10], fontSize: 20, speed: 2, beforeEmit(danmu) { window.resourceSends.push({ index, text: danmu.text, receiver: this === window.resourcePlugins[index].option }) // Capture the lock scheduled in this send's await continuation, not // unrelated browser/core one-second timers while typing or clicking. if (window.observeResourceTimers) window.captureNextResourceTimer = true return scenario === 'pending' || scenario === 'reject' ? window.sendGate : true }, ...extra, } art.plugins.add(window.artplayerPluginDanmuku(option)) const plugin = art.plugins.artplayerPluginDanmuku window.resourcePlugins[index] = plugin window.resourceOwners[index] = plugin.show() return art } window.art = window.createResourcePlayer(0) document.querySelector('#play').onclick = () => window.art.play() }, { scenario }) await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true) await testInfo.attach('danmuku-resources-inputs', { contentType: 'application/json', body: JSON.stringify({ core, scenario, artifact: provenance.file || provenance.kind, sha256: hash(candidate), native: 'video, RAF, DOM, Worker; observe the first one-second timer after beforeEmit in the synchronous send continuation, no fake timer clock' }) }) } test.afterEach(async ({ page }, testInfo) => { await testInfo.attach('danmuku-resources-page', { contentType: 'image/png', body: await page.screenshot() }) const evidence = await page.evaluate(() => { for (const art of window.resourcePlayers || []) { if (!art.isDestroy) art.destroy(false) } return { workers: window.resourceWorkers, sends: window.resourceSends, messages: window.resourceMessages, errors: window.resourceErrors, retainedSettings: document.querySelectorAll('[id^="resource-mount-"] .artplayer-plugin-danmuku').length, timers: window.resourceTimers?.size } }) await testInfo.attach('danmuku-resources-evidence', { contentType: 'application/json', body: JSON.stringify(evidence) }) expect(evidence.retainedSettings).toBe(0) for (const worker of evidence.workers || []) expect(worker.terminated).toBe(1) }) async function renderResourceRow(page, text) { await page.evaluate(async (text) => { const owner = await window.resourcePlugins[0].emit({ text, time: 0.2, mode: 1 }) // Resource tests start with an eligible row, then require a real Worker and // rendered DOM. Native timestamp delivery is covered by the scheduler spec. owner.setState(owner.queue[owner.queue.length - 1], 'ready') }, text) await page.click('#play') await expect.poll(() => page.evaluate(() => window.resourceMessages)).toContain(text) } for (const core of ['published', 'candidate']) { test(`${core}: shared setting mount preserves the surviving instance and later user changes`, async ({ page }, testInfo) => { await setup(page, core, 'normal', testInfo) const result = await page.evaluate(() => { const mount = document.querySelector('#resource-mount-0') window.createResourcePlayer(1, { mount, theme: 'light' }) window.art.destroy(false) const survivor = { panels: mount.querySelectorAll('.artplayer-plugin-danmuku').length, theme: mount.dataset.danmukuTheme } mount.dataset.danmukuTheme = 'user-value' window.resourcePlayers[1].destroy(false) return { survivor, theme: mount.dataset.danmukuTheme, ownedKeys: Object.keys(mount.dataset).filter(key => key.startsWith('danmuku') && key !== 'danmukuTheme'), panels: mount.querySelectorAll('.artplayer-plugin-danmuku').length } }) expect(result).toEqual({ survivor: { panels: 1, theme: 'light' }, theme: 'user-value', ownedKeys: [], panels: 0 }) }) test(`${core}: invalid setting mount rolls back plugin resources while preserving the original error`, async ({ page }, testInfo) => { await setup(page, core, 'normal', testInfo) const result = await page.evaluate(() => { const container = document.body.appendChild(document.createElement('div')) container.style.cssText = 'width:640px;height:360px' const art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true }) window.resourcePlayers.push(art) art.template.$controlsCenter.style.setProperty('display', 'grid', 'important') const display = art.template.$controlsCenter.style.display let error try { art.plugins.add(window.artplayerPluginDanmuku({ danmuku: [], mount: '#missing-setting-mount' })) } catch (failure) { error = failure.message } return { error, panels: container.querySelectorAll('.artplayer-plugin-danmuku').length, worker: window.resourceWorkers.at(-1), display: art.template.$controlsCenter.style.display, priority: art.template.$controlsCenter.style.getPropertyPriority('display'), previousDisplay: display } }) expect(result.error).toContain('Can not find the mount point: #missing-setting-mount') expect(result.panels).toBe(0) expect(result.worker.terminated).toBe(1) expect(result.display).toBe(result.previousDisplay) expect(result.priority).toBe('important') }) test(`${core}: destroy cancels pending setting send without clearing retained input or starting a lock`, async ({ page }, testInfo) => { await setup(page, core, 'pending', testInfo) const mount = page.locator('#resource-mount-0') await mount.locator('.apd-input').fill('PENDING') await mount.locator('.apd-send').click() expect(await page.evaluate(() => window.resourceSends)).toEqual([{ index: 0, text: 'PENDING', receiver: true }]) const result = await page.evaluate(async () => { const input = document.querySelector('#resource-mount-0 .apd-input') const send = document.querySelector('#resource-mount-0 .apd-send') window.art.destroy(false) window.resolveSend(true) await new Promise(resolve => setTimeout(resolve, 30)) return { value: input.value, locked: send.classList.contains('apd-lock'), queue: window.resourceOwners[0].queue.map(item => item.text), connected: input.isConnected } }) expect(result).toEqual({ value: 'PENDING', locked: false, queue: [], connected: false }) expect(await page.evaluate(() => window.resourceErrors)).toEqual([]) }) test(`${core}: destroy clears active send timer and leaves another mounted instance usable`, async ({ page }, testInfo) => { await setup(page, core, 'normal', testInfo) await page.evaluate(() => window.observeResourceTimers = true) await page.locator('#resource-mount-0 .apd-input').fill('LOCK') await page.locator('#resource-mount-0 .apd-send').click() await expect(page.locator('#resource-mount-0 .apd-send')).toHaveClass(/apd-lock/u) expect(await page.evaluate(() => window.resourceTimers.size)).toBe(1) await page.evaluate(() => { window.observeResourceTimers = false window.createResourcePlayer(1) window.art.destroy(false) }) expect(await page.evaluate(() => window.resourceTimers.size)).toBe(0) await expect(page.locator('#resource-mount-0 .artplayer-plugin-danmuku')).toHaveCount(0) await page.locator('#resource-mount-1 .apd-input').fill('SECOND') await page.locator('#resource-mount-1 .apd-send').click() await expect.poll(() => page.evaluate(() => window.resourceOwners[1].queue.map(row => row.text))).toEqual(['SECOND']) expect(await page.locator('style#artplayer-plugin-danmuku').count()).toBe(1) }) test(`${core}: rejected setting callback retains input and original console error without an unhandled rejection`, async ({ page }, testInfo) => { await setup(page, core, 'reject', testInfo) await page.locator('#resource-mount-0 .apd-input').fill('REJECT') await page.locator('#resource-mount-0 .apd-send').click() await page.evaluate(() => window.rejectSend(new Error('controlled setting rejection'))) await expect.poll(() => page.evaluate(() => window.resourceErrors)).toEqual([['Error emitting danmuku:', 'controlled setting rejection']]) await expect(page.locator('#resource-mount-0 .apd-input')).toHaveValue('REJECT') await expect(page.locator('#resource-mount-0 .apd-send')).not.toHaveClass(/apd-lock/u) expect(await page.evaluate(() => window.resourceOwners[0].queue.length)).toBe(0) }) test(`${core}: destroy false releases actual renderer nodes while retaining the player shell`, async ({ page }, testInfo) => { await setup(page, core, 'normal', testInfo) await renderResourceRow(page, 'VISIBLE') const result = await page.evaluate(() => { const layer = window.art.template.$danmuku const node = [...layer.children].find(item => item.textContent === 'VISIBLE') const owner = window.resourceOwners[0] window.art.destroy(false) return { connected: node.isConnected, nodes: layer.children.length, refs: owner.$refs.length, dangling: owner.queue.filter(item => item.$ref).length, shell: !!window.art.template.$player } }) expect(result).toEqual({ connected: false, nodes: 0, refs: 0, dangling: 0, shell: true }) }) test(`${core}: replacement load removes a moved renderer node and preserves unrelated body content`, async ({ page }, testInfo) => { await setup(page, core, 'normal', testInfo) await renderResourceRow(page, 'MOVED') const result = await page.evaluate(async () => { window.art.pause() const layer = window.art.template.$danmuku const node = [...layer.children].find(item => item.textContent === 'MOVED') const foreign = document.createElement('aside') document.body.append(node, foreign) await window.resourcePlugins[0].load() const replacement = { connected: node.isConnected, nodes: layer.childElementCount, foreign: foreign.isConnected, refs: window.resourceOwners[0].$refs.length } window.art.destroy(false) return { replacement, foreignAfterDestroy: foreign.isConnected } }) expect(result).toEqual({ replacement: { connected: false, nodes: 0, foreign: true, refs: 0 }, foreignAfterDestroy: true }) }) test(`${core}: narrow heatmap invalid sampling stays responsive and two instances have independent gradients`, async ({ page }, testInfo) => { await setup(page, core, 'heatmap', testInfo) // A second evaluation has fresh module counters but shares this document. await page.addScriptTag({ content: candidate }) await page.evaluate(() => { window.createResourcePlayer(1) const first = window.art.controls.heatmap first.style.width = '99px' window.art.emit('resize') }) await expect.poll(() => page.evaluate(() => window.resourcePlayers[1].isReady)).toBe(true) await expect.poll(() => page.locator('.art-control-heatmap svg').count()).toBe(2) const result = await page.evaluate(() => { const first = window.resourcePlayers[0].controls.heatmap const second = window.resourcePlayers[1].controls.heatmap const gradient = node => node.querySelector('linearGradient').id window.resourcePlayers[0].emit('artplayerPluginDanmuku:points', [[0, 0], [49, 40], [99, 10]]) window.resourcePlayers[1].emit('artplayerPluginDanmuku:points', [[0, 20], [300, 0], [640, 35]]) window.resourcePlayers[0].emit('setBar', 'played', 0.25) window.resourcePlayers[1].emit('setBar', 'played', 0.75) return { ids: [gradient(first), gradient(second)], fills: [first.querySelector('path').getAttribute('fill'), second.querySelector('path').getAttribute('fill')], offsets: [first.querySelector('#heatmap-start').getAttribute('offset'), second.querySelector('#heatmap-start').getAttribute('offset')], validPaths: [first, second].every(node => !/NaN|Infinity/.test(node.querySelector('path').getAttribute('d'))), } }) expect(new Set(result.ids).size).toBe(2) expect(result.ids[0]).toBe('heatmap-solids') expect(result.fills).toEqual(result.ids.map(id => `url(#${id})`)) expect(result.offsets).toEqual(['25%', '75%']) expect(result.validPaths).toBe(true) await testInfo.attach('danmuku-resources-heatmap', { contentType: 'application/json', body: JSON.stringify(result) }) await testInfo.attach('danmuku-heatmap-live-page', { contentType: 'image/png', body: await page.screenshot({ fullPage: true }) }) await page.evaluate(() => window.art.destroy(false)) await expect(page.locator('.art-control-heatmap')).toHaveCount(1) await page.evaluate(() => window.resourcePlayers[1].emit('resize')) await expect(page.locator('.art-control-heatmap svg')).toHaveCount(1) }) }