test(danmuku): [PKG-DANMUKU-08] cover old plugin combinations and expose model load failures

This commit is contained in:
Harvey Zhao committed 2026-09-14 21:32:10 +08:00
1 parent 30363a5346
commit 475d38043e
13 files changed
+11199 -282

No files matched your search

+8
View File
@@ -250,6 +250,14 @@ with a missed narrow timestamp window. A passing post-ready combination is not
proof of startup losslessness, private GPU/WASM closure, OS fullscreen or devices.
See refactor/changes/2026-09-14-PKG-MASK-05-native-checkpoint.md for exact limits.
Its combination cases now include frozen/current Danmuku and a bounded 120-row
load while the actual model continues. `danmuku-combination-load.js` observes
real media/RAF and cleans up its test listeners; it does not seed eligible rows.
Candidate completeness failures remain failing under PKG-DANMUKU-MASK-LOAD-01.
`danmuku-dpip.spec.js` also includes two-window load cases and both plugin versions.
Frozen-plugin retained DOM, candidate cleanup and unsupported API outcomes remain
distinct; the old plugin's observed loss is not complete-delivery acceptance.
`jassub-native.spec.js` runs actual published JASSUB 1.1.0, its native Worker,
fixed local WASM/font and authored ASS cues with offscreenRender=false. It checks
real canvas pixels before/after seek, CSS web fullscreen and normal destruction.
+167 -139
View File
@@ -1,161 +1,189 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { observeDanmukuLoad } from '../helpers/danmuku-combination-load.js'
import { dpipCandidate } from '../helpers/dpip.js'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
const dpip = await dpipCandidate()
let danmuku
const implementations = new Map()
test.beforeAll(async () => {
danmuku = process.env.ARTPLAYER_DANMUKU_ARTIFACT
const release = JSON.parse(fs.readFileSync('refactor/baselines/danmuku-release.json', 'utf8')).release
const member = 'package/dist/artplayer-plugin-danmuku.js'
const published = readMember(await ensureArchive(release), member)
assert.equal(hash(published), release.files[member])
implementations.set('published', { input: `npm ${release.version}`, code: published.toString() })
const candidate = process.env.ARTPLAYER_DANMUKU_ARTIFACT
? fs.readFileSync(process.env.ARTPLAYER_DANMUKU_ARTIFACT, 'utf8')
: await compilePackage('artplayer-plugin-danmuku', 'umd')
implementations.set('candidate', { input: process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'source UMD', code: candidate })
})
for (const core of ['published', 'candidate']) {
for (const ending of ['close', 'native-close', 'destroy']) {
test(`${core}: Danmuku, heatmap and Worker survive native Document PiP ${ending}`, async ({ page, context }, testInfo) => {
await page.goto(`/test/player.html?core=${core}`)
const supported = await page.evaluate(() => typeof window.documentPictureInPicture?.requestWindow === 'function')
const evidence = { core, ending, supported, danmuku: { input: process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'source UMD', sha256: hash(danmuku) }, dpip: { input: dpip.name, sha256: hash(dpip.code) }, outcome: 'incomplete', rounds: [] }
if (!supported) {
evidence.outcome = 'native-document-pip-unavailable'
await testInfo.attach('danmuku-dpip', { contentType: 'application/json', body: JSON.stringify(evidence) })
return
}
await page.evaluate(() => {
window.combinationWorkers = []
const NativeWorker = window.Worker
window.Worker = class extends NativeWorker {
constructor(...args) {
super(...args)
this.record = { terminated: 0, replies: 0 }
window.combinationWorkers.push(this.record)
this.addEventListener('message', () => this.record.replies++)
}
terminate() {
this.record.terminated++
return super.terminate()
}
for (const implementation of ['published', 'candidate']) {
for (const ending of ['close', 'native-close', 'destroy', 'load']) {
test(`${core}: ${implementation} Danmuku, heatmap and Worker survive native Document PiP ${ending}`, async ({ page, context }, testInfo) => {
if (ending === 'load')
test.setTimeout(45000) // Two native PiP loads add 16 seconds of actual media progression.
const input = implementations.get(implementation)
const danmuku = input.code
await page.goto(`/test/player.html?core=${core}`)
const supported = await page.evaluate(() => typeof window.documentPictureInPicture?.requestWindow === 'function')
const evidence = { core, implementation, ending, supported, danmuku: { input: input.input, sha256: hash(danmuku) }, dpip: { input: dpip.name, sha256: hash(dpip.code) }, outcome: 'incomplete', rounds: [], loads: [] }
if (!supported) {
evidence.outcome = 'native-document-pip-unavailable'
await testInfo.attach('danmuku-dpip', { contentType: 'application/json', body: JSON.stringify(evidence) })
return
}
})
await page.addScriptTag({ content: danmuku })
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${dpip.code}; window.dpipFactory = module.exports.default || module.exports; })();` })
await page.evaluate(() => {
window.combinationVisible = []
window.combinationErrors = []
window.combinationEvents = []
window.art = new window.Artplayer({
container: '.player',
url: '/assets/sample/video.mp4',
muted: true,
plugins: [window.artplayerPluginDanmuku({ danmuku: [], heatmap: true, emitter: true, speed: 4 }), window.dpipFactory({ fallbackToVideoPiP: false })],
})
window.danmuku = window.art.plugins.artplayerPluginDanmuku
window.danmukuOwner = window.danmuku.config({})
window.pip = window.art.plugins.artplayerPluginDocumentPip
window.combinationPlayer = window.art.template.$player
window.combinationLayer = window.art.template.$danmuku
window.combinationParent = window.combinationPlayer.parentNode
window.art.on('document-pip', value => window.combinationEvents.push(value))
window.art.on('artplayerPluginDanmuku:error', error => window.combinationErrors.push(String(error)))
window.art.on('artplayerPluginDanmuku:visible', row => window.combinationVisible.push({ text: row.text, ref: row.$ref, time: window.art.currentTime }))
document.querySelector('#play').onclick = () => window.art.play()
const open = document.createElement('button')
open.id = 'danmuku-open-pip'
open.textContent = 'Open Danmuku PiP'
open.onclick = () => {
window.openingDanmukuPip = window.pip.open()
}
document.body.append(open)
})
try {
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
for (let round = 0; round < 2; round++) {
const popupPromise = context.waitForEvent('page', { timeout: 8000 })
await page.click('#danmuku-open-pip')
const popup = await popupPromise
await page.evaluate(() => window.openingDanmukuPip)
await expect(popup.locator('video.art-video')).toBeVisible()
await expect(popup.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
const text = `PiP row ${round}`
await page.evaluate(async (text) => {
await window.danmuku.load([{ text, time: window.art.currentTime + 0.5, mode: 1 }])
}, text)
await expect.poll(() => page.evaluate(text => window.combinationVisible.some(row => row.text === text), text)).toBe(true)
await expect(popup.getByText(text, { exact: true })).toBeVisible()
const snapshot = await page.evaluate((text) => {
const row = window.combinationVisible.find(row => row.text === text)
const rect = row.ref.getBoundingClientRect()
const popup = window.documentPictureInPicture.window
return {
time: window.art.currentTime,
rowTime: row.time,
width: rect.width,
height: rect.height,
adopted: row.ref.ownerDocument === popup.document && window.combinationLayer.ownerDocument === popup.document,
inLayer: window.combinationLayer.contains(row.ref),
position: popup.getComputedStyle(row.ref).position,
player: window.combinationPlayer.ownerDocument === popup.document,
heatmap: window.art.controls.heatmap.ownerDocument === popup.document && !!window.art.controls.heatmap.querySelector('svg'),
workers: window.combinationWorkers.map(worker => ({ ...worker })),
await page.evaluate(() => {
window.combinationWorkers = []
const NativeWorker = window.Worker
window.Worker = class extends NativeWorker {
constructor(...args) {
super(...args)
this.record = { terminated: 0, replies: 0 }
window.combinationWorkers.push(this.record)
this.addEventListener('message', () => this.record.replies++)
}
}, text)
evidence.rounds.push(snapshot)
expect(snapshot.adopted && snapshot.inLayer && snapshot.player && snapshot.heatmap).toBe(true)
expect(snapshot.width).toBeGreaterThan(0)
expect(snapshot.height).toBeGreaterThan(0)
expect(snapshot.position).toBe('absolute')
expect(snapshot.workers).toHaveLength(1)
expect(snapshot.workers[0].replies).toBeGreaterThan(0)
if (round === 0)
await testInfo.attach('native-danmuku-page', { contentType: 'image/png', body: await popup.screenshot() })
if (round === 1 && ending === 'destroy') {
terminate() {
this.record.terminated++
return super.terminate()
}
}
})
await page.addScriptTag({ content: danmuku })
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${dpip.code}; window.dpipFactory = module.exports.default || module.exports; })();` })
await page.evaluate(() => {
window.combinationVisible = []
window.combinationErrors = []
window.combinationEvents = []
window.art = new window.Artplayer({
container: '.player',
url: '/assets/sample/video.mp4',
muted: true,
plugins: [window.artplayerPluginDanmuku({ danmuku: [], heatmap: true, emitter: true, speed: 4 }), window.dpipFactory({ fallbackToVideoPiP: false })],
})
window.danmuku = window.art.plugins.artplayerPluginDanmuku
window.danmukuOwner = window.danmuku.config({})
window.pip = window.art.plugins.artplayerPluginDocumentPip
window.combinationPlayer = window.art.template.$player
window.combinationLayer = window.art.template.$danmuku
window.combinationParent = window.combinationPlayer.parentNode
window.art.on('document-pip', value => window.combinationEvents.push(value))
window.art.on('artplayerPluginDanmuku:error', error => window.combinationErrors.push(String(error)))
window.art.on('artplayerPluginDanmuku:visible', row => window.combinationVisible.push({ text: row.text, ref: row.$ref, time: window.art.currentTime }))
document.querySelector('#play').onclick = () => window.art.play()
const open = document.createElement('button')
open.id = 'danmuku-open-pip'
open.textContent = 'Open Danmuku PiP'
open.onclick = () => {
window.openingDanmukuPip = window.pip.open()
}
document.body.append(open)
})
try {
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
for (let round = 0; round < 2; round++) {
const popupPromise = context.waitForEvent('page', { timeout: 8000 })
await page.click('#danmuku-open-pip')
const popup = await popupPromise
await page.evaluate(() => window.openingDanmukuPip)
await expect(popup.locator('video.art-video')).toBeVisible()
await expect(popup.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
const text = `PiP row ${round}`
await page.evaluate(async (text) => {
await window.danmuku.load([{ text, time: window.art.currentTime + 0.5, mode: 1 }])
}, text)
await expect.poll(() => page.evaluate(text => window.combinationVisible.some(row => row.text === text), text)).toBe(true)
await expect(popup.getByText(text, { exact: true }).and(popup.locator(':visible'))).toBeVisible()
const snapshot = await page.evaluate((text) => {
const row = window.combinationVisible.find(row => row.text === text)
const rect = row.ref.getBoundingClientRect()
const popup = window.documentPictureInPicture.window
return {
time: window.art.currentTime,
rowTime: row.time,
width: rect.width,
height: rect.height,
adopted: row.ref.ownerDocument === popup.document && window.combinationLayer.ownerDocument === popup.document,
inLayer: window.combinationLayer.contains(row.ref),
position: popup.getComputedStyle(row.ref).position,
player: window.combinationPlayer.ownerDocument === popup.document,
heatmap: window.art.controls.heatmap.ownerDocument === popup.document && !!window.art.controls.heatmap.querySelector('svg'),
workers: window.combinationWorkers.map(worker => ({ ...worker })),
}
}, text)
evidence.rounds.push(snapshot)
expect(snapshot.adopted && snapshot.inLayer && snapshot.player && snapshot.heatmap).toBe(true)
expect(snapshot.width).toBeGreaterThan(0)
expect(snapshot.height).toBeGreaterThan(0)
expect(snapshot.position).toBe('absolute')
expect(snapshot.workers).toHaveLength(1)
expect(snapshot.workers[0].replies).toBeGreaterThan(0)
if (round === 0)
await testInfo.attach('native-danmuku-page', { contentType: 'image/png', body: await popup.screenshot() })
if (ending === 'load')
evidence.loads.push(await observeDanmukuLoad(page, { label: `PiP ${round}`, requireComplete: implementation === 'candidate' }))
evidence.beforeDestroy = await page.evaluate(() => ({ nodes: window.combinationLayer.children.length }))
if (round === 1 && ending === 'destroy') {
await page.evaluate(() => window.art.destroy(false))
}
else {
await page.evaluate(ending => ending === 'native-close' ? window.documentPictureInPicture.window.close() : window.pip.close(), ending)
await expect.poll(() => page.evaluate(() => window.pip.isActive)).toBe(false)
expect(await page.evaluate(() => window.combinationPlayer.parentNode === window.combinationParent && window.combinationLayer.ownerDocument === document)).toBe(true)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(snapshot.time + 0.1)
}
}
if (ending !== 'destroy')
await page.evaluate(() => window.art.destroy(false))
await expect.poll(() => page.evaluate(() => !window.documentPictureInPicture.window || window.documentPictureInPicture.window.closed)).toBe(true)
evidence.final = await page.evaluate(() => ({
active: window.pip.isActive,
workers: window.combinationWorkers,
errors: window.combinationErrors,
events: window.combinationEvents,
frame: window.danmukuOwner.scheduler?.frame,
operation: window.danmukuOwner.scheduler?.operation,
nodes: window.combinationLayer.children.length,
settings: document.querySelectorAll('.artplayer-plugin-danmuku').length,
placeholders: document.querySelectorAll('.artplayer-document-pip-placeholder').length,
}))
expect(evidence.final.active).toBe(false)
expect(evidence.final.workers).toHaveLength(1)
expect(evidence.final.workers[0].terminated).toBe(1)
expect(evidence.final.errors).toEqual([])
// Destroy suppresses further public events, as in the existing native PiP contract.
expect(evidence.final.events).toEqual(ending === 'destroy' ? [true, false, true] : [true, false, true, false])
if (implementation === 'candidate') {
expect(evidence.final.frame).toBeNull()
expect(evidence.final.operation).toBeNull()
expect(evidence.final.nodes + evidence.final.settings + evidence.final.placeholders).toBe(0)
evidence.outcome = 'native-playback-danmuku-and-cleanup-passed'
}
else {
await page.evaluate(ending => ending === 'native-close' ? window.documentPictureInPicture.window.close() : window.pip.close(), ending)
await expect.poll(() => page.evaluate(() => window.pip.isActive)).toBe(false)
expect(await page.evaluate(() => window.combinationPlayer.parentNode === window.combinationParent && window.combinationLayer.ownerDocument === document)).toBe(true)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(snapshot.time + 0.1)
// Frozen npm 5.3.0 destroy(false) retains its renderer and Setting nodes.
expect(evidence.final.nodes).toBe(ending === 'load' ? evidence.beforeDestroy.nodes : 3)
expect(evidence.final.nodes).toBeGreaterThan(0)
expect(evidence.final.settings).toBe(1)
expect(evidence.final.placeholders).toBe(0)
evidence.outcome = 'native-playback-with-published-retained-dom'
}
}
if (ending !== 'destroy')
await page.evaluate(() => window.art.destroy(false))
await expect.poll(() => page.evaluate(() => !window.documentPictureInPicture.window || window.documentPictureInPicture.window.closed)).toBe(true)
evidence.final = await page.evaluate(() => ({
active: window.pip.isActive,
workers: window.combinationWorkers,
errors: window.combinationErrors,
events: window.combinationEvents,
frame: window.danmukuOwner.scheduler.frame,
operation: window.danmukuOwner.scheduler.operation,
nodes: window.combinationLayer.children.length,
settings: document.querySelectorAll('.artplayer-plugin-danmuku').length,
placeholders: document.querySelectorAll('.artplayer-document-pip-placeholder').length,
}))
expect(evidence.final.active).toBe(false)
expect(evidence.final.workers).toHaveLength(1)
expect(evidence.final.workers[0].terminated).toBe(1)
expect(evidence.final.errors).toEqual([])
// Destroy suppresses further public events, as in the existing native PiP contract.
expect(evidence.final.events).toEqual(ending === 'destroy' ? [true, false, true] : [true, false, true, false])
expect(evidence.final.frame).toBeNull()
expect(evidence.final.operation).toBeNull()
expect(evidence.final.nodes + evidence.final.settings + evidence.final.placeholders).toBe(0)
evidence.outcome = 'native-playback-danmuku-and-cleanup-passed'
}
finally {
await testInfo.attach('danmuku-dpip', { contentType: 'application/json', body: JSON.stringify(evidence) })
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy(false)
})
}
})
finally {
evidence.incompleteLoad = await page.evaluate(() => window.danmukuCombinationLoadResult)
await testInfo.attach('danmuku-dpip', { contentType: 'application/json', body: JSON.stringify(evidence) })
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy(false)
})
}
})
}
}
}
+147 -131
View File
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { observeDanmukuLoad } from '../helpers/danmuku-combination-load.js'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
@@ -20,11 +21,18 @@ const assets = baseline.sdk.assets.map(({ file, sha256 }) => {
const media = 'docs/assets/sample/steve-jobs.mp4'
const danmukuFile = process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'source UMD'
let danmukuCode
const danmukuImplementations = new Map()
test.beforeAll(async () => {
;[code, danmukuCode] = await Promise.all([
process.env.ARTPLAYER_MASK_ARTIFACT ? fs.readFileSync(artifact, 'utf8') : compilePackage('artplayer-plugin-danmuku-mask', 'umd'),
process.env.ARTPLAYER_DANMUKU_ARTIFACT ? fs.readFileSync(danmukuFile, 'utf8') : compilePackage('artplayer-plugin-danmuku', 'umd'),
])
const release = JSON.parse(fs.readFileSync('refactor/baselines/danmuku-release.json', 'utf8')).release
const member = 'package/dist/artplayer-plugin-danmuku.js'
const published = readMember(await ensureArchive(release), member)
assert.equal(hash(published), release.files[member])
danmukuImplementations.set('published', { file: `npm ${release.version}`, code: published.toString() })
danmukuImplementations.set('candidate', { file: danmukuFile, code: danmukuCode })
})
for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
@@ -132,138 +140,146 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
}
})
test(`Mask actual model and Danmuku share the layer through pause, seek and web fullscreen with ${core} core`, async ({ page }, testInfo) => {
test.setTimeout(90000)
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: danmukuCode })
await page.addScriptTag({ content: code })
const unexpected = []
const origin = new URL(testInfo.project.use.baseURL).origin
await page.route('**/*', (route) => {
if (new URL(route.request().url()).origin !== origin) {
unexpected.push(route.request().url())
return route.abort()
}
return route.continue()
})
await page.evaluate(() => {
window.maskCombination = { times: [], visible: [], snapshots: [], samples: [] }
const encode = HTMLCanvasElement.prototype.toDataURL
HTMLCanvasElement.prototype.toDataURL = function (...args) {
const result = encode.apply(this, args)
window.maskCombination.times.push(window.art?.currentTime)
return result
}
window.art = new window.Artplayer({
container: '.player',
url: '/assets/sample/steve-jobs.mp4',
muted: true,
fullscreenWeb: true,
plugins: [window.artplayerPluginDanmuku({ danmuku: [{ text: 'Native mask combination', time: 6, mode: 1 }], heatmap: false }), window.artplayerPluginDanmukuMask({ solutionPath: '/assets/@mediapipe/selfie_segmentation' })],
for (const implementation of ['published', 'candidate']) {
test(`Mask actual model and Danmuku share the layer through pause, seek and web fullscreen with ${core} core / ${implementation} plugin`, async ({ page }, testInfo) => {
const danmuku = danmukuImplementations.get(implementation)
test.setTimeout(90000)
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: danmuku.code })
await page.addScriptTag({ content: code })
const unexpected = []
let load
const origin = new URL(testInfo.project.use.baseURL).origin
await page.route('**/*', (route) => {
if (new URL(route.request().url()).origin !== origin) {
unexpected.push(route.request().url())
return route.abort()
}
return route.continue()
})
window.maskCombination.layer = window.art.template.$danmuku
const owner = window.art.plugins.artplayerPluginDanmuku.config({})
const ready = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(owner), 'readys').get
Object.defineProperty(owner, 'readys', { configurable: true, get() {
const rows = ready.call(this)
window.maskCombination.samples.push({ time: window.art.currentTime, wall: performance.now(), rows: rows.map(row => ({ time: row.time, text: row.text })) })
return rows
} })
window.art.on('artplayerPluginDanmuku:visible', (row) => {
const node = row.$ref
const rect = node?.getBoundingClientRect()
window.maskCombination.visible.push({ text: row.text, connected: node?.isConnected, inLayer: node ? window.maskCombination.layer.contains(node) : false, width: rect?.width, height: rect?.height, display: node ? getComputedStyle(node).display : null })
await page.evaluate(() => {
window.maskCombination = { times: [], visible: [], snapshots: [], samples: [] }
const encode = HTMLCanvasElement.prototype.toDataURL
HTMLCanvasElement.prototype.toDataURL = function (...args) {
const result = encode.apply(this, args)
window.maskCombination.times.push(window.art?.currentTime)
return result
}
window.art = new window.Artplayer({
container: '.player',
url: '/assets/sample/steve-jobs.mp4',
muted: true,
fullscreenWeb: true,
plugins: [window.artplayerPluginDanmuku({ danmuku: [{ text: 'Native mask combination', time: 6, mode: 1 }], heatmap: false }), window.artplayerPluginDanmukuMask({ solutionPath: '/assets/@mediapipe/selfie_segmentation' })],
})
window.maskCombination.layer = window.art.template.$danmuku
const owner = window.art.plugins.artplayerPluginDanmuku.config({})
const ready = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(owner), 'readys').get
Object.defineProperty(owner, 'readys', { configurable: true, get() {
const rows = ready.call(this)
window.maskCombination.samples.push({ time: window.art.currentTime, wall: performance.now(), rows: rows.map(row => ({ time: row.time, text: row.text })) })
return rows
} })
window.art.on('artplayerPluginDanmuku:visible', (row) => {
const node = row.$ref
const rect = node?.getBoundingClientRect()
window.maskCombination.visible.push({ text: row.text, connected: node?.isConnected, inLayer: node ? window.maskCombination.layer.contains(node) : false, width: rect?.width, height: rect?.height, display: node ? getComputedStyle(node).display : null })
})
document.querySelector('#play').onclick = () => window.art.play()
document.querySelector('#pause').onclick = () => window.art.pause()
window.maskSnapshot = (phase) => {
const layer = window.art.template.$danmuku
const rect = layer.getBoundingClientRect()
const snapshot = { phase, time: window.art.currentTime, outputs: window.maskCombination.times.length, sameLayer: layer === window.maskCombination.layer, mask: layer.style.maskImage.startsWith('url('), mode: getComputedStyle(layer).maskMode, width: rect.width, height: rect.height, fullscreenWeb: window.art.fullscreenWeb }
window.maskCombination.snapshots.push(snapshot)
return snapshot
}
})
document.querySelector('#play').onclick = () => window.art.play()
document.querySelector('#pause').onclick = () => window.art.pause()
window.maskSnapshot = (phase) => {
const layer = window.art.template.$danmuku
const rect = layer.getBoundingClientRect()
const snapshot = { phase, time: window.art.currentTime, outputs: window.maskCombination.times.length, sameLayer: layer === window.maskCombination.layer, mask: layer.style.maskImage.startsWith('url('), mode: getComputedStyle(layer).maskMode, width: rect.width, height: rect.height, fullscreenWeb: window.art.fullscreenWeb }
window.maskCombination.snapshots.push(snapshot)
return snapshot
try {
await expect.poll(() => page.evaluate(() => window.art.duration)).toBeGreaterThan(20)
await page.evaluate(() => {
window.art.currentTime = 5
})
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.maskCombination.times.length), { timeout: 30000 }).toBeGreaterThan(2)
// Keep the startup row and sampling history as diagnostics: native model
// initialization may block its narrow existing timestamp window. Exercise
// the ongoing combination after the real model has become operational.
await page.evaluate(async () => {
const time = window.art.currentTime + 1
window.maskCombination.scheduled = time
await window.art.plugins.artplayerPluginDanmuku.load([{ text: 'Ready mask combination', time, mode: 1 }])
})
await expect.poll(() => page.evaluate(() => window.maskCombination.visible.map(row => row.text))).toContain('Ready mask combination')
const row = await page.evaluate(() => window.maskCombination.visible.find(row => row.text === 'Ready mask combination'))
expect(row.connected && row.inLayer).toBe(true)
expect(row.width).toBeGreaterThan(0)
expect(row.height).toBeGreaterThan(0)
expect(row.display).not.toBe('none')
const initial = await page.evaluate(() => window.maskSnapshot('playing'))
expect(initial.mask).toBe(true)
expect(initial.sameLayer).toBe(true)
await page.locator('#pause').click()
await expect.poll(() => page.evaluate(() => window.art.video.paused)).toBe(true)
// Wait native frames for the already-running SDK frame to finish. Compare
// its media timestamp, rather than forbidding one legitimate late encode.
const paused = await page.evaluate(async () => {
const time = window.art.currentTime
const beforeOutputs = window.maskCombination.times.length
await new Promise(resolve => setTimeout(resolve, 500))
return { before: time, after: window.art.currentTime, extraOutputs: window.maskCombination.times.length - beforeOutputs, snapshot: window.maskSnapshot('paused') }
})
expect(Math.abs(paused.after - paused.before)).toBeLessThan(0.01)
expect(paused.extraOutputs).toBeLessThanOrEqual(1)
await page.evaluate(async () => {
const seeked = new Promise(resolve => window.art.video.addEventListener('seeked', resolve, { once: true }))
window.art.currentTime = 20
window.art.fullscreenWeb = true
await seeked
window.maskCombination.seeked = { time: window.art.currentTime, outputs: window.maskCombination.times.length }
})
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThanOrEqual(19.9)
const full = await page.evaluate(() => window.maskSnapshot('fullscreen-paused'))
expect(full.sameLayer).toBe(true)
expect(full.fullscreenWeb).toBe(true)
expect(full.width).toBeGreaterThan(initial.width)
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.maskCombination.times.length - window.maskCombination.seeked.outputs), { timeout: 30000 }).toBeGreaterThanOrEqual(2)
expect(await page.evaluate(() => window.maskCombination.times.at(-1))).toBeGreaterThan(20)
await page.evaluate(async () => {
window.art.fullscreenWeb = false
window.art.plugins.artplayerPluginDanmuku.hide()
window.art.plugins.artplayerPluginDanmuku.show()
await window.art.plugins.artplayerPluginDanmuku.load([{ text: 'After seek and visibility', time: window.art.currentTime + 1, mode: 1 }])
})
await expect.poll(() => page.evaluate(() => window.maskCombination.visible.map(row => row.text))).toContain('After seek and visibility')
const afterRow = await page.evaluate(() => window.maskCombination.visible.find(row => row.text === 'After seek and visibility'))
expect(afterRow.connected && afterRow.inLayer).toBe(true)
expect(afterRow.width).toBeGreaterThan(0)
expect(afterRow.height).toBeGreaterThan(0)
expect(afterRow.display).not.toBe('none')
const restored = await page.evaluate(() => window.maskSnapshot('restored'))
expect(restored.sameLayer).toBe(true)
expect(restored.mask).toBe(true)
expect(restored.width).toBe(initial.width)
expect(restored.mode).toBe('alpha')
await testInfo.attach('actual-combination-page', { body: await page.screenshot(), contentType: 'image/png' })
const outputsBeforeLoad = await page.evaluate(() => window.maskCombination.times.length)
load = await observeDanmukuLoad(page, { label: 'Mask load', requireComplete: implementation === 'candidate' })
load.modelOutputs = await page.evaluate(before => window.maskCombination.times.length - before, outputsBeforeLoad)
expect(load.modelOutputs).toBeGreaterThan(1)
await page.evaluate(() => window.art.destroy(false))
expect(await page.evaluate(() => window.maskCombination.layer.style.maskImage)).toBe('none')
expect(unexpected).toEqual([])
}
finally {
const state = await page.evaluate(() => ({ times: window.maskCombination.times, visible: window.maskCombination.visible, snapshots: window.maskCombination.snapshots, scheduled: window.maskCombination.scheduled, samples: window.maskCombination.samples, seeked: window.maskCombination.seeked, incompleteLoad: window.danmukuCombinationLoadResult }))
await testInfo.attach('actual-combination-evidence', { body: JSON.stringify({ core, implementation, mask: { file: artifact, sha256: hash(code) }, danmuku: { file: danmuku.file, sha256: hash(danmuku.code) }, unexpected, state, load, limitation: 'Actual model, native media, timestamp-delivered Danmuku and CSS web fullscreen. Bounded 20 rows/s after model readiness. No OS fullscreen, GPU completion or device coverage claim.' }, null, 2), contentType: 'application/json' })
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy()
})
}
})
try {
await expect.poll(() => page.evaluate(() => window.art.duration)).toBeGreaterThan(20)
await page.evaluate(() => {
window.art.currentTime = 5
})
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.maskCombination.times.length), { timeout: 30000 }).toBeGreaterThan(2)
// Keep the startup row and sampling history as diagnostics: native model
// initialization may block its narrow existing timestamp window. Exercise
// the ongoing combination after the real model has become operational.
await page.evaluate(async () => {
const time = window.art.currentTime + 1
window.maskCombination.scheduled = time
await window.art.plugins.artplayerPluginDanmuku.load([{ text: 'Ready mask combination', time, mode: 1 }])
})
await expect.poll(() => page.evaluate(() => window.maskCombination.visible.map(row => row.text))).toContain('Ready mask combination')
const row = await page.evaluate(() => window.maskCombination.visible.find(row => row.text === 'Ready mask combination'))
expect(row.connected && row.inLayer).toBe(true)
expect(row.width).toBeGreaterThan(0)
expect(row.height).toBeGreaterThan(0)
expect(row.display).not.toBe('none')
const initial = await page.evaluate(() => window.maskSnapshot('playing'))
expect(initial.mask).toBe(true)
expect(initial.sameLayer).toBe(true)
await page.locator('#pause').click()
await expect.poll(() => page.evaluate(() => window.art.video.paused)).toBe(true)
// Wait native frames for the already-running SDK frame to finish. Compare
// its media timestamp, rather than forbidding one legitimate late encode.
const paused = await page.evaluate(async () => {
const time = window.art.currentTime
const beforeOutputs = window.maskCombination.times.length
await new Promise(resolve => setTimeout(resolve, 500))
return { before: time, after: window.art.currentTime, extraOutputs: window.maskCombination.times.length - beforeOutputs, snapshot: window.maskSnapshot('paused') }
})
expect(Math.abs(paused.after - paused.before)).toBeLessThan(0.01)
expect(paused.extraOutputs).toBeLessThanOrEqual(1)
await page.evaluate(async () => {
const seeked = new Promise(resolve => window.art.video.addEventListener('seeked', resolve, { once: true }))
window.art.currentTime = 20
window.art.fullscreenWeb = true
await seeked
window.maskCombination.seeked = { time: window.art.currentTime, outputs: window.maskCombination.times.length }
})
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThanOrEqual(19.9)
const full = await page.evaluate(() => window.maskSnapshot('fullscreen-paused'))
expect(full.sameLayer).toBe(true)
expect(full.fullscreenWeb).toBe(true)
expect(full.width).toBeGreaterThan(initial.width)
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.maskCombination.times.length - window.maskCombination.seeked.outputs), { timeout: 30000 }).toBeGreaterThanOrEqual(2)
expect(await page.evaluate(() => window.maskCombination.times.at(-1))).toBeGreaterThan(20)
await page.evaluate(async () => {
window.art.fullscreenWeb = false
window.art.plugins.artplayerPluginDanmuku.hide()
window.art.plugins.artplayerPluginDanmuku.show()
await window.art.plugins.artplayerPluginDanmuku.load([{ text: 'After seek and visibility', time: window.art.currentTime + 1, mode: 1 }])
})
await expect.poll(() => page.evaluate(() => window.maskCombination.visible.map(row => row.text))).toContain('After seek and visibility')
const afterRow = await page.evaluate(() => window.maskCombination.visible.find(row => row.text === 'After seek and visibility'))
expect(afterRow.connected && afterRow.inLayer).toBe(true)
expect(afterRow.width).toBeGreaterThan(0)
expect(afterRow.height).toBeGreaterThan(0)
expect(afterRow.display).not.toBe('none')
const restored = await page.evaluate(() => window.maskSnapshot('restored'))
expect(restored.sameLayer).toBe(true)
expect(restored.mask).toBe(true)
expect(restored.width).toBe(initial.width)
expect(restored.mode).toBe('alpha')
await testInfo.attach('actual-combination-page', { body: await page.screenshot(), contentType: 'image/png' })
await page.evaluate(() => window.art.destroy(false))
expect(await page.evaluate(() => window.maskCombination.layer.style.maskImage)).toBe('none')
expect(unexpected).toEqual([])
}
finally {
const state = await page.evaluate(() => ({ times: window.maskCombination.times, visible: window.maskCombination.visible, snapshots: window.maskCombination.snapshots, scheduled: window.maskCombination.scheduled, samples: window.maskCombination.samples, seeked: window.maskCombination.seeked }))
await testInfo.attach('actual-combination-evidence', { body: JSON.stringify({ core, mask: { file: artifact, sha256: hash(code) }, danmuku: { file: danmukuFile, sha256: hash(danmukuCode) }, unexpected, state, limitation: 'Actual model, native media, timestamp-delivered Danmuku and CSS web fullscreen. No OS fullscreen, GPU completion or device coverage claim.' }, null, 2), contentType: 'application/json' })
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy()
})
}
})
}
}
+60
View File
@@ -0,0 +1,60 @@
import { expect } from '@playwright/test'
// Observe real media/RAF delivery after a companion plugin has become operational.
export async function observeDanmukuLoad(page, { label, requireComplete, rate = 20, seconds = 6 }) {
let result
try {
await page.evaluate(async ({ label, rate, seconds }) => {
const art = window.art
const plugin = art.plugins.artplayerPluginDanmuku
const owner = plugin.config({ speed: 1, antiOverlap: false })
const host = art.template.$player.ownerDocument.defaultView
const base = art.currentTime
const rows = Array.from({ length: rate * seconds }, (_, index) => ({
id: `${label}-${index}`,
text: `${label} ${index}`,
time: base + 0.5 + index / rate,
mode: index % 3,
}))
const ids = new Set(rows.map(row => row.id))
const state = window.danmukuCombinationLoad = { base, end: base + seconds + 2, rows, visible: [], frames: [], errors: [], startWall: host.performance.now() }
const visible = (row) => {
if (ids.has(row.id))
state.visible.push({ id: row.id, time: art.currentTime, wall: host.performance.now() })
}
const error = error => state.errors.push(String(error))
let frame
const sample = () => {
state.frames.push({ time: art.currentTime, wall: host.performance.now() })
frame = host.requestAnimationFrame(sample)
}
frame = host.requestAnimationFrame(sample)
art.on('artplayerPluginDanmuku:visible', visible)
art.on('artplayerPluginDanmuku:error', error)
window.finishDanmukuCombinationLoad = () => {
host.cancelAnimationFrame(frame)
art.off('artplayerPluginDanmuku:visible', visible)
art.off('artplayerPluginDanmuku:error', error)
const loaded = owner.queue.filter(row => ids.has(row.id))
const result = { ...state, endWall: host.performance.now(), time: art.currentTime, loaded: loaded.length, waiting: loaded.filter(row => row.$state === 'wait').length, referenced: loaded.filter(row => row.$ref).length, nodes: art.template.$danmuku.children.length, pooled: owner.$refs.length, uniquePool: new Set(owner.$refs).size, decodedWidth: art.video.videoWidth }
window.danmukuCombinationLoadResult = result
return result
}
await plugin.load(rows)
}, { label, rate, seconds })
await expect.poll(() => page.evaluate(() => window.art.currentTime >= window.danmukuCombinationLoad.end), { timeout: 16000 }).toBe(true)
}
finally {
result = await page.evaluate(() => window.finishDanmukuCombinationLoad?.())
}
expect(result.decodedWidth).toBeGreaterThan(0)
expect(result.loaded).toBe(rate * seconds)
expect(result.errors).toEqual([])
if (requireComplete) {
expect(result.visible.map(row => row.id).sort()).toEqual(result.rows.map(row => row.id).sort())
expect(result.waiting).toBe(rate * seconds)
expect(result.referenced).toBe(0)
expect(result.uniquePool).toBe(result.pooled)
}
return { ...result, requireComplete, rate, seconds }
}