test(danmuku): [PKG-DANMUKU-08] verify fullscreen lifetimes and old-core loads

This commit is contained in:
Harvey Zhao committed 2026-09-14 21:13:03 +08:00
1 parent b54a890832
commit 30363a5346
10 files changed
+15385 -170

No files matched your search

@@ -12,8 +12,24 @@ the corresponding built formats; both loaded hashes appear in each attachment.
The actual Mask/model combination in `danmuku-mask-native.spec.js` now compiles
both plugins from source by default. `ARTPLAYER_MASK_ARTIFACT` and
`ARTPLAYER_DANMUKU_ARTIFACT` override them independently and fail on missing files.
Do not substitute a stale dist file for source checks. Full old-plugin/core,
OS fullscreen, installation, performance and device acceptance remain task 08/09.
Do not substitute a stale dist file for source checks. Installation, remaining
old-plugin combinations, combined-load performance and devices remain task 08/09.
`danmuku-fullscreen.spec.js` tests all old/current core and plugin pairs in real
document and CSS web fullscreen, with three fresh player lifetimes per case.
Each lifetime must show a timestamp-delivered row, restore layout and release its
Worker and DOM; the candidate also has no pending scheduler frame or operation.
The published renderer has a hidden measurement node with the same text: locate
the visible node, rather than assuming text uniquely identifies a render node.
Wait for the native exit event before destroying the player; fullscreenElement
can clear before the event is dispatched. Instance-local event records expose
the frozen core's known BASE-LIFE-18 listeners after destroy, without attributing
their events to a new player. The candidate must receive no later events.
`danmuku-stability.spec.js` now includes both core versions. Use its core-prefixed
test titles to select a matrix subset and retain the core field in observations.
The three 14-second cycles and per-row assertions remain the same; this does not
turn the bounded load test into a full-screen/model/PiP performance benchmark.
The compatibility reference is the actual npm 5.3.0 package. Its archive,
historical declarations, earlier exports and failure evidence are indexed in
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,79 @@
# PKG-DANMUKU-08 全屏生命周期与旧核心负载检查点
状态:仍 doing。起点 b54a890832ccfec82715129c319befa5d26ef942;本批只补充实际
浏览器验证和维护文档,没有修改生产运行时、声明、依赖或分发文件。
## 覆盖与测试设计
新增 `test/browser/danmuku-fullscreen.spec.js`:冻结 npm 核心 5.4.0/当前源码核心,
冻结 npm 弹幕 5.3.0/当前源码插件,CSS 网页全屏/真实 document 全屏,三个引擎。
每项连续创建三个独立播放器;每个播放器使用真实视频与原生 Worker,按媒体时间
加载可见弹幕,检查全屏图层宽度、设置/热力图单例、退出后的宽度恢复,以及销毁后
Worker 恰好终止一次、DOM 无残留。候选还检查 scheduler.frame/operation 清空。
文件/包哈希和每轮快照写入附件;不能把三个生命周期称作三小时耐久测试。
45 秒单项上限为新增三轮播放/全屏操作预留,没有增大旧用例的超时,也没有重试、
强制点击、伪造媒体事件、替换全屏 API 或跳过某个浏览器。兼容覆盖 API-04/05/07/08。
`danmuku-stability.spec.js` 的原有三轮 14 秒、每秒 2/20 条负载现在增加核心版本轴,
附件增加 core 字段,原候选逐条采样/显示/回收、状态池、节点复用和 Worker 断言不变。
本轮只运行新增旧核心子集;既有候选核心证据仍标明其实际运行日期与源码哈希。
## 首轮失败与修正依据
1. Chromium 首轮 3 通过、5 失败:旧插件保留同名隐藏测量节点,纯文本定位违反
唯一性;改为精确文本与可见节点交集,仍要求实际可见。旧核心销毁后留下的全屏
监听器通过测试的 window 全局数组写入下一实例记录;改为实例局部数组,并保留
每个旧实例后续收到的事件。BASE-LIFE-18 已在 CORE-16 复现和修复;冻结旧核心的
三个实例分别观察三轮/两轮/一轮事件,新核心的每个实例只能收到自己的一轮。
2. 三引擎首轮 20 通过、4 失败:WebKit 原生退出时 fullscreenElement 和布局先恢复,
change 事件稍后投递;测试提前 destroy 抑制了候选的后续通知。现在在销毁之前
使用原有 7 秒 expect 上限等待真实 [true,false] 事件序列,并继续精确断言次数。
这不是在缺少事件时跳过检查,也不是对生产代码增加延时。
两份失败报告、trace 和错误断言均保留。后续报告不覆盖它们,见机器证据中的路径及哈希。
## 已验证结果
固定 Node 24.21.0、Yarn 1.22.22;Windows Chromium 153.0.8010.12、Firefox 155.0、
WebKit 26.6。以下原生全屏均实际进入 document 全屏,未按 API 不可用计作通过。
| 输入与范围 | 实际结果 |
| --- | --- |
| 全屏源码,新旧核心/新旧插件/两个模式/三引擎 | 24 通过,87.07 秒 Yarn 总耗时 |
| 全屏 main,新旧核心/候选插件/两个模式/三引擎 | 12 通过,47.09 秒 |
| 全屏 legacy,同上 | 12 通过,47.13 秒 |
| 旧核心,旧/新插件,每秒 2/20 条,三引擎 | 12 通过,278.37 秒 |
| 两个测试文件的只读 ESLint | 通过 |
源码 72 次、main/legacy 各 36 次播放器生命周期;每个格式重新创建样本,不能
把累计次数当作一个播放器的长期稳定性证明。main/legacy 使用实际工作区分发字节,
不是仓库外安装验证;证据脚本核对加载 SHA 与对应 dist 文件完全一致。
完整快照见 [全屏证据](../baselines/danmuku-fullscreen-validation.json)和
[旧核心负载证据](../baselines/danmuku-old-core-load-validation.json)。本轮候选普通
负载三轮完整显示/回收 72 条,分配 3 节点、复用 69 次;密集负载完整处理 720 条,
分配 21–22 节点、复用 698–699 次。全部 Worker 正常终止,销毁后播放器根节点为零。
对照07的候选核心报告,先核验原报告哈希以及新旧报告加载的插件字节完全一致。
候选插件的 RAF 帧间隔 P95:Chromium 本轮 16.7/16.8ms,原报告 16.7/16.7ms;
Firefox 两档均约16.68ms;WebKit 本轮61/79ms,原报告61/80ms。WebKit在这台
Windows测试环境中本来就没有60fps,旧插件本轮62/79ms;不能把该差异隐去或声称
所有引擎流畅60fps。原始周期级帧间隔也保留,跨周期总体统计明确包含暂停间隙。
这些是不同时间运行的有限样本,没有随机化/同时运行对照,不能宣称因果性能提升。
也未用未强制GC的heap读数证明不存在泄漏。
## 重跑与后续
- 源码:`yarn test:browser:source test/browser/danmuku-fullscreen.spec.js --workers=2`。
- main/legacy:显式设置 `ARTPLAYER_DANMUKU_ARTIFACT`,同一入口加
`--grep 'candidate Danmuku'`。依次运行并在每次前归档前一报告。
- 旧核心负载:`yarn test:browser:source test/browser/danmuku-stability.spec.js
--grep 'published core /' --workers=2`。Playwright 匹配含项目和文件名的完整标题,
不使用 `^published core`;本轮第一次错误选择导致的 No tests found 已独立保留,
不能计作任何测试通过。
08 的完整旧插件组合与 Mask/PiP 组合负载仍待继续;09 承担最终仓库外安装、示例、
分发与声明收口。设备和远端 CI 也没有被本批桌面结果替代。不标任务完成或 npm 可发布。
回退本批提交即可撤销测试/文档覆盖,生产运行时无需回退。独立本地检查点提交后运行
提交审计,再继续下一批;没有 push、部署或发布。
+1 -1
View File
@@ -611,7 +611,7 @@
- PKG-DANMUKU-05: [记录](changes/2026-09-13-PKG-DANMUKU-05-resources.md) [记录](baselines/danmuku-resources-validation.json)
- PKG-DANMUKU-06: [记录](changes/2026-09-13-PKG-DANMUKU-06-types.md) [记录](baselines/danmuku-types-validation.json)
- PKG-DANMUKU-07: [记录](changes/2026-09-13-PKG-DANMUKU-07-stability.md) [记录](baselines/danmuku-stability-validation.json) [记录](danmuku-stability.md)
- PKG-DANMUKU-08: [记录](changes/2026-09-14-PKG-DANMUKU-08-native-combinations.md) [记录](baselines/danmuku-combination-checkpoint.json)
- PKG-DANMUKU-08: [记录](changes/2026-09-14-PKG-DANMUKU-08-native-combinations.md) [记录](baselines/danmuku-combination-checkpoint.json) [记录](changes/2026-09-14-PKG-DANMUKU-08-fullscreen-lifetimes.md) [记录](baselines/danmuku-fullscreen-validation.json) [记录](baselines/danmuku-old-core-load-validation.json)
- PKG-CANVAS-01: [记录](changes/2026-09-12-PKG-CANVAS-01-contract.md) [记录](baselines/canvas-contract.md) [记录](baselines/canvas-release.json) [记录](canvas-validation.md) [记录](baselines/canvas-contract-validation.json)
- PKG-CANVAS-02: [记录](changes/2026-09-12-PKG-CANVAS-02-tests.md) [记录](baselines/canvas-behavior-validation.json) [记录](canvas-validation.md)
- PKG-CANVAS-03: [记录](changes/2026-09-12-PKG-CANVAS-03-lifecycle.md) [记录](baselines/canvas-lifecycle-validation.json) [记录](canvas-validation.md)
+19
View File
@@ -1,5 +1,24 @@
# 进度与证据
## PKG-DANMUKU-08 全屏生命周期与旧核心负载检查点(仍 doing)
三引擎/新旧核心/新旧弹幕的网页及原生document全屏源码24项通过,候选main与
legacy各12项通过。每项三个完整播放器生命周期,按媒体时间显示行,核验宽度
恢复、设置/热力图、Worker终止及候选scheduler清空。冻结旧核心销毁后监听器
残留按已有BASE-LIFE-18独立记录,新核心不接受这项历史差异。
首轮3/5与三引擎首轮20/4失败均保留:分别修正隐藏同名测量节点的测试定位、
跨实例全局事件记录污染,以及WebKit退出事件晚于fullscreenElement清空的等待。
没有修改生产运行时/声明、扩大旧超时或重试。新增旧核心持续负载12项通过;
候选完整处理72/720行、分配3/21–22节点,Worker及DOM清理通过。同插件哈希下
与07候选核心帧间隔P95接近;不是Mask/PiP组合负载或物理设备性能证明。
见[变更](changes/2026-09-14-PKG-DANMUKU-08-fullscreen-lifetimes.md)、
[全屏证据](baselines/danmuku-fullscreen-validation.json)和
[旧核心负载](baselines/danmuku-old-core-load-validation.json)。261项仍192 done、
21 doing、48 todo;08接续完整旧插件组合及组合负载,09最终安装分发。
本检查点独立本地提交,随后核验提交审计;没有push/部署/发布。
## PKG-DANMUKU-08 原生画中画与真实 Mask 组合检查点(仍 doing)
新增新旧核心中的两次原生Document PiP开窗,按媒体时间显示弹幕,检查图层/热力图
+4 -1
View File
@@ -3562,7 +3562,10 @@
"acceptance": "性能无未解释退化,反复装卸无累计资源",
"evidence": [
"changes/2026-09-14-PKG-DANMUKU-08-native-combinations.md",
"baselines/danmuku-combination-checkpoint.json"
"baselines/danmuku-combination-checkpoint.json",
"changes/2026-09-14-PKG-DANMUKU-08-fullscreen-lifetimes.md",
"baselines/danmuku-fullscreen-validation.json",
"baselines/danmuku-old-core-load-validation.json"
]
},
{
+10 -1
View File
@@ -219,7 +219,16 @@ row. CPU-block observations remain diagnostic, not no-loss acceptance. Both it a
`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
`danmuku-fullscreen.spec.js` covers old/current core and plugin combinations in
native document and CSS web fullscreen, with three fresh player lifetimes each.
It checks timestamp-delivered visible text, layout restoration, Worker/DOM cleanup
and instance-local event sequences. Frozen-core post-destroy listeners are an
explicit historical observation, never a waiver for the candidate. Native exit
must deliver its event before teardown; hidden measurement nodes are not visible
rows. `ARTPLAYER_DANMUKU_ARTIFACT` selects a real candidate main/legacy input.
`danmuku-stability.spec.js` covers old/current core and plugin combinations and
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
+167
View File
@@ -0,0 +1,167 @@
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('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() })
implementations.set('candidate', { input: process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'source UMD', code: process.env.ARTPLAYER_DANMUKU_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_DANMUKU_ARTIFACT, 'utf8') : await compilePackage('artplayer-plugin-danmuku', 'umd') })
})
for (const core of ['published', 'candidate']) {
for (const plugin of ['published', 'candidate']) {
for (const mode of ['web', 'native']) {
test(`${core} core / ${plugin} Danmuku: ${mode} fullscreen across three player lifetimes`, async ({ page }, testInfo) => {
test.setTimeout(45000) // Three independent players, native entry/exit and timestamp-delivered rows.
await page.goto(`/test/player.html?core=${core}`)
const input = implementations.get(plugin)
const evidence = { core, plugin, mode, input: input.input, sha256: hash(input.code), cycles: [], outcome: 'incomplete' }
await page.evaluate(() => {
window.fullscreenWorkers = []
window.fullscreenLifetimes = []
const NativeWorker = window.Worker
window.Worker = class extends NativeWorker {
constructor(...args) {
super(...args)
this.record = { replies: 0, terminated: 0 }
window.fullscreenWorkers.push(this.record)
this.addEventListener('message', () => this.record.replies++)
}
terminate() {
this.record.terminated++
return super.terminate()
}
}
})
await page.addScriptTag({ content: input.code })
try {
for (let cycle = 0; cycle < 3; cycle++) {
await page.evaluate(({ cycle, mode }) => {
window.fullscreenRows = []
window.fullscreenErrors = []
window.fullscreenModes = []
window.art = new window.Artplayer({
container: '.player',
url: '/assets/sample/video.mp4',
muted: true,
fullscreen: true,
fullscreenWeb: true,
plugins: [window.artplayerPluginDanmuku({ danmuku: [], heatmap: true, emitter: true, speed: 5 })],
})
window.fullscreenPlugin = window.art.plugins.artplayerPluginDanmuku
window.fullscreenOwner = window.fullscreenPlugin.config({})
const { art, fullscreenModes: modes, fullscreenRows: rows, fullscreenErrors: errors } = window
window.fullscreenLifetimes.push({ modes, rows, errors })
art.on(mode === 'native' ? 'fullscreen' : 'fullscreenWeb', active => modes.push(active))
art.on('artplayerPluginDanmuku:visible', row => rows.push({ text: row.text, time: art.currentTime }))
art.on('artplayerPluginDanmuku:error', error => errors.push(String(error)))
const toggle = document.createElement('button')
toggle.id = 'danmuku-fullscreen-toggle'
toggle.textContent = `Fullscreen ${cycle}`
toggle.style.cssText = 'position:absolute;top:10px;left:10px;z-index:9999'
toggle.onclick = () => {
if (mode === 'native')
window.art.fullscreen = !window.art.fullscreen
else
window.art.fullscreenWeb = !window.art.fullscreenWeb
}
window.art.template.$player.append(toggle)
document.querySelector('#play').onclick = () => window.art.play()
}, { cycle, mode })
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)
const width = await page.evaluate(() => window.art.template.$player.clientWidth)
await page.click('#danmuku-fullscreen-toggle')
if (mode === 'native')
await expect.poll(() => page.evaluate(() => (document.fullscreenElement || document.webkitFullscreenElement) === window.art.template.$player)).toBe(true)
else
await expect.poll(() => page.evaluate(() => window.art.fullscreenWeb)).toBe(true)
const text = `Fullscreen row ${cycle}`
await page.evaluate(async (text) => {
await window.fullscreenPlugin.load([{ text, time: window.art.currentTime + 0.5, mode: 1 }])
}, text)
await expect.poll(() => page.evaluate(text => window.fullscreenRows.some(row => row.text === text), text)).toBe(true)
// The published renderer retains a hidden measurement node with the same text.
await expect(page.getByText(text, { exact: true }).and(page.locator(':visible'))).toBeVisible()
const entered = await page.evaluate(() => ({
width: window.art.template.$player.clientWidth,
layerWidth: window.art.template.$danmuku.clientWidth,
settings: document.querySelectorAll('.artplayer-plugin-danmuku').length,
heatmaps: document.querySelectorAll('.art-control-heatmap').length,
time: window.art.currentTime,
}))
expect(entered.width).toBeGreaterThan(width)
expect(entered.layerWidth).toBe(entered.width)
expect(entered.settings).toBe(1)
expect(entered.heatmaps).toBe(1)
await page.evaluate(() => window.art.pause())
await expect.poll(() => page.evaluate(() => window.art.video.paused)).toBe(true)
await page.click('#danmuku-fullscreen-toggle')
if (mode === 'native')
await expect.poll(() => page.evaluate(() => Boolean(document.fullscreenElement || document.webkitFullscreenElement))).toBe(false)
else
await expect.poll(() => page.evaluate(() => window.art.fullscreenWeb)).toBe(false)
await expect.poll(() => page.evaluate(() => window.art.template.$player.clientWidth)).toBe(width)
// Native exit can clear fullscreenElement before its change event is delivered.
await expect.poll(() => page.evaluate(() => window.fullscreenModes)).toEqual([true, false])
const after = await page.evaluate(() => {
const art = window.art
const owner = window.fullscreenOwner
const modes = [...window.fullscreenModes]
art.destroy()
return {
modes,
errors: window.fullscreenErrors,
roots: document.querySelectorAll('.art-video-player').length,
settings: document.querySelectorAll('.artplayer-plugin-danmuku').length,
heatmaps: document.querySelectorAll('.art-control-heatmap').length,
styles: document.querySelectorAll('style#artplayer-plugin-danmuku').length,
workers: window.fullscreenWorkers.map(worker => ({ ...worker })),
frame: owner.scheduler?.frame,
operation: owner.scheduler?.operation,
}
})
evidence.cycles.push({ cycle, entered, after })
expect(after.roots + after.settings + after.heatmaps).toBe(0)
expect(after.styles).toBe(1)
expect(after.errors).toEqual([])
expect(after.modes).toEqual([true, false])
expect(after.workers).toHaveLength(cycle + 1)
for (const worker of after.workers) {
expect(worker.replies).toBeGreaterThan(0)
expect(worker.terminated).toBe(1)
}
if (plugin === 'candidate') {
expect(after.frame).toBeNull()
expect(after.operation).toBeNull()
}
}
evidence.lifetimes = await page.evaluate(() => window.fullscreenLifetimes)
for (const [index, lifetime] of evidence.lifetimes.entries()) {
// BASE-LIFE-18: the frozen core keeps native listeners after destroy.
const changes = core === 'published' && mode === 'native' ? 3 - index : 1
expect(lifetime.modes).toEqual(Array.from({ length: changes }, () => [true, false]).flat())
expect(lifetime.errors).toEqual([])
}
evidence.outcome = 'three-lifetimes-fullscreen-playback-cleanup-passed'
}
finally {
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy()
})
await testInfo.attach('danmuku-fullscreen', { contentType: 'application/json', body: JSON.stringify(evidence) })
}
})
}
}
}
+167 -165
View File
@@ -17,178 +17,180 @@ test.beforeAll(async () => {
: 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))
}
for (const core of ['published', 'candidate']) {
for (const implementation of ['published', 'candidate']) {
for (const rate of [2, 20]) {
test(`${core} core / ${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=${core}`)
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)
}
postMessage(...args) {
this.record.posts++
return super.postMessage(...args)
}
terminate() {
this.record.terminated++
return super.terminate()
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)
}
}
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,
})
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
})
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)
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)
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({ core, 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)
}
}
for (const worker of evidence.workers) {
expect(worker.terminated).toBe(1)
expect(worker.errors).toEqual([])
if (implementation === 'candidate')
expect(worker.posts).toBe(worker.replies)
}
}
})
})
}
}
}