test(danmuku): [PKG-DANMUKU-02] establish failure browser and load baselines

This commit is contained in:
Harvey Zhao committed 2026-09-13 18:03:21 +08:00
1 parent ccf3d6ed14
commit d3c91af92c
14 files changed
+8508 -15

No files matched your search

+337
View File
@@ -0,0 +1,337 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { expect, test } from './fixtures.js'
const baseline = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/danmuku-release.json', import.meta.url), 'utf8'))
const release = baseline.release
assert.equal(release.version, '5.3.0')
const implementations = new Map()
test.beforeAll(async () => {
const archive = await ensureArchive(release)
for (const format of ['main', 'legacy']) {
const member = `package/${release.manifest[format].replace(/^\.\//u, '')}`
const bytes = readMember(archive, member)
assert.equal(hash(bytes), release.files[member], `Published Danmuku ${format} bytes drifted`)
implementations.set(format, { member, sha256: hash(bytes), code: bytes.toString() })
}
})
async function setup(page, core, format, scenario, testInfo) {
const external = []
const origin = new URL(testInfo.project.use.baseURL).origin
await page.route('**/*', (route) => {
const url = new URL(route.request().url())
if (url.origin !== origin) {
external.push(url.href)
return route.abort()
}
// Same-origin blob URLs are actual inline Worker/media resources, not external requests.
return route.continue()
})
await page.goto(`/test/player.html?core=${core}`)
await page.evaluate(() => {
window.danmukuEvidence = { workers: [], events: [], gates: [], sends: [], failures: [] }
const NativeWorker = window.Worker
window.Worker = class ObservedNativeWorker extends NativeWorker {
constructor(url, options) {
super(url, options)
this.evidence = { url: String(url), replies: [], terminated: 0, errors: [] }
window.danmukuEvidence.workers.push(this.evidence)
this.addEventListener('message', ({ data }) => this.evidence.replies.push({ id: data.id, result: data.result ?? null }))
this.addEventListener('error', event => this.evidence.errors.push(event.message))
}
terminate() {
this.evidence.terminated++
return super.terminate()
}
}
window.danmukuPlayers = []
window.danmukuPlugins = []
window.danmukuNodes = []
window.danmukuVisibility = new Promise((resolve) => {
window.releaseDanmukuVisibility = resolve
})
window.danmukuSending = new Promise((resolve) => {
window.releaseDanmukuSending = resolve
})
})
const implementation = implementations.get(format)
await page.addScriptTag({ content: implementation.code })
await page.evaluate(({ scenario }) => {
const first = document.querySelector('.player')
first.id = 'danmuku-player-0'
first.style.cssText = 'width:640px;height:360px'
window.createDanmukuPlayer = (index) => {
const container = index === 0 ? first : document.body.appendChild(document.createElement('div'))
container.id = `danmuku-player-${index}`
container.style.cssText = 'width:640px;height:360px'
const mount = document.createElement('div')
mount.id = `danmuku-mount-${index}`
mount.style.cssText = 'width:640px;height:36px;background:#333'
container.after(mount)
const art = new window.Artplayer({ container, url: `/test/pattern.mp4?danmuku=${index}`, muted: true, mutex: false, fullscreenWeb: true })
window.danmukuPlayers[index] = art
for (const event of ['loaded', 'visible', 'start', 'stop', 'reset', 'destroy', 'config', 'error']) {
art.on(`artplayerPluginDanmuku:${event}`, (value) => {
window.danmukuEvidence.events.push({ index, event, time: art.currentTime, id: value?.id, text: value?.text, mode: value?.mode, count: Array.isArray(value) ? value.length : undefined })
if (event === 'error')
window.danmukuEvidence.failures.push(String(value))
})
}
const option = {
danmuku: [],
heatmap: false,
speed: 8,
margin: [10, 10],
fontSize: 20,
antiOverlap: true,
synchronousPlayback: true,
lockTime: 1,
...(scenario === 'settings' ? { mount } : {}),
filter: danmu => danmu.text !== 'FILTERED',
beforeVisible: async (danmu) => {
window.danmukuEvidence.gates.push({ index, id: danmu.id, text: danmu.text })
if (danmu.text === 'GATED')
return window.danmukuVisibility
return danmu.text !== 'DENIED'
},
beforeEmit: async (danmu) => {
window.danmukuEvidence.sends.push({ index, text: danmu.text, mode: danmu.mode })
return scenario === 'settings' && index === 0 ? window.danmukuSending : true
},
}
art.plugins.add(window.artplayerPluginDanmuku(option))
window.danmukuPlugins[index] = art.plugins.artplayerPluginDanmuku
return art
}
window.art = window.createDanmukuPlayer(0)
document.querySelector('#play').onclick = () => window.danmukuPlayers[0].play()
document.querySelector('#pause').onclick = () => window.danmukuPlayers[0].pause()
}, { scenario })
await expect.poll(() => page.evaluate(() => window.art.isReady && window.art.template.$video.videoWidth > 0)).toBe(true)
await testInfo.attach('danmuku-browser-inputs', { contentType: 'application/json', body: JSON.stringify({ core, plugin: release.name, version: release.version, format, member: implementation.member, sha256: implementation.sha256, integrity: release.integrity, media: '/test/pattern.mp4', scenario, worker: 'Native Blob Worker, only constructor/message/termination observation; no Worker responses, media clocks, layout or RAF mocked', heatmap: false }) })
return external
}
async function geometry(page) {
return page.evaluate(() => (window.danmukuPlayers || []).map((art, index) => {
const player = art.template.$player.getBoundingClientRect()
return {
index,
playing: art.playing,
time: art.currentTime,
rate: art.playbackRate,
player: { x: player.x, y: player.y, width: player.width, height: player.height },
nodes: [...art.template.$danmuku.children].map((node) => {
const rect = node.getBoundingClientRect()
const style = getComputedStyle(node)
return { id: node.dataset.id, mode: node.dataset.mode, state: node.dataset.state, text: node.textContent, x: rect.x - player.x, y: rect.y - player.y, width: rect.width, height: rect.height, visibility: style.visibility, display: style.display, transition: node.style.transition, opacity: style.opacity }
}),
}
}))
}
const visibleIds = page => page.evaluate(() => [...new Set(window.danmukuEvidence.events.filter(item => item.event === 'visible' && item.id).map(item => item.id))])
test.afterEach(async ({ page }, testInfo) => {
await testInfo.attach('danmuku-layout', { contentType: 'application/json', body: JSON.stringify(await geometry(page)) })
await testInfo.attach('danmuku-native-page', { contentType: 'image/png', body: await page.screenshot() })
await page.evaluate(() => {
for (const art of window.danmukuPlayers || []) {
if (!art.isDestroy)
art.destroy()
}
})
const evidence = await page.evaluate(() => ({ workers: [], failures: [], ...window.danmukuEvidence, version: window.Artplayer?.version, retainedExternalSettings: [...document.querySelectorAll('[id^="danmuku-mount-"]')].map(node => node.querySelectorAll('.artplayer-plugin-danmuku').length) }))
await testInfo.attach('danmuku-worker-and-lifecycle', { contentType: 'application/json', body: JSON.stringify(evidence) })
expect(evidence.failures).toEqual([])
for (const worker of evidence.workers) {
expect(worker.url).toMatch(/^blob:/u)
expect(worker.errors).toEqual([])
expect(worker.terminated).toBe(1)
}
})
for (const core of ['published', 'candidate']) {
for (const format of ['main', 'legacy']) {
const label = `${core} / Danmuku 5.3.0 ${format}`
test(`${label}: native Worker renders three modes with synchronous filter and asynchronous beforeVisible`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'modes', testInfo)
await page.evaluate(async () => {
await window.danmukuPlugins[0].load([
{ id: 'scroll', text: 'SCROLL', mode: 0, time: 0.5 },
{ id: 'top', text: 'TOP', mode: 1, time: 0.5 },
{ id: 'bottom', text: 'BOTTOM', mode: 2, time: 0.5 },
{ id: 'filtered', text: 'FILTERED', mode: 1, time: 0.5 },
{ id: 'denied', text: 'DENIED', mode: 1, time: 0.5 },
{ id: 'gated', text: 'GATED', mode: 1, time: 0.5 },
])
})
await page.click('#play')
await expect.poll(() => visibleIds(page)).toEqual(expect.arrayContaining(['scroll', 'top', 'bottom']))
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.gates.some(item => item.id === 'gated'))).toBe(true)
expect(await visibleIds(page)).not.toContain('gated')
expect(await visibleIds(page)).not.toContain('filtered')
expect(await visibleIds(page)).not.toContain('denied')
expect(await page.evaluate(() => window.danmukuEvidence.gates.some(item => item.id === 'filtered'))).toBe(false)
await page.evaluate(() => window.releaseDanmukuVisibility(true))
await expect.poll(() => visibleIds(page)).toContain('gated')
const [layout] = await geometry(page)
for (const id of ['top', 'bottom']) {
const item = layout.nodes.find(node => node.id === id && node.visibility === 'visible')
expect(item).toBeTruthy()
expect(Math.abs(item.x + item.width / 2 - layout.player.width / 2)).toBeLessThanOrEqual(2)
expect(item.y).toBeGreaterThanOrEqual(9)
expect(item.y + item.height).toBeLessThanOrEqual(layout.player.height - 8)
}
expect(layout.nodes.find(node => node.id === 'top').y).toBeLessThan(layout.nodes.find(node => node.id === 'bottom').y)
expect(await page.evaluate(() => window.danmukuEvidence.workers[0].replies.length)).toBeGreaterThanOrEqual(4)
expect(external).toEqual([])
})
test(`${label}: six simultaneous fixed comments preserve usable track geometry and input order`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'density', testInfo)
await page.evaluate(async () => {
await window.danmukuPlugins[0].load(Array.from({ length: 6 }, (_, index) => ({ id: `dense-${index}`, text: `TRACK ${index}`, mode: 1, time: 0.5 })))
})
await page.click('#play')
await expect.poll(() => visibleIds(page)).toEqual(expect.arrayContaining(Array.from({ length: 6 }, (_, index) => `dense-${index}`)))
await page.click('#pause')
await expect.poll(() => page.evaluate(() => window.danmukuPlugins[0].isStop)).toBe(true)
const [layout] = await geometry(page)
const rows = Array.from({ length: 6 }, (_, index) => layout.nodes.find(node => node.id === `dense-${index}` && node.visibility === 'visible'))
expect(rows.every(Boolean)).toBe(true)
for (let index = 0; index < rows.length; index++) {
expect(rows[index].y).toBeGreaterThanOrEqual(9)
expect(rows[index].y + rows[index].height).toBeLessThanOrEqual(layout.player.height - 8)
if (index)
expect(rows[index].y).toBeGreaterThanOrEqual(rows[index - 1].y + rows[index - 1].height - 1)
}
const order = await visibleIds(page)
expect(order).toEqual(Array.from({ length: 6 }, (_, index) => `dense-${index}`))
await testInfo.attach('danmuku-dense-tracks', { contentType: 'application/json', body: JSON.stringify(rows) })
expect(external).toEqual([])
})
test(`${label}: native pause seek rate and public reset/load retain historical pool semantics`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'playback', testInfo)
await page.evaluate(async () => {
await window.danmukuPlugins[0].emit({ id: 'moving', text: 'MOVING', mode: 0, time: 0.5 })
})
await page.click('#play')
await expect.poll(() => visibleIds(page)).toContain('moving')
await page.click('#pause')
await expect.poll(() => page.evaluate(() => window.danmukuPlugins[0].isStop)).toBe(true)
const frozen = await page.evaluate(async () => {
const node = window.art.template.$danmuku.querySelector('[data-id="moving"]')
window.danmukuNodes = [node]
const left = node.getBoundingClientRect().left
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
return { left, next: node.getBoundingClientRect().left, state: node.dataset.state }
})
expect(frozen.state).toBe('stop')
expect(Math.abs(frozen.left - frozen.next)).toBeLessThanOrEqual(1)
await page.evaluate(async () => {
window.art.playbackRate = 2
const sought = new Promise(resolve => window.art.once('video:seeked', resolve))
window.art.currentTime = 2
await sought
})
expect(await page.evaluate(() => window.danmukuNodes[0].dataset.state)).toBe('stop')
await page.evaluate(async () => {
window.danmukuPlugins[0].reset()
await window.danmukuPlugins[0].emit({ id: 'reused', text: 'REUSED', mode: 0, time: window.art.currentTime + 0.5 })
})
await page.click('#play')
await expect.poll(() => visibleIds(page)).toContain('reused')
const reused = await page.evaluate(() => {
const node = window.art.template.$danmuku.querySelector('[data-id="reused"]')
const original = window.danmukuNodes[0]
const nodes = [...window.art.template.$danmuku.children]
return {
poolReused: original.textContent === 'REUSED' && nodes.includes(original),
visibleSameNode: node === original,
original: { id: original.dataset.id, state: original.dataset.state, visibility: getComputedStyle(original).visibility, text: original.textContent },
extraNodes: nodes.filter(item => item !== original).map(item => ({ id: item.dataset.id, state: item.dataset.state, visibility: getComputedStyle(item).visibility, text: item.textContent })),
transition: node.style.transitionDuration,
rate: window.art.playbackRate,
}
})
// Native initial evidence reused the pooled node but a competing old RAF
// loop left it hidden and allocated another visible node. Record both.
expect(reused.poolReused).toBe(true)
expect(reused.transition).toBe('4s')
expect(reused.rate).toBe(2)
await page.evaluate(async () => {
await window.danmukuPlugins[0].load([{ id: 'appended', text: 'APPENDED', mode: 1, time: window.art.currentTime + 0.5 }])
})
await expect.poll(() => visibleIds(page)).toContain('appended')
await page.click('#pause')
await page.evaluate(async () => {
await window.danmukuPlugins[0].load()
})
expect(await page.locator('#danmuku-player-0 .art-danmuku > div').count()).toBe(0)
await testInfo.attach('danmuku-pool-and-rate', { contentType: 'application/json', body: JSON.stringify({ frozen, reused, loadWithoutArgument: 'replaces from original empty option; explicit array load appends' }) })
expect(external).toEqual([])
})
test(`${label}: actual settings, asynchronous send lock, external fullscreen mount and two-player destruction`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'settings', testInfo)
const mount = page.locator('#danmuku-mount-0')
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
await mount.locator('.apd-config').hover()
await mount.locator('.apd-config-mode [data-mode="2"]').click()
expect(await page.evaluate(() => window.danmukuPlugins[0].option.modes)).toEqual([0, 1])
await mount.locator('.apd-style').hover()
await mount.locator('.apd-style-mode [data-mode="1"]').click()
expect(await page.evaluate(() => window.danmukuPlugins[0].option.mode)).toBe(1)
await mount.locator('.apd-input').fill('FIRST')
await mount.locator('.apd-send').click()
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.sends.length)).toBe(1)
await mount.locator('.apd-input').press('Enter')
expect(await page.evaluate(() => window.danmukuEvidence.sends.length)).toBe(1)
await page.evaluate(() => window.releaseDanmukuSending(true))
await expect(mount.locator('.apd-input')).toHaveValue('')
await expect(mount.locator('.apd-send')).toHaveClass(/apd-lock/u)
await mount.locator('.apd-input').fill('LOCKED')
await mount.locator('.apd-input').press('Enter')
expect(await page.evaluate(() => window.danmukuEvidence.sends.length)).toBe(1)
await expect(mount.locator('.apd-send')).not.toHaveClass(/apd-lock/u)
await page.evaluate(() => {
window.danmukuEmitter = document.querySelector('#danmuku-mount-0 .artplayer-plugin-danmuku')
window.art.fullscreenWeb = true
})
// FULLSCREEN_WEB_IN_BODY can move the player out of its original container.
await expect.poll(() => page.evaluate(() => window.art.template.$controlsCenter.contains(window.danmukuEmitter))).toBe(true)
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(0)
await page.evaluate(() => {
window.art.fullscreenWeb = false
})
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
expect(await page.evaluate(() => document.querySelector('#danmuku-mount-0').contains(window.danmukuEmitter))).toBe(true)
await page.evaluate(() => {
const art = window.createDanmukuPlayer(1)
const button = document.createElement('button')
button.id = 'play-second-danmuku'
button.textContent = 'Play second'
button.onclick = () => art.play()
document.body.appendChild(button)
})
await expect.poll(() => page.evaluate(() => window.danmukuPlayers[1].isReady)).toBe(true)
await page.evaluate(() => window.danmukuPlayers[0].destroy())
expect(await page.evaluate(() => window.danmukuEvidence.workers.map(worker => worker.terminated))).toEqual([1, 0])
// The released plugin does not dispose external Setting DOM; retain this observation.
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
await page.locator('#danmuku-mount-1 .apd-input').fill('SECOND')
await page.locator('#danmuku-mount-1 .apd-send').click()
await page.click('#play-second-danmuku')
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.events.some(item => item.index === 1 && item.event === 'visible' && item.text === 'SECOND'))).toBe(true)
expect(await page.evaluate(() => window.danmukuPlayers[1].isDestroy)).toBe(false)
expect(external).toEqual([])
})
}
}
+186
View File
@@ -0,0 +1,186 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { expect, test } from './fixtures.js'
const baseline = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/danmuku-release.json', import.meta.url), 'utf8'))
const release = baseline.release
const mediaSha256 = hash(fs.readFileSync(new URL('./media/pattern.mp4', import.meta.url)))
const implementations = new Map()
assert.equal(release.version, '5.3.0')
test.beforeAll(async () => {
const archive = await ensureArchive(release)
for (const format of ['main', 'legacy']) {
const member = `package/${release.manifest[format].replace(/^\.\//u, '')}`
const bytes = readMember(archive, member)
assert.equal(hash(bytes), release.files[member])
implementations.set(format, { member, sha256: hash(bytes), code: bytes.toString() })
}
})
test.afterEach(async ({ page }, testInfo) => {
await page.evaluate(() => {
if (window.loadObserverFrame !== undefined)
cancelAnimationFrame(window.loadObserverFrame)
if (window.loadArt && !window.loadArt.isDestroy)
window.loadArt.destroy()
})
await testInfo.attach('danmuku-load-final-observations', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({
evidence: window.loadEvidence,
destroyed: window.loadArt?.isDestroy,
retainedPlayerRoots: document.querySelectorAll('#danmuku-load-player .art-video-player').length,
retainedExternalSettings: document.querySelectorAll('#danmuku-load-mount .artplayer-plugin-danmuku').length,
}))) })
})
for (const format of ['main', 'legacy']) {
test(`published core / Danmuku 5.3.0 ${format}: 300-comment native load and frame/heap observation`, async ({ page }, testInfo) => {
const external = []
const origin = new URL(testInfo.project.use.baseURL).origin
await page.route('**/*', (route) => {
const url = new URL(route.request().url())
if (url.origin !== origin) {
external.push(url.href)
return route.abort()
}
return route.continue()
})
await page.goto('/test/player.html?core=published')
await page.evaluate(() => {
window.loadEvidence = { workers: [], loaded: [], visible: [], frameTimes: [], filtered: 0, heaps: [], failures: [], destroyEvents: 0 }
window.readLoadHeap = (phase) => {
const memory = performance.memory
const snapshot = memory && [memory.usedJSHeapSize, memory.totalJSHeapSize, memory.jsHeapSizeLimit].every(Number.isFinite)
? { usedJSHeapSize: memory.usedJSHeapSize, totalJSHeapSize: memory.totalJSHeapSize, jsHeapSizeLimit: memory.jsHeapSizeLimit }
: null
window.loadEvidence.heaps.push({ phase, timestamp: performance.now(), memory: snapshot })
}
window.readLoadHeap('before-plugin-script')
const NativeWorker = window.Worker
window.Worker = class ObservedLoadWorker extends NativeWorker {
constructor(url, options) {
super(url, options)
this.observation = { url: String(url), posts: 0, replies: 0, terminated: 0, errors: [] }
window.loadEvidence.workers.push(this.observation)
this.addEventListener('message', () => this.observation.replies++)
this.addEventListener('error', event => this.observation.errors.push(event.message))
}
postMessage(message, ...options) {
this.observation.posts++
return super.postMessage(message, ...options)
}
terminate() {
this.observation.terminated++
return super.terminate()
}
}
// Independent observer: never replace the plugin's RAF or mutate its clock.
function frame(timestamp) {
if (window.loadArt?.playing)
window.loadEvidence.frameTimes.push(timestamp)
window.loadObserverFrame = requestAnimationFrame(frame)
}
window.loadObserverFrame = requestAnimationFrame(frame)
})
const implementation = implementations.get(format)
await page.addScriptTag({ content: implementation.code })
await testInfo.attach('danmuku-load-inputs', { contentType: 'application/json', body: JSON.stringify({ core: 'published', coreVersion: await page.evaluate(() => window.Artplayer.version), pluginVersion: release.version, format, member: implementation.member, sha256: implementation.sha256, integrity: release.integrity, mediaSha256, comments: 300, modes: [0, 1, 2], scheduledTime: 0.5, heatmap: false, scope: 'Single bounded native sample; independent RAF observer and optional performance.memory readings. No forced GC, leak diagnosis, cross-browser heap equivalence or performance threshold.' }) })
await page.evaluate(async () => {
const container = document.querySelector('.player')
container.id = 'danmuku-load-player'
container.style.cssText = 'width:640px;height:360px'
const mount = document.createElement('div')
mount.id = 'danmuku-load-mount'
mount.style.cssText = 'width:640px;height:36px;background:#333'
container.after(mount)
const art = window.loadArt = new window.Artplayer({ container, url: '/test/pattern.mp4?case=danmuku-load-300', muted: true })
art.on('artplayerPluginDanmuku:loaded', queue => window.loadEvidence.loaded.push({ count: queue.length, ids: queue.map(item => item.id) }))
art.on('artplayerPluginDanmuku:visible', item => window.loadEvidence.visible.push({ id: item.id, mode: item.mode, mediaTime: art.currentTime, timestamp: performance.now() }))
art.on('artplayerPluginDanmuku:error', error => window.loadEvidence.failures.push(String(error)))
art.on('artplayerPluginDanmuku:destroy', () => window.loadEvidence.destroyEvents++)
art.plugins.add(window.artplayerPluginDanmuku({
danmuku: [],
mount,
heatmap: false,
speed: 8,
fontSize: 18,
margin: [10, 10],
antiOverlap: true,
filter(item) {
window.loadEvidence.filtered++
return item.id.startsWith('load-')
},
}))
const plugin = window.loadPlugin = art.plugins.artplayerPluginDanmuku
const input = Array.from({ length: 300 }, (_, index) => ({ id: `load-${String(index).padStart(3, '0')}`, text: `Load ${index}`, mode: index % 3, time: 0.5 }))
await plugin.load(input)
window.readLoadHeap('after-300-loaded')
document.querySelector('#play').onclick = () => art.play()
document.querySelector('#pause').onclick = () => art.pause()
})
await expect.poll(() => page.evaluate(() => window.loadArt.isReady && window.loadArt.template.$video.videoWidth > 0)).toBe(true)
const loaded = await page.evaluate(() => ({ latest: window.loadEvidence.loaded.at(-1), filtered: window.loadEvidence.filtered }))
expect(loaded.latest.count).toBe(300)
expect(new Set(loaded.latest.ids).size).toBe(300)
expect(loaded.filtered).toBe(300)
await page.click('#play')
// A state deadline, not a performance pass/fail budget or a fixed sleep.
await expect.poll(() => page.evaluate(() => window.loadArt.currentTime), { timeout: 15000 }).toBeGreaterThanOrEqual(4)
await page.click('#pause')
await expect.poll(() => page.evaluate(() => window.loadArt.template.$video.paused && window.loadPlugin.isStop)).toBe(true)
const sample = await page.evaluate(() => {
cancelAnimationFrame(window.loadObserverFrame)
window.loadObserverFrame = undefined
window.readLoadHeap('paused-after-four-media-seconds')
const art = window.loadArt
const rect = art.template.$player.getBoundingClientRect()
const nodes = [...art.template.$danmuku.children].map((node) => {
const position = node.getBoundingClientRect()
const style = getComputedStyle(node)
return { id: node.dataset.id, state: node.dataset.state, mode: node.dataset.mode, text: node.textContent, x: position.x - rect.x, y: position.y - rect.y, width: position.width, height: position.height, visibility: style.visibility, display: style.display }
})
const times = window.loadEvidence.frameTimes
const frameIntervals = times.slice(1).map((timestamp, index) => timestamp - times[index])
const sorted = [...frameIntervals].sort((a, b) => a - b)
return {
coreVersion: window.Artplayer.version,
mediaTime: art.currentTime,
video: { width: art.template.$video.videoWidth, height: art.template.$video.videoHeight, duration: art.duration },
loadedCount: window.loadEvidence.loaded.at(-1).count,
visibleEvents: window.loadEvidence.visible.length,
uniqueVisible: new Set(window.loadEvidence.visible.map(item => item.id)).size,
nodeCount: nodes.length,
visibleNodeCount: nodes.filter(node => node.visibility === 'visible' && node.display !== 'none').length,
hiddenNodeCount: nodes.filter(node => node.visibility === 'hidden' || node.display === 'none').length,
nodes,
frameTimes: times,
frameIntervals,
frameSummary: { count: frameIntervals.length, median: sorted[Math.floor(sorted.length / 2)] ?? null, p95: sorted[Math.floor(sorted.length * 0.95)] ?? null, max: sorted.at(-1) ?? null },
heaps: window.loadEvidence.heaps,
workers: window.loadEvidence.workers,
}
})
await testInfo.attach('danmuku-load-sample', { contentType: 'application/json', body: JSON.stringify(sample) })
await testInfo.attach('danmuku-load-screenshot', { contentType: 'image/png', body: await page.screenshot() })
expect(sample.mediaTime).toBeGreaterThanOrEqual(4)
expect(sample.uniqueVisible).toBeGreaterThan(0)
expect(sample.visibleNodeCount).toBeGreaterThan(0)
expect(sample.frameIntervals.length).toBeGreaterThan(1)
expect(sample.workers).toHaveLength(1)
expect(sample.workers[0].url).toMatch(/^blob:/u)
expect(sample.workers[0].posts).toBeGreaterThan(0)
expect(sample.workers[0].replies).toBeGreaterThan(0)
expect(sample.workers[0].errors).toEqual([])
await page.evaluate(() => {
window.loadArt.destroy()
window.readLoadHeap('immediately-after-destroy')
})
expect(await page.evaluate(() => window.loadArt.isDestroy)).toBe(true)
expect(await page.evaluate(() => window.loadEvidence.destroyEvents)).toBe(1)
expect(await page.evaluate(() => window.loadEvidence.workers[0].terminated)).toBe(1)
expect(external).toEqual([])
})
}
+278
View File
@@ -0,0 +1,278 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Historical failures are explicit Node observations.
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { danmukuEnvironment, danmukuHistorical, deferred } from './helpers/danmuku.js'
const implementations = await danmukuHistorical()
const cache = fileURLToPath(new URL('../refactor/.cache/danmuku02-controlled/', import.meta.url))
fs.mkdirSync(cache, { recursive: true })
const evidenceDirectory = fs.mkdtempSync(path.join(cache, 'run-'))
function isolated(implementation, scenario) {
const program = `
import fs from 'node:fs';
import vm from 'node:vm';
import { danmukuEnvironment } from './test/helpers/danmuku.js';
const implementation = JSON.parse(fs.readFileSync(0, 'utf8'));
const scenario = ${JSON.stringify(scenario)};
let settled = false;
let env;
process.on('uncaughtExceptionMonitor', (error, origin) => {
process.stdout.write(JSON.stringify({ stage: 'fatal', origin, error: error.message, settled, errors: env?.events.filter(e => e.name === 'artplayerPluginDanmuku:error').length }) + '\\n');
});
if (scenario === 'heatmap-zero' || scenario === 'heatmap-positive') {
env = danmukuEnvironment(implementation, { heatmapWidth: 99 });
env.factory({ danmuku: [], heatmap: scenario === 'heatmap-positive' ? { sampling: 1 } : true })(env.art);
const sandbox = vm.createContext({ run: () => env.art.emit('ready') });
process.stdout.write('heatmap-ready\\n');
try {
vm.runInContext('run()', sandbox, { timeout: 100 });
process.stdout.write(JSON.stringify({ stage: 'complete' }) + '\\n');
} catch (error) {
if (error.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw error;
process.stdout.write(JSON.stringify({ stage: 'bounded-timeout', code: error.code }) + '\\n');
}
process.exit(0);
}
if (scenario === 'beforeVisible-reject') {
env = danmukuEnvironment(implementation);
const plugin = env.factory({ danmuku: [], beforeVisible: () => Promise.reject(new Error('beforeVisible-original')) })(env.art);
await plugin.emit({ text: 'visible', time: 10 });
env.art.playing = true;
env.art.emit('video:play');
// Browser rAF ignores a callback's returned Promise. Do not observe it here.
env.frame();
} else {
env = danmukuEnvironment(implementation, { fetch: async () => {
if (scenario === 'fetch-reject') throw new Error('fetch-original');
return { text: async () => { throw new Error('text-original'); } };
} });
const plugin = env.factory({ danmuku: [] })(env.art);
plugin.load('/controlled.xml').then(() => settled = true, () => settled = true);
}
setTimeout(() => process.stdout.write('unexpected-survival\\n'), 200);
`
const child = spawnSync(process.execPath, ['--unhandled-rejections=strict', '--input-type=module', '-e', program], {
cwd: fileURLToPath(new URL('../', import.meta.url)),
input: JSON.stringify(implementation),
encoding: 'utf8',
timeout: 3000,
maxBuffer: 2 * 1024 * 1024,
})
const evidence = { implementation: implementation.name, scenario, status: child.status, signal: child.signal, errorCode: child.error?.code ?? null, stdout: child.stdout, stderr: child.stderr }
fs.writeFileSync(path.join(evidenceDirectory, `${implementation.name}-${scenario}.json`), `${JSON.stringify(evidence, null, 2)}\n`)
assert.equal(child.error, undefined, `Isolated process infrastructure failed: ${child.error?.message}`)
return evidence
}
for (const implementation of implementations) {
for (const scenario of ['fetch-reject', 'text-reject']) {
test(`danmuku ${implementation.name}: historical ${scenario} crashes the isolated process while public load stays pending`, () => {
const evidence = isolated(implementation, scenario)
assert.equal(evidence.status, 1)
const record = JSON.parse(evidence.stdout.trim())
assert.equal(record.stage, 'fatal')
assert.equal(record.origin, 'unhandledRejection')
assert.equal(record.settled, false)
assert.equal(record.errors, 0)
assert.equal(record.error, scenario === 'fetch-reject' ? 'fetch-original' : 'text-original')
})
}
test(`danmuku ${implementation.name}: historical beforeVisible rejection is an unobserved animation callback Promise`, () => {
const evidence = isolated(implementation, 'beforeVisible-reject')
assert.equal(evidence.status, 1)
const record = JSON.parse(evidence.stdout.trim())
assert.equal(record.error, 'beforeVisible-original')
assert.equal(record.origin, 'unhandledRejection')
assert.equal(record.errors, 0)
})
test(`danmuku ${implementation.name}: historical heatmap width 99 blocks at default sampling zero but sampling 1 completes`, () => {
const zero = isolated(implementation, 'heatmap-zero')
const positive = isolated(implementation, 'heatmap-positive')
assert.equal(zero.status, 0)
assert.equal(positive.status, 0)
assert.match(zero.stdout, /heatmap-ready\n\{"stage":"bounded-timeout","code":"ERR_SCRIPT_EXECUTION_TIMEOUT"\}/u)
assert.match(positive.stdout, /heatmap-ready\n\{"stage":"complete"\}/u)
})
for (const event of ['error', 'empty-xml']) {
test(`danmuku ${implementation.name}: historical Bilibili ${event} leaves loading pending and parser worker alive`, async () => {
const env = danmukuEnvironment(implementation, event === 'empty-xml' ? { fetch: async () => ({ text: async () => '' }) } : {})
const plugin = env.factory({ danmuku: [] })(env.art)
let settled = false
plugin.load('/controlled.xml').then(() => settled = true, () => settled = true)
await env.flush()
assert.equal(env.workers.length, 2)
const worker = env.workers[1]
assert.equal(worker.messages.length, 1)
if (event === 'error') {
assert.equal(worker.onerror, undefined)
worker.fail(new Error('parser worker failure'))
}
else {
assert.equal(worker.messages[0].message.xml, '')
// Execute the actual Blob, not a reimplementation of its empty-input branch.
await worker.execute(worker.messages[0].message)
}
await env.flush()
assert.equal(settled, false)
env.destroy()
assert.equal(worker.terminated, false, 'Parser worker is not owned by the Danmuku destroy path')
})
}
test(`danmuku ${implementation.name}: historical successful parser leaves its Blob URL allocated`, async () => {
const env = danmukuEnvironment(implementation)
const plugin = env.factory({ danmuku: [] })(env.art)
const pending = plugin.load('/controlled.xml')
await env.flush()
const worker = env.workers[1]
await worker.execute(worker.messages[0].message)
const internal = await pending
assert.equal(internal.queue.length, 1)
assert.equal(internal.queue[0].text, 'xml')
assert.equal(worker.terminated, true)
assert.equal(env.urls.has(worker.url), true)
assert.equal(env.revoked.includes(worker.url), false)
env.destroy()
})
test(`danmuku ${implementation.name}: historical parser worker completion emits new data after player destruction`, async () => {
const env = danmukuEnvironment(implementation)
const plugin = env.factory({ danmuku: [] })(env.art)
const pending = plugin.load('/controlled.xml')
await env.flush()
const worker = env.workers[1]
env.destroy()
const count = env.events.length
assert.equal(worker.terminated, false)
await worker.execute(worker.messages[0].message)
const internal = await pending
assert.equal(internal.queue[0].text, 'xml')
assert.deepEqual(env.events.slice(count).map(event => event.name), ['artplayerPluginDanmuku:loaded'])
assert.equal(worker.terminated, true)
})
test(`danmuku ${implementation.name}: historical overlapping replacement loads let the oldest completion overwrite newer data`, async () => {
const env = danmukuEnvironment(implementation)
const plugin = env.factory({ danmuku: [] })(env.art)
const first = deferred()
const second = deferred()
const jobs = [first, second]
// Change an additional scalar because the historical function-only config bug is separate.
plugin.config({ danmuku: () => jobs.shift().promise, opacity: 0.9 })
const older = plugin.load()
const newer = plugin.load()
second.resolve([{ text: 'newer', time: 10 }])
const internal = await newer
assert.deepEqual(Array.from(internal.queue, item => item.text), ['newer'])
first.resolve([{ text: 'older', time: 10 }])
await older
assert.deepEqual(Array.from(internal.queue, item => item.text), ['older'])
env.destroy()
})
test(`danmuku ${implementation.name}: historical asynchronous input mutates queue and emits loaded after destroy`, async () => {
const env = danmukuEnvironment(implementation)
const plugin = env.factory({ danmuku: [] })(env.art)
const input = deferred()
const pending = plugin.load(() => input.promise)
env.destroy()
const count = env.events.length
input.resolve([{ text: 'late', time: 10 }])
const internal = await pending
assert.deepEqual(Array.from(internal.queue, item => item.text), ['late'])
assert.deepEqual(env.events.slice(count).map(event => event.name), ['artplayerPluginDanmuku:loaded'])
})
test(`danmuku ${implementation.name}: historical pending beforeVisible allocates DOM and posts to a terminated worker after destroy`, async () => {
const visible = deferred()
const env = danmukuEnvironment(implementation)
const plugin = env.factory({ danmuku: [], beforeVisible: () => visible.promise })(env.art)
await plugin.emit({ text: 'late visible', time: 10 })
env.art.playing = true
env.art.emit('video:play')
env.frame()
await env.flush()
assert.equal(env.art.template.$danmuku.children.length, 0)
env.destroy()
visible.resolve(true)
await env.flush()
assert.equal(env.art.template.$danmuku.children.length, 1)
assert.equal(env.workers[0].messages.length, 1)
assert.equal(env.workers[0].messages[0].afterTerminate, true)
assert.equal(env.events.filter(event => event.name === 'artplayerPluginDanmuku:visible').length, 0)
})
test(`danmuku ${implementation.name}: historical worker response continuation still mutates state after destroy`, async () => {
const env = danmukuEnvironment(implementation)
const plugin = env.factory({ danmuku: [] })(env.art)
await plugin.emit({ text: 'pending position', time: 10 })
env.art.playing = true
env.art.emit('video:play')
const frame = env.frame()
await env.flush()
const worker = env.workers[0]
const { id } = worker.messages[0].message
const internal = plugin.show()
// Delivery starts before terminate; its awaiting rAF continuation runs afterward.
// This does not require a native Worker to dispatch a new event after terminate.
worker.deliver({ id, result: 10 })
env.destroy()
assert.equal(internal.queue[0].$state, 'wait')
await frame
assert.equal(internal.queue[0].$state, 'ready')
assert.equal(internal.queue[0].$ref, null)
assert.equal(env.frames.size, 0)
assert.equal(env.events.filter(event => event.name === 'artplayerPluginDanmuku:visible').length, 0)
})
test(`danmuku ${implementation.name}: historical play and playing schedule duplicate loops and overwrite pending worker response handlers`, async () => {
const env = danmukuEnvironment(implementation)
const plugin = env.factory({ danmuku: [] })(env.art)
await plugin.emit({ text: 'two loops', time: 10 })
env.art.playing = true
env.art.emit('video:play')
env.art.emit('video:playing')
assert.equal(env.frames.size, 2)
let firstSettled = false
env.frame().then(() => firstSettled = true)
await env.flush()
env.tick(1)
const second = env.frame()
await env.flush()
const worker = env.workers[0]
assert.equal(worker.messages.length, 2)
const firstId = worker.messages[0].message.id
const secondId = worker.messages[1].message.id
assert.notEqual(firstId, secondId)
worker.deliver({ id: firstId, result: 10 })
await env.flush()
assert.equal(firstSettled, false)
worker.deliver({ id: secondId, result: 10 })
await second
assert.equal(firstSettled, false)
assert.equal(env.events.filter(event => event.name === 'artplayerPluginDanmuku:visible').length, 1)
env.destroy()
})
test(`danmuku ${implementation.name}: historical duplicate start then pause cancels only the latest scheduled frame`, () => {
const env = danmukuEnvironment(implementation)
env.factory({ danmuku: [] })(env.art)
env.art.emit('video:play')
env.art.emit('video:playing')
env.art.emit('video:pause')
assert.equal(env.frames.size, 1)
env.destroy()
assert.equal(env.frames.size, 1)
})
}
process.stdout.write(`Danmuku controlled subprocess evidence: ${evidenceDirectory}\n`)
+239
View File
@@ -0,0 +1,239 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import { build } from 'esbuild'
import { verifyDanmukuContract } from '../../refactor/scripts/danmuku-contract.mjs'
import { ensureArchive, readMember, refactorDir } from '../../refactor/scripts/releases.mjs'
export function deferred() {
let resolve
let reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
export async function danmukuHistorical() {
const { baseline, archives, sources } = await verifyDanmukuContract()
const prefix = 'packages/artplayer-plugin-danmuku/'
const compiled = await build({ entryPoints: [`${prefix}src/index.js`], bundle: true, write: false, format: 'cjs', platform: 'browser', plugins: [{ name: 'frozen-danmuku-failures', setup(build) {
build.onResolve({ filter: /.*/ }, args => ({ path: path.posix.normalize(args.importer ? path.posix.join(path.posix.dirname(args.importer), args.path) : args.path), namespace: 'frozen' }))
build.onLoad({ filter: /.*/, namespace: 'frozen' }, (args) => {
if (args.path.includes('?worker'))
return { contents: 'export default Worker', loader: 'js' }
const file = args.path.split('?')[0]
const found = sources.get(file) ?? sources.get(`${file}.js`)
assert.notEqual(found, undefined, file)
return { contents: args.path.includes('?') ? `export default ${JSON.stringify(found)}` : found, loader: 'js' }
})
} }] })
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()
return [
{ name: 'frozen-source', code: compiled.outputFiles[0].text, coreCode },
...['main', 'legacy'].map(field => ({ name: `published-5.3.0-${field}`, code: readMember(archives.get('5.3.0'), `package/${baseline.release.manifest[field].replace(/^\.\//u, '')}`).toString(), coreCode })),
]
}
class Element {
constructor() {
this.style = {}
this.dataset = {}
this.children = []
this.nodes = new Map()
this.clientWidth = 640
this.clientHeight = 360
this.offsetWidth = 640
this.offsetHeight = 100
this.offsetTop = 0
this.className = ''
this.textContent = ''
this.value = ''
}
get [Symbol.toStringTag]() { return 'HTMLDivElement' }
appendChild(child) {
child.parentElement = this
this.children.push(child)
return child
}
querySelector(selector) {
if (!this.nodes.has(selector))
this.nodes.set(selector, new Element())
return this.nodes.get(selector)
}
getBoundingClientRect() { return { top: 0, left: 0, right: this.clientWidth, bottom: this.clientHeight, width: this.clientWidth, height: this.clientHeight } }
setAttribute(key, value) { this[key] = value }
addEventListener() {}
}
export function danmukuEnvironment(implementation, settings = {}) {
const coreModule = { exports: {} }
vm.runInNewContext(implementation.coreCode, { module: coreModule, exports: coreModule.exports }, { timeout: 1000 })
const events = []
const listeners = new Map()
const workers = []
const frames = new Map()
const timers = new Map()
const proxies = []
const controls = []
const requests = []
const urls = new Map()
const revoked = []
const consoleErrors = []
let id = 0
let now = 1000
const host = {
requestAnimationFrame(callback) {
frames.set(++id, callback)
return id
},
cancelAnimationFrame(id) { frames.delete(id) },
}
class Worker {
constructor(url) {
this.url = url
this.messages = []
this.listeners = new Map()
this.terminated = false
workers.push(this)
}
terminate() { this.terminated = true }
postMessage(message) { this.messages.push({ message, afterTerminate: this.terminated }) }
addEventListener(name, callback) { this.listeners.set(name, [...this.listeners.get(name) || [], callback]) }
deliver(data) { this.onmessage?.({ data }) }
async execute(message) {
const blob = urls.get(this.url)
assert(blob, 'Expected the parser actual Blob source')
const workerContext = vm.createContext({ postMessage: data => this.deliver(data) })
vm.runInContext(await blob.text(), workerContext, { timeout: 1000 })
workerContext.input = message
vm.runInContext('onmessage({ data: input })', workerContext, { timeout: 1000 })
}
fail(error) {
this.onerror?.(error)
for (const callback of this.listeners.get('error') || [])
callback(error)
}
}
class Clock extends Date {
static now() { return now }
}
const module = { exports: {} }
const context = vm.createContext({
window: host,
Worker,
Blob,
Date: Clock,
URL: {
createObjectURL(blob) {
const url = `blob:danmuku-${++id}`
urls.set(url, blob)
return url
},
revokeObjectURL(url) { revoked.push(url) },
},
atob: value => Buffer.from(value, 'base64').toString('binary'),
setTimeout(callback, delay) {
timers.set(++id, { callback, delay })
return id
},
clearTimeout(id) { timers.delete(id) },
fetch: async (url) => {
requests.push(url)
if (settings.fetch)
return settings.fetch(url)
return { text: async () => '<i><d p="10,1,25,16777215,1,0,u,1">xml</d></i>' }
},
console: { error: (...args) => consoleErrors.push(args) },
module,
exports: module.exports,
})
vm.runInContext(implementation.code, context, { timeout: 1000 })
const factory = typeof module.exports === 'function' ? module.exports : module.exports.default
const body = new Element()
context.document = { querySelector: selector => selector === '#external' ? body : null, createElement: () => new Element() }
const utils = {
clamp: (value, min, max) => Math.min(Math.max(value, min), max),
setStyle: (element, key, value) => element.style[key] = value,
setStyles: (element, style) => Object.assign(element.style, style),
errorHandle: (value, message) => {
if (!value)
throw new Error(message)
return value
},
createElement: () => new Element(),
query: (selector, element) => element.querySelector(selector),
append: (element, child) => element.appendChild(child),
tooltip() {},
inverseClass() {},
addClass() {},
removeClass() {},
}
const art = {
constructor: { utils, validator: coreModule.exports.validator },
template: { $danmuku: new Element(), $player: new Element(), $controlsCenter: new Element() },
currentTime: 10,
duration: 60,
played: 0,
option: { isLive: false },
width: 640,
playbackRate: 1,
playing: false,
plugins: {},
controls: { add(option) {
const element = new Element()
element.offsetWidth = settings.heatmapWidth ?? 640
controls.push({ option, element })
option.mounted?.(element)
return undefined
} },
on(name, callback) { listeners.set(name, [...listeners.get(name) || [], callback]) },
off(name, callback) { listeners.set(name, (listeners.get(name) || []).filter(item => item !== callback)) },
emit(name, ...args) {
events.push({ name, args })
for (const callback of listeners.get(name) || [])
callback(...args)
},
proxy(target, name, callback) { proxies.push({ target, name, callback }) },
}
return {
context,
factory,
art,
workers,
events,
listeners,
frames,
timers,
proxies,
controls,
requests,
urls,
revoked,
consoleErrors,
async flush() {
for (let step = 0; step < 12; step++)
await Promise.resolve()
},
frame(frameId = frames.keys().next().value) {
const callback = frames.get(frameId)
assert.equal(typeof callback, 'function', 'Expected one scheduled animation frame')
frames.delete(frameId)
return callback(now)
},
tick(milliseconds) { now += milliseconds },
destroy() {
art.isDestroy = true
art.emit('destroy')
},
}
}