mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-15 00:56:16 -08:00
test(danmuku): [PKG-DANMUKU-08] verify fullscreen lifetimes and old-core loads
This commit is contained in:
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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user