mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-25 08:01:20 -08:00
test(danmuku): [PKG-DANMUKU-07] verify native load and shared layer stability
This commit is contained in:
1 parent
4f559cb31d
commit
00e81309ca
14 files changed
+6724
-11
No files matched your search
@@ -147,3 +147,14 @@ rows and serial callbacks; the fixed published baseline retains its missing midd
|
||||
row. CPU-block observations remain diagnostic, not no-loss acceptance. Both it and
|
||||
`danmuku-lifetime.spec.js` accept the same artifact override. The latter verifies
|
||||
the full visible lifetime after a controlled delay to an actual Worker request.
|
||||
|
||||
`danmuku-stability.spec.js` runs three native 14-second media cycles at 2 or 20
|
||||
rows per second, appending through the public load(rows) contract. The candidate
|
||||
must deliver and recycle every row once, preserve state membership and reuse
|
||||
actual node identities. Its 90-second per-case deadline budgets the 42 seconds
|
||||
of media plus setup/teardown; it is not a relaxation of other tests. Heap readings
|
||||
remain observations. No media/RAF clock is replaced or ready state seeded here.
|
||||
`danmuku-mask-boundary.spec.js` verifies the core-owned root and external CSS mask
|
||||
across commands and destroy(false), with actual video and placement Worker. It
|
||||
uses explicit ready rows for resource setup and a fixed SVG mask; it does not
|
||||
load the Mask SDK/model or provide timestamp-delivery evidence.
|
||||
@@ -0,0 +1,176 @@
|
||||
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.addScriptTag({ content: candidate })
|
||||
await page.evaluate(() => {
|
||||
window.maskBoundaryEvidence = { snapshots: [], workers: [], visible: [], errors: [] }
|
||||
const NativeWorker = window.Worker
|
||||
window.Worker = class extends NativeWorker {
|
||||
constructor(url, options) {
|
||||
super(url, options)
|
||||
this.observation = { requests: [], replies: [], terminated: 0 }
|
||||
window.maskBoundaryEvidence.workers.push(this.observation)
|
||||
this.addEventListener('message', ({ data }) => this.observation.replies.push({ id: data.id, result: data.result ?? null }))
|
||||
}
|
||||
|
||||
postMessage(message, ...args) {
|
||||
this.observation.requests.push({ type: message.type, id: message.id })
|
||||
return super.postMessage(message, ...args)
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.observation.terminated++
|
||||
return super.terminate()
|
||||
}
|
||||
}
|
||||
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true })
|
||||
window.maskBoundaryLayer = window.art.template.$danmuku
|
||||
const layer = window.maskBoundaryLayer
|
||||
// Fixed CSS input tests Danmuku's shared-layer contract. No Mask plugin,
|
||||
// model, TensorFlow backend or segmentation output is loaded or simulated.
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="320" height="360" fill="white"/></svg>'
|
||||
layer.style.maskImage = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
|
||||
Object.assign(layer.style, { maskMode: 'alpha', maskSize: 'contain', maskRepeat: 'no-repeat', backgroundSize: 'contain', backgroundRepeat: 'no-repeat' })
|
||||
window.maskBoundarySnapshot = (label) => {
|
||||
const properties = ['mask-image', 'mask-mode', 'mask-size', 'mask-repeat', 'background-size', 'background-repeat']
|
||||
const computed = getComputedStyle(layer)
|
||||
const snapshot = {
|
||||
label,
|
||||
sameLayer: window.art.template.$danmuku === layer,
|
||||
connected: layer.isConnected,
|
||||
styles: properties.map(name => ({ name, value: layer.style.getPropertyValue(name), priority: layer.style.getPropertyPriority(name), computed: computed.getPropertyValue(name) })),
|
||||
children: layer.children.length,
|
||||
opacity: layer.style.opacity,
|
||||
time: window.art.currentTime,
|
||||
}
|
||||
window.maskBoundaryEvidence.snapshots.push(snapshot)
|
||||
return snapshot
|
||||
}
|
||||
window.maskBoundaryBase = window.maskBoundarySnapshot('before-registration')
|
||||
window.art.on('artplayerPluginDanmuku:visible', item => window.maskBoundaryEvidence.visible.push(item.id))
|
||||
window.art.on('artplayerPluginDanmuku:error', error => window.maskBoundaryEvidence.errors.push(String(error)))
|
||||
window.art.plugins.add(window.artplayerPluginDanmuku({ danmuku: [], heatmap: false, speed: 8, margin: [10, 10], fontSize: 20 }))
|
||||
window.maskBoundaryPlugin = window.art.plugins.artplayerPluginDanmuku
|
||||
document.querySelector('#play').onclick = () => window.art.play()
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady && window.art.template.$video.videoWidth > 0)).toBe(true)
|
||||
// Real slow playback leaves room for lifecycle checks on the finite fixture.
|
||||
await page.evaluate(() => window.art.playbackRate = 0.25)
|
||||
await testInfo.attach('danmuku-mask-boundary-inputs', {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ core, artifact: process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'source UMD', sha256: hash(candidate), media: '/test/pattern.mp4', scope: 'Shared root-layer identity and CSS preservation with fixed inline SVG mask; native video, DOM, RAF and placement Worker. No Mask SDK/model/end-to-end acceptance. Eligible rows are explicit resource-test preconditions, not timestamp-delivery evidence.' }),
|
||||
})
|
||||
return external
|
||||
}
|
||||
|
||||
async function assertLayer(page, label) {
|
||||
const { baseline, snapshot } = await page.evaluate(label => ({ baseline: window.maskBoundaryBase, snapshot: window.maskBoundarySnapshot(label) }), label)
|
||||
expect(snapshot.sameLayer, label).toBe(true)
|
||||
expect(snapshot.connected, label).toBe(true)
|
||||
expect(snapshot.styles, label).toEqual(baseline.styles)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
async function renderRow(page, id) {
|
||||
await page.evaluate(async (id) => {
|
||||
const owner = await window.maskBoundaryPlugin.emit({ id, text: id, mode: 1, time: 0 })
|
||||
// Establish eligibility, then require actual placement Worker and visible DOM.
|
||||
owner.setState(owner.queue[owner.queue.length - 1], 'ready')
|
||||
window.maskBoundaryOwner = owner
|
||||
}, id)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(id => window.maskBoundaryEvidence.visible.includes(id), id)).toBe(true)
|
||||
await expect.poll(() => page.evaluate((id) => {
|
||||
const node = [...window.maskBoundaryLayer.children].find(node => node.dataset.id === id)
|
||||
if (!node)
|
||||
return false
|
||||
const rect = node.getBoundingClientRect()
|
||||
return node.dataset.state === 'emit' && getComputedStyle(node).visibility === 'visible' && rect.width > 0 && rect.height > 0
|
||||
}, id)).toBe(true)
|
||||
}
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
if (page.isClosed())
|
||||
return
|
||||
const evidence = await page.evaluate(() => {
|
||||
if (window.maskBoundaryEvidence && window.art && !window.art.isDestroy)
|
||||
window.art.destroy(false)
|
||||
return window.maskBoundaryEvidence ?? null
|
||||
})
|
||||
await testInfo.attach('danmuku-mask-boundary-evidence', { contentType: 'application/json', body: JSON.stringify(evidence) })
|
||||
})
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: Danmuku retains the core layer and external CSS mask across commands and retained-HTML destroy`, async ({ page }, testInfo) => {
|
||||
const external = await setup(page, core, testInfo)
|
||||
const initial = await assertLayer(page, 'after-registration')
|
||||
const image = initial.styles.find(item => item.name === 'mask-image')
|
||||
expect(image.value).toContain('data:image/svg+xml,')
|
||||
expect(image.computed).toContain('data:image/svg+xml,')
|
||||
await renderRow(page, 'MASK-BOUNDARY-EMIT')
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0)
|
||||
await assertLayer(page, 'after-native-emit')
|
||||
await testInfo.attach('danmuku-mask-boundary-playing', { contentType: 'image/png', body: await page.screenshot() })
|
||||
|
||||
const reset = await page.evaluate(() => {
|
||||
const owner = window.maskBoundaryPlugin.reset()
|
||||
return { sameOwner: owner === window.maskBoundaryOwner, states: owner.queue.map(item => item.$state) }
|
||||
})
|
||||
expect(reset).toEqual({ sameOwner: true, states: ['wait'] })
|
||||
await assertLayer(page, 'after-reset')
|
||||
|
||||
const replacement = await page.evaluate(async () => {
|
||||
window.maskBoundaryPlugin.config({ danmuku: [{ id: 'CONFIGURED-REPLACEMENT', text: 'CONFIGURED-REPLACEMENT', time: 100 }] })
|
||||
const owner = await window.maskBoundaryPlugin.load()
|
||||
return { sameOwner: owner === window.maskBoundaryOwner, ids: owner.queue.map(item => item.id) }
|
||||
})
|
||||
expect(replacement).toEqual({ sameOwner: true, ids: ['CONFIGURED-REPLACEMENT'] })
|
||||
await assertLayer(page, 'after-no-argument-load')
|
||||
await renderRow(page, 'MASK-BOUNDARY-AFTER-LOAD')
|
||||
await assertLayer(page, 'after-replacement-native-emit')
|
||||
|
||||
await page.evaluate(() => window.maskBoundaryPlugin.hide())
|
||||
expect((await assertLayer(page, 'after-hide')).opacity).toBe('0')
|
||||
await page.evaluate(() => window.maskBoundaryPlugin.show())
|
||||
expect((await assertLayer(page, 'after-show')).opacity).toBe('1')
|
||||
|
||||
const beforeDestroy = await page.evaluate(() => ({ workers: window.maskBoundaryEvidence.workers, errors: window.maskBoundaryEvidence.errors }))
|
||||
expect(beforeDestroy.errors).toEqual([])
|
||||
expect(beforeDestroy.workers).toHaveLength(1)
|
||||
expect(beforeDestroy.workers[0].requests.length).toBeGreaterThanOrEqual(2)
|
||||
expect(beforeDestroy.workers[0].replies.length).toBeGreaterThanOrEqual(2)
|
||||
await page.evaluate(() => window.art.destroy(false))
|
||||
const destroyed = await assertLayer(page, 'after-destroy-false')
|
||||
expect(destroyed.children).toBe(0)
|
||||
// Use the native frame clock to detect immediate late plugin writes after cleanup.
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
|
||||
const settled = await assertLayer(page, 'after-destroy-native-frames')
|
||||
expect(settled.children).toBe(0)
|
||||
const terminal = await page.evaluate(() => ({ workers: window.maskBoundaryEvidence.workers, errors: window.maskBoundaryEvidence.errors }))
|
||||
expect(terminal.errors).toEqual([])
|
||||
expect(terminal.workers[0].terminated).toBe(1)
|
||||
expect(terminal.workers[0].requests).toEqual(beforeDestroy.workers[0].requests)
|
||||
expect(external).toEqual([])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
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 { compilePackage } from '../helpers/load.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementations = new Map()
|
||||
test.beforeAll(async () => {
|
||||
const release = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/danmuku-release.json', import.meta.url), 'utf8')).release
|
||||
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())
|
||||
implementations.set('candidate', process.env.ARTPLAYER_DANMUKU_ARTIFACT
|
||||
? fs.readFileSync(process.env.ARTPLAYER_DANMUKU_ARTIFACT, 'utf8')
|
||||
: await compilePackage('artplayer-plugin-danmuku', 'umd'))
|
||||
})
|
||||
|
||||
for (const implementation of ['published', 'candidate']) {
|
||||
for (const rate of [2, 20]) {
|
||||
test(`${implementation}: ${rate} rows per second across three native playback cycles`, async ({ page }, testInfo) => {
|
||||
// Three 14-second media cycles plus setup/teardown; this is a bounded soak,
|
||||
// not the ordinary short browser test deadline or a synthetic clock jump.
|
||||
test.setTimeout(90000)
|
||||
await page.goto('/test/player.html?core=candidate')
|
||||
const code = implementations.get(implementation)
|
||||
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(() => {
|
||||
const art = window.art
|
||||
const evidence = window.stabilityEvidence = { workers: [], frames: [], samples: [], visible: [], recycled: [], callbacks: [], cycles: [], errors: [], allocations: 0, reuses: 0, bypassedPool: 0 }
|
||||
const NativeWorker = window.Worker
|
||||
window.Worker = class extends NativeWorker {
|
||||
constructor(...args) {
|
||||
super(...args)
|
||||
this.record = { posts: 0, replies: 0, terminated: 0, errors: [] }
|
||||
evidence.workers.push(this.record)
|
||||
this.addEventListener('message', () => this.record.replies++)
|
||||
this.addEventListener('error', event => this.record.errors.push(event.message))
|
||||
}
|
||||
|
||||
postMessage(...args) {
|
||||
this.record.posts++
|
||||
return super.postMessage(...args)
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.record.terminated++
|
||||
return super.terminate()
|
||||
}
|
||||
}
|
||||
art.on('artplayerPluginDanmuku:error', error => evidence.errors.push(String(error)))
|
||||
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,
|
||||
speed: 1,
|
||||
antiOverlap: false,
|
||||
beforeVisible(row) {
|
||||
evidence.callbacks.push({ id: row.id, time: art.currentTime })
|
||||
return true
|
||||
},
|
||||
}))
|
||||
const owner = window.stabilityOwner = art.plugins.artplayerPluginDanmuku.config({})
|
||||
const acquire = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(owner), '$ref').get
|
||||
const acquired = new WeakSet()
|
||||
Object.defineProperty(owner, '$ref', { configurable: true, get() {
|
||||
const available = new Set(this.$refs)
|
||||
const ref = acquire.call(this)
|
||||
if (available.size && !available.has(ref))
|
||||
evidence.bypassedPool++
|
||||
if (acquired.has(ref))
|
||||
evidence.reuses++
|
||||
else evidence.allocations++
|
||||
acquired.add(ref)
|
||||
return ref
|
||||
} })
|
||||
const getter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(owner), 'readys').get
|
||||
Object.defineProperty(owner, 'readys', { configurable: true, get() {
|
||||
const rows = getter.call(this)
|
||||
evidence.samples.push({ time: art.currentTime, wall: performance.now(), ids: rows.map(row => row.id) })
|
||||
return rows
|
||||
} })
|
||||
const makeWait = owner.makeWait.bind(owner)
|
||||
owner.makeWait = (row) => {
|
||||
if (row.$state === 'emit')
|
||||
evidence.recycled.push({ id: row.id, time: art.currentTime })
|
||||
return makeWait(row)
|
||||
}
|
||||
const frame = (wall) => {
|
||||
if (art.playing)
|
||||
evidence.frames.push({ wall, time: art.currentTime })
|
||||
window.stabilityFrame = requestAnimationFrame(frame)
|
||||
}
|
||||
window.stabilityFrame = requestAnimationFrame(frame)
|
||||
})
|
||||
try {
|
||||
for (let cycle = 0; cycle < 3; cycle++) {
|
||||
await page.evaluate(async ({ cycle, rate }) => {
|
||||
const art = window.art
|
||||
const base = art.currentTime
|
||||
const rows = Array.from({ length: 12 * rate }, (_, index) => ({ id: `${cycle}-${index}`, text: `Row ${index}`, time: base + 0.5 + index / rate, mode: index % 3 }))
|
||||
await art.plugins.artplayerPluginDanmuku.load(rows)
|
||||
window.stabilityEnd = base + 14
|
||||
window.stabilityEvidence.cycles.push({ cycle, base, rows: rows.map(row => ({ id: row.id, time: row.time })), startWall: performance.now() })
|
||||
}, { cycle, rate })
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime >= window.stabilityEnd), { timeout: 22000 }).toBe(true)
|
||||
await page.click('#pause')
|
||||
const snapshot = await page.evaluate(() => {
|
||||
const owner = window.stabilityOwner
|
||||
const record = window.stabilityEvidence.cycles.at(-1)
|
||||
const ids = new Set(record.rows.map(row => row.id))
|
||||
const visible = window.stabilityEvidence.visible.filter(row => ids.has(row.id))
|
||||
const sampled = new Set(window.stabilityEvidence.samples.flatMap(sample => sample.ids).filter(id => ids.has(id)))
|
||||
const shown = new Set(visible.map(row => row.id))
|
||||
const refs = owner.queue.map(row => row.$ref).filter(Boolean)
|
||||
const queue = new Set(owner.queue)
|
||||
const members = Object.values(owner.states).flat()
|
||||
const memory = performance.memory
|
||||
Object.assign(record, {
|
||||
endWall: performance.now(),
|
||||
time: window.art.currentTime,
|
||||
decodedWidth: window.art.video.videoWidth,
|
||||
visibleCount: visible.length,
|
||||
uniqueVisible: shown.size,
|
||||
missedSampling: record.rows.filter(row => !sampled.has(row.id)).map(row => row.id),
|
||||
sampledNotVisible: record.rows.filter(row => sampled.has(row.id) && !shown.has(row.id)).map(row => row.id),
|
||||
queue: owner.queue.length,
|
||||
states: Object.fromEntries(Object.entries(owner.states).map(([state, rows]) => [state, rows.length])),
|
||||
nodes: owner.$danmuku.children.length,
|
||||
pooled: owner.$refs.length,
|
||||
uniquePool: new Set(owner.$refs).size,
|
||||
referenced: refs.length,
|
||||
distinctReferences: new Set(refs).size,
|
||||
uniqueStateMembers: new Set(members).size,
|
||||
validStateMembers: Object.entries(owner.states).every(([state, rows]) => rows.every(row => queue.has(row) && row.$state === state)),
|
||||
heap: memory ? { used: memory.usedJSHeapSize, total: memory.totalJSHeapSize } : null,
|
||||
})
|
||||
return record
|
||||
})
|
||||
expect(snapshot.decodedWidth).toBeGreaterThan(0)
|
||||
expect(snapshot.time - snapshot.base).toBeGreaterThanOrEqual(14)
|
||||
// load(rows) appends by public contract; old rows stay in the queue.
|
||||
expect(snapshot.queue).toBe((cycle + 1) * 12 * rate)
|
||||
expect(Object.values(snapshot.states).reduce((sum, count) => sum + count, 0)).toBe(snapshot.queue)
|
||||
if (implementation === 'candidate') {
|
||||
expect(snapshot.missedSampling).toEqual([])
|
||||
expect(snapshot.sampledNotVisible).toEqual([])
|
||||
expect(snapshot.visibleCount).toBe(12 * rate)
|
||||
expect(snapshot.uniqueVisible).toBe(12 * rate)
|
||||
expect(snapshot.referenced).toBe(0)
|
||||
expect(snapshot.uniquePool).toBe(snapshot.pooled)
|
||||
expect(snapshot.nodes).toBe(snapshot.pooled)
|
||||
expect(snapshot.uniqueStateMembers).toBe(snapshot.queue)
|
||||
expect(snapshot.validStateMembers).toBe(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
const evidence = await page.evaluate(() => {
|
||||
cancelAnimationFrame(window.stabilityFrame)
|
||||
window.art.destroy()
|
||||
return { ...window.stabilityEvidence, destroyed: window.art.isDestroy, retainedPlayerRoots: document.querySelectorAll('.art-video-player').length }
|
||||
})
|
||||
await testInfo.attach('danmuku-stability-observation', { contentType: 'application/json', body: JSON.stringify({ implementation, rate, sha256: hash(code), evidence, scope: 'Three bounded real media cycles; published defects are observed, candidate delivery and resource ownership are asserted. Optional unforced heap readings are not leak proof or comparable across engines.' }) })
|
||||
expect(evidence.destroyed).toBe(true)
|
||||
expect(evidence.retainedPlayerRoots).toBe(0)
|
||||
expect(evidence.errors).toEqual([])
|
||||
expect(evidence.workers).toHaveLength(1)
|
||||
if (implementation === 'candidate' && evidence.cycles.length === 3) {
|
||||
const expected = evidence.cycles.flatMap(cycle => cycle.rows.map(row => row.id)).sort()
|
||||
expect(evidence.visible.map(row => row.id).sort()).toEqual(expected)
|
||||
expect(evidence.recycled.map(row => row.id).sort()).toEqual(expected)
|
||||
expect(evidence.bypassedPool).toBe(0)
|
||||
expect(evidence.reuses).toBeGreaterThan(0)
|
||||
expect(evidence.allocations + evidence.reuses).toBe(expected.length)
|
||||
expect(evidence.cycles.at(-1).nodes).toBe(evidence.allocations)
|
||||
}
|
||||
for (const worker of evidence.workers) {
|
||||
expect(worker.terminated).toBe(1)
|
||||
expect(worker.errors).toEqual([])
|
||||
if (implementation === 'candidate')
|
||||
expect(worker.posts).toBe(worker.replies)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user