Files
ArtPlayer/test/browser/danmuku-timing-diagnostic.spec.js
T

124 lines
7.0 KiB
JavaScript

import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { browserCandidate } from '../helpers/browser-candidate.js'
import { expect, test } from './fixtures.js'
const release = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/danmuku-release.json', import.meta.url), 'utf8')).release
let provenance
const implementations = new Map()
test.beforeAll(async () => {
const member = `package/${release.manifest.main.replace(/^\.\//u, '')}`
const published = readMember(await ensureArchive(release), member)
assert.equal(hash(published), release.files[member])
implementations.set('published', published.toString())
const candidate = await browserCandidate('artplayer-plugin-danmuku', process.env.ARTPLAYER_DANMUKU_ARTIFACT)
provenance = candidate.provenance
implementations.set('candidate', candidate.code)
})
for (const implementation of ['published', 'candidate']) {
for (const scenario of ['main-thread-gap', 'async-visibility-gap']) {
test(`${implementation}: native ${scenario} records actual frame and eligibility gaps`, async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=candidate')
const code = implementations.get(implementation)
await testInfo.attach('danmuku-selected-input', { contentType: 'application/json', body: JSON.stringify({ implementation, provenance: implementation === 'candidate' ? provenance : { kind: 'published', sha256: hash(code) } }) })
await page.addScriptTag({ content: code })
await page.evaluate(() => window.createPlayer('/assets/sample/video.mp4'))
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.click('#pause')
await page.evaluate(async (scenario) => {
const art = window.art
const evidence = window.timingEvidence = { frames: [], eligibility: [], visible: [], callbacks: [], block: null }
const base = art.currentTime
window.timingEnd = base + 2.5
const rows = [
{ id: 'first', text: 'first', time: base + 0.45, mode: 1 },
{ id: 'middle', text: 'middle', time: base + 0.75, mode: 1 },
{ id: 'sentinel', text: 'after gap', time: base + 1.7, mode: 2 },
]
art.on('artplayerPluginDanmuku:visible', row => evidence.visible.push({ id: row.id, time: art.currentTime, wall: performance.now() }))
art.plugins.add(window.artplayerPluginDanmuku({
danmuku: [],
heatmap: false,
emitter: false,
antiOverlap: false,
beforeVisible(row) {
const entry = { id: row.id, start: performance.now(), media: art.currentTime }
evidence.callbacks.push(entry)
if (scenario === 'async-visibility-gap' && row.id === 'first') {
return new Promise(resolve => setTimeout(() => {
entry.end = performance.now()
resolve(true)
}, 600))
}
entry.end = performance.now()
return true
},
}))
const plugin = art.plugins.artplayerPluginDanmuku
const owner = window.timingOwner = plugin.config({})
const getter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(owner), 'readys').get
// Record the real getter invocation without changing rows, state or returned order.
Object.defineProperty(owner, 'readys', { configurable: true, get() {
const ready = getter.call(this)
evidence.eligibility.push({ wall: performance.now(), time: art.currentTime, ids: ready.map(row => row.id) })
return ready
} })
await plugin.load(rows)
let blocked = false
function observe(wall) {
if (art.playing) {
evidence.frames.push({ wall, time: art.currentTime })
if (scenario === 'main-thread-gap' && !blocked && art.currentTime >= base + 0.2) {
blocked = true
const start = performance.now()
const before = art.currentTime
// Intentional bounded main-thread work. Media and native RAF are never faked.
while (performance.now() - start < 800) { /* Controlled CPU load. */ }
evidence.block = { start, end: performance.now(), before, after: art.currentTime }
}
}
window.timingFrame = requestAnimationFrame(observe)
}
window.timingFrame = requestAnimationFrame(observe)
}, scenario)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.currentTime >= window.timingEnd)).toBe(true)
await page.click('#pause')
const observation = await page.evaluate(() => {
cancelAnimationFrame(window.timingFrame)
const evidence = window.timingEvidence
const rows = window.timingOwner.queue.map(row => ({ id: row.id, time: row.time, state: row.$state }))
const visible = new Set(evidence.visible.map(row => row.id))
return { ...evidence, rows, missing: rows.filter(row => !visible.has(row.id)).map(row => row.id), media: { time: window.art.currentTime, width: window.art.video.videoWidth } }
})
await testInfo.attach('danmuku-timing-observation', { contentType: 'application/json', body: JSON.stringify({ implementation, scenario, sha256: hash(code), observation, scope: 'Native timing with controlled load, original readys getter and unmodified queue states. Candidate must recover both CPU and asynchronous gaps; published CPU loss is observed separately. This is not sustained-load acceptance.' }) })
expect(observation.media.width).toBeGreaterThan(0)
expect(observation.rows).toHaveLength(3)
expect(observation.frames.length).toBeGreaterThan(2)
expect(observation.eligibility.length).toBeGreaterThan(2)
expect(observation.visible.some(row => row.id === 'sentinel')).toBe(true)
if (scenario === 'main-thread-gap')
expect(observation.block.end - observation.block.start).toBeGreaterThanOrEqual(800)
else
expect(observation.callbacks.find(row => row.id === 'first').end - observation.callbacks.find(row => row.id === 'first').start).toBeGreaterThanOrEqual(550)
if (scenario === 'async-visibility-gap') {
expect(observation.missing).toEqual(implementation === 'candidate' ? [] : ['middle'])
expect(observation.visible.map(row => row.id)).toEqual(implementation === 'candidate' ? ['first', 'middle', 'sentinel'] : ['first', 'sentinel'])
if (implementation === 'candidate') {
for (let index = 1; index < observation.callbacks.length; index++)
expect(observation.callbacks[index].start).toBeGreaterThanOrEqual(observation.callbacks[index - 1].end)
}
}
if (implementation === 'candidate') {
expect(observation.missing).toEqual([])
expect(observation.visible.map(row => row.id)).toEqual(['first', 'middle', 'sentinel'])
}
await page.evaluate(() => window.art.destroy())
})
}
}