mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 10:56:15 -08:00
test(BASE-06): capture performance and teardown resource baselines
This commit is contained in:
1 parent
c7a375e146
commit
e01f39ffa0
14 files changed
+3036
-18
No files matched your search
@@ -44,3 +44,7 @@ BASE-04 已在内置浏览器完成两轮 16 项 DOM/样式/实际输入断言
|
||||
## 分发、类型与 SSR 消费
|
||||
|
||||
BASE-05 的 [覆盖/复跑/历史诊断](consumer-coverage.md) 包含 18 项 Node runtime、16 项严格 TS 场景和浏览器两轮各 5 项 SSR 检查。[消费者](consumers.json)、[SSR](ssr.json)、[22 包资源](distribution.json) 分别记录已发布内容与工作区观察。8 项类型历史失败保持显式,不能计作候选通过。
|
||||
|
||||
## 性能和资源
|
||||
|
||||
BASE-06 保存两轮交错计时与独立资源诊断,见 [采样/阈值/范围](performance-coverage.md)、[原始报告](performance.json) 和 [压缩体积](sizes.json)。销毁后的延迟 resize/notice 工作已登记,不能把基本清理通过当成无内存泄漏证明;本任务没有优化收益声明。
|
||||
@@ -0,0 +1,73 @@
|
||||
# 性能与资源基线
|
||||
|
||||
BASE-06 使用固定发布 core 5.4.0 / chapter 1.1.0、本地 video.mp4 和 640x360 容器。两次独立页面重载,每次 12 个计时样本和 6 个资源探针;保留原始数据、预热标记和来源,见 [performance.json](performance.json)。没有修改生产实现,本任务不声称任何优化收益。
|
||||
|
||||
## 环境与采样
|
||||
|
||||
- Codex 内置浏览器,UA Chrome 152.0.0.0 / Windows,viewport 890x1272,devicePixelRatio=1,全程 visible,无 visibilitychange。
|
||||
- 浏览器报告 hardwareConcurrency=12、deviceMemory=32;这些是浏览器暴露值。测量后独立 Windows CIM 查询为 i5-12400F / 6 物理核心 / 12 逻辑线程 / 31.8 GiB 内存,记录在 hostContext,不将浏览器字段当硬件容量证明。
|
||||
- 发布 JS、夹具 LF 指纹和媒体 SHA-256 均绑定报告;固定本地服务返回 no-store,但未强制清空操作系统/解码器/连接缓存,不能称严格冷启动。其他应用负载、CPU 电源/温度和浏览器调度未锁定。
|
||||
- 每轮 core/chapter 交错执行,各 1 个预热、5 个计时样本;原始预热也保留。构造同步耗时、构造到 ready、play Promise 兑现、destroy 同步耗时分别测量。ready 通过 RAF 轮询已就绪状态,包含约一帧的观察量化和本地媒体初始化,不能称精确解码延迟。
|
||||
- 每个样本真实播放 currentTime 至少前进 0.15 秒,然后暂停、销毁;不通过固定 sleep 假装媒体就绪。没有测完整片段播放、FPS、 dropped frames、seek 热路径、首个呈现帧或下载耗时。
|
||||
|
||||
## 两轮计时结果
|
||||
|
||||
以下均为各 5 个非预热样本的中位数,单位 ms;完整 min/median/max 在报告中。样本量不足以报告有意义的尾部百分位或置信收益。
|
||||
|
||||
| 配置/轮次 | constructor | ready | play Promise | destroy |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| core 第一轮 | 5.60 | 30.20 | 3.00 | 3.00 |
|
||||
| chapter 第一轮 | 5.70 | 29.80 | 3.40 | 3.10 |
|
||||
| core 第二轮 | 4.10 | 12.80 | 3.10 | 2.80 |
|
||||
| chapter 第二轮 | 5.00 | 14.50 | 1.40 | 3.20 |
|
||||
|
||||
代码完全相同,ready 中位数仍明显变化,说明一次快慢差异不足以证明优化。后续候选须在相同浏览器/设备/viewport/样本/配置中交错旧新运行,至少三组配对复测后评估,不拿这张旧表与不同机器/浏览器直接比较。
|
||||
|
||||
## 资源观察
|
||||
|
||||
计时样本不替换定时器。资源探针另行临时追踪 window timeout/interval/RAF,并显式启用 USE_RAF=true;默认计时保持发布默认 USE_RAF=false。探针保留原生回调 this、参数、句柄和取消行为,但任何 instrumentation 仍可能扰动时序,因此资源探针的耗时不纳入性能比较。
|
||||
|
||||
每轮资源 probe 运行 core/chapter 各三次:实际播放至至少两个 raf 事件、开启 notice、触发一个自定义代理事件,以及受控的 synthetic window resize 后立即 destroy。350ms 是销毁后观察窗口,不是等待媒体加载的固定延迟。
|
||||
|
||||
| 观察面 | 两轮实际结果 | 判断界限 |
|
||||
| --- | --- | --- |
|
||||
| 计时样本 DOM / 实例 / proxy 清理 Set | 每次挂载 core 113 节点、chapter 125 节点;57 个代理清理项;销毁后均为 0 | 数量是本配置的观察,不是必须永远固定的内部结构;Set 为内部账本,不等同全浏览器原生监听器统计 |
|
||||
| video | 每次销毁后 paused=true、src 属性移除 | 未证明网络连接、解码器、GPU 内存即时释放 |
|
||||
| 资源探针自定义代理 | 销毁前收到一次,销毁后再次 dispatch 不增加调用 | 真实 DOM 事件分发但为受控 synthetic 事件,不冒充用户输入 |
|
||||
| RAF | 销毁前至少 2 次;销毁后 0 次 | 检查 opt-in USE_RAF 路径,不等于所有插件的动画循环通过 |
|
||||
| pending resize / notice | 每次销毁后仍有 200ms timeout;它执行一次 resize,随后创建新的 2000ms notice timeout;350ms 后仍在记录中 | BASE-PERF-01,见下节;不是已经修复或永久泄漏证明 |
|
||||
|
||||
两轮六组资源状态相同,errors/unhandled 与浏览器 error/warn 日志为空,最终 Artplayer.instances=0。探针先保存未清资源,再用原生取消函数清理其追踪到的剩余句柄并恢复 API;**此额外清理不能算作播放器自己的清理成功**。追踪器本身会临时持有回调/句柄,未进行 heap snapshot、强制 GC、detached-node 可达性或进程 RSS 测量,不能给出“无内存泄漏”结论。
|
||||
|
||||
### BASE-PERF-01:销毁后的 resize 工作
|
||||
|
||||
受控 window resize 在 destroy 前安排 debounce,destroy 清掉 DOM 代理、现有 Info/Notice timer 和 RAF,但没有取消该 debounce。晚到回调再次 emit resize,aspectRatio setter 又安排 notice timeout。当前源码 resizeInit、utils/time、aspectRatioMix 与发布行为一致;CORE-17 负责取消/晚到事件,关联 CORE-04 生命周期和 CORE-18 notice 防线。
|
||||
|
||||
后续应增加候选正向断言:销毁后没有该 resize 回调及新通知计时器,同时保持正常 resize 时序和公开快捷键。不要要求候选继续满足历史异常 profile,也不要只延长等待至 timer 自然结束来掩盖失去生命周期归属的问题。旧实现的现象仅在此有界窗口确认,不能直接断言永久保留内存。
|
||||
|
||||
## 包体积与审查阈值
|
||||
|
||||
[sizes.json](sizes.json) 分别保存 28 个真实发布 JS 文件和 85 个工作区 JS 文件的 raw/gzip9/Brotli6 字节数。工作区覆盖 21 库并匹配 BASE-05 的历史观察哈希,可能包含陈旧 dist,不能当作 21 包的真实 npm 版本。文档站没有库 bundle 入口,其站点体积由 SITE/ENG-08 单独测量。压缩使用固定 Node 24.21.0 及记录的 zlib/Brotli 版本,单文件独立压缩不是生产 HTTP 实际传输量或安装后大小。
|
||||
|
||||
| 发布主文件 | raw bytes | gzip level 9 | Brotli quality 6 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| core .js | 134037 | 35486 | 32533 |
|
||||
| core .legacy.js | 135748 | 36021 | 33045 |
|
||||
| core .mjs | 208826 | 43868 | 40443 |
|
||||
| chapter .js / .legacy.js,各自 | 4973 | 1850 | 1653 |
|
||||
| chapter .mjs | 7721 | 2126 | 1904 |
|
||||
|
||||
阈值是初始审查规则,不是测得的收益承诺或单次自动发布裁决:
|
||||
|
||||
- 同环境中位数增加超过 max(25%, 绝对容差) 才产生计时 review signal;constructor/destroy 容差 2ms,ready 50ms,play Promise 10ms。绝对容差防止毫秒级噪声因比例被夸大;ready 容差还考虑 RAF/媒体噪声。发布前 ENG-08/MOD-03 结合更多配对样本复核阈值。
|
||||
- 同一分发文件、相同压缩方法增加超过 max(5%, 1024 bytes) 必须解释。不同格式、总安装体积、不同来源不可混比;较小变化不豁免接口或依赖审查。
|
||||
- 新增可复现的销毁后监听响应、RAF、DOM/实例持续增长或未说明残留资源属于功能/生命周期阻断项,不能用计时改善抵消。BASE-PERF-01 作为已有问题有明确修复责任。
|
||||
|
||||
## 命令和维护入口
|
||||
|
||||
1. 运行 `node refactor/scripts/browser-server.mjs`,打开 `http://127.0.0.1:8083/fixtures/performance.html` 并点击 Run performance baseline。保持前台,采样期间不要同时构建/压缩或运行其他重负载。
|
||||
2. 显示 Saved 12 timing samples and 6 resource probes / errors 0 后,运行 `node refactor/scripts/performance.mjs --compare refactor/.cache/reports/performance.json`。比较会验证来源、媒体、前台状态及基本清理,再输出中位数、计时审查信号和资源 profile 差异;时间信号要求配对复测。
|
||||
3. 重载重复,保留每轮报告,不覆盖历史证据。`performance.mjs --check` 只校验已存报告,不重跑浏览器。`sizes.mjs --check` 会重算真实发布文件压缩大小,工作区部分仍为历史清单。
|
||||
4. `node --test refactor/scripts/performance.test.mjs` / `yarn test:baseline` 包含假播放、后台、缺样本、清理失败、设备不一致、重大变慢和体积遗漏负例。计时比较排除预热,并允许候选修正历史残留后报告明确差异。
|
||||
|
||||
performance.js 负责实际采样与有限资源追踪,performance.mjs 负责数据有效性、统计和比较,sizes.mjs 负责内容哈希与压缩。任务归属:BASE-06 提供首批基线;ENG-08 参数化候选与趋势/体积准入;MOD-03 核心性能、MOD-04 重型插件/proxy、CORE-17/04/18 处理已复现残留。未覆盖的字幕/worker/模型/网络/GPU/真机长期资源验证仍由对应包和发布复盘承担。
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
# BASE-06:性能与资源基线
|
||||
|
||||
日期 2026-09-10;分支 codex/compatible-modernization;起点 c7a375e1。
|
||||
|
||||
## 实现及测量
|
||||
|
||||
新增 performance.html/js,使用固定发布核心/chapter 和本地视频。两次前台重载各收集 12 个交错计时样本(每配置 1 预热+5 样本),真实播放推进后销毁;分别测 constructor、ready、play Promise 和 destroy,不用固定延迟声称媒体已就绪。
|
||||
|
||||
资源诊断独立于计时,临时追踪定时器/RAF、开启 USE_RAF=true、触发代理事件和销毁前的 pending resize。两轮各 6 组结果一致:DOM/实例/代理账本清空、原代理不再响应、RAF 停止;同时复现 BASE-PERF-01,销毁后的 debounce 触发 resize 并重新创建 notice timer。追踪器记录后自己的额外取消操作没有计入播放器清理结果。
|
||||
|
||||
新增独立验证/统计/比较工具、阈值说明、28 个已发布 JS 与 85 个工作区 JS 的压缩体积清单(21 库)。真实发布来源与工作区陈旧产物分开。浏览器报告和测量后的 host CIM 参数均留存;不声称已测内存/GC/GPU 泄漏或获得性能收益。同代码两轮 ready 中位数从约 30ms 到 13-15ms,恰说明需要多组配对比较。
|
||||
|
||||
详细结果、ID、环境、范围、阈值和复现见 [性能台账](../baselines/performance-coverage.md)、[浏览器原始数据](../baselines/performance.json) 和 [体积数据](../baselines/sizes.json)。BASE-PERF-01 已分配 CORE-17,关联 CORE-04/18;ENG-08/MOD-03 接续候选比较与改进。
|
||||
|
||||
## 验证与接续
|
||||
|
||||
两轮 errors/unhandled 和浏览器 error/warn 为空;新增 3 项 Node 测试包含数据无效、后台/假播放、清理失败、忽略预热、重大变慢、不同设备与体积漏项负例,真实发布压缩结果可重算。完整 ci:check、计划/链接、语法和 diff 检查通过。没有修改生产代码、依赖、声明或分发产物,不用历史异常要求候选继续出错。
|
||||
|
||||
本任务独立本地提交,主题含 BASE-06;不推送/发布。撤销提交恢复上一个基线服务和测试入口;历史报告由 Git 保留。下一任务 BASE-07:整合差异、vendored/外部依赖与风险,随后继续工程基础和首个 TS 试点。
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<title>ArtPlayer published performance baseline</title>
|
||||
<style>.player { width: 640px; height: 360px; } pre { white-space: pre-wrap; }</style>
|
||||
<h1>Published performance and resource baseline</h1>
|
||||
<p>Keep this tab visible. Timing samples are separate from instrumented resource probes.</p>
|
||||
<button id="run">Run performance baseline</button>
|
||||
<p id="status">Ready</p>
|
||||
<div class="player"></div>
|
||||
<pre id="result"></pre>
|
||||
<script src="/releases/artplayer/dist/artplayer.js"></script>
|
||||
<script src="/releases/artplayer-plugin-chapter/dist/artplayer-plugin-chapter.js"></script>
|
||||
<script src="/fixtures/performance.js"></script>
|
||||
</html>
|
||||
@@ -0,0 +1,166 @@
|
||||
/* global Artplayer, artplayerPluginChapter */
|
||||
(() => {
|
||||
const container = document.querySelector('.player')
|
||||
const status = document.querySelector('#status')
|
||||
const now = () => performance.now()
|
||||
const options = chapter => ({ container, url: '/assets/sample/video.mp4', muted: true, plugins: chapter ? [artplayerPluginChapter({ chapters: [{ start: 0, end: 10, title: 'First' }, { start: 10, end: Infinity, title: 'Second' }] })] : [] })
|
||||
function waitFor(test, timeout = 12000) {
|
||||
const started = now()
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = () => {
|
||||
if (test()) resolve()
|
||||
else if (now() - started > timeout) reject(new Error('State timeout'))
|
||||
else requestAnimationFrame(poll)
|
||||
}
|
||||
poll()
|
||||
})
|
||||
}
|
||||
function timers() {
|
||||
const original = Object.fromEntries(['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'requestAnimationFrame', 'cancelAnimationFrame'].map(name => [name, window[name]]))
|
||||
const active = new Map()
|
||||
const late = []
|
||||
let destroyed = false
|
||||
const capture = (kind, callback, delay, args) => {
|
||||
if (typeof callback !== 'function') throw new Error('Timer probe only supports function callbacks')
|
||||
let handle
|
||||
const wrapped = function (...values) {
|
||||
if (kind !== 'interval') active.delete(`${kind}:${handle}`)
|
||||
if (destroyed) late.push({ kind, delay })
|
||||
return callback.apply(this, values)
|
||||
}
|
||||
handle = kind === 'raf' ? original.requestAnimationFrame.call(window, wrapped) : original[kind === 'timeout' ? 'setTimeout' : 'setInterval'].call(window, wrapped, delay, ...args)
|
||||
active.set(`${kind}:${handle}`, { kind, delay })
|
||||
return handle
|
||||
}
|
||||
window.setTimeout = (callback, delay = 0, ...args) => capture('timeout', callback, delay, args)
|
||||
window.setInterval = (callback, delay = 0, ...args) => capture('interval', callback, delay, args)
|
||||
window.requestAnimationFrame = callback => capture('raf', callback, null, [])
|
||||
for (const clear of ['clearTimeout', 'clearInterval']) window[clear] = (handle) => {
|
||||
active.delete(`timeout:${handle}`)
|
||||
active.delete(`interval:${handle}`)
|
||||
return original[clear].call(window, handle)
|
||||
}
|
||||
window.cancelAnimationFrame = (handle) => {
|
||||
active.delete(`raf:${handle}`)
|
||||
return original.cancelAnimationFrame.call(window, handle)
|
||||
}
|
||||
return {
|
||||
snapshot: () => [...active.values()], late,
|
||||
markDestroyed: () => { destroyed = true },
|
||||
wait: ms => new Promise(resolve => original.setTimeout.call(window, resolve, ms)),
|
||||
restore: () => {
|
||||
// Probe-owned cleanup only after recording; never count it as library cleanup.
|
||||
for (const key of active.keys()) {
|
||||
const [kind, handle] = key.split(':')
|
||||
original[kind === 'raf' ? 'cancelAnimationFrame' : kind === 'interval' ? 'clearInterval' : 'clearTimeout'].call(window, Number(handle))
|
||||
}
|
||||
Object.assign(window, original)
|
||||
active.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
document.querySelector('#run').onclick = async () => {
|
||||
document.querySelector('#run').disabled = true
|
||||
const report = {
|
||||
kind: 'performance',
|
||||
environment: { userAgent: navigator.userAgent, language: navigator.language, hardwareConcurrency: navigator.hardwareConcurrency, deviceMemory: navigator.deviceMemory ?? null, devicePixelRatio, viewport: { width: innerWidth, height: innerHeight }, visibility: document.visibilityState },
|
||||
scripts: [...document.scripts].map(script => new URL(script.src).pathname),
|
||||
samples: [], resources: [], errors: [], unhandled: [], visibilityChanges: [],
|
||||
}
|
||||
const onError = event => report.errors.push(event.message)
|
||||
const onUnhandled = event => report.unhandled.push(String(event.reason))
|
||||
const onVisibility = () => report.visibilityChanges.push(document.visibilityState)
|
||||
addEventListener('error', onError)
|
||||
addEventListener('unhandledrejection', onUnhandled)
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
let art
|
||||
const originalRaf = Artplayer.USE_RAF
|
||||
try {
|
||||
if (document.visibilityState !== 'visible') throw new Error('Keep benchmark tab visible')
|
||||
if (originalRaf !== false) throw new Error('Unexpected published default USE_RAF')
|
||||
// Interleave configurations, retaining one warm-up per configuration separately.
|
||||
for (let round = 0; round < 6; round++) {
|
||||
for (const chapter of [false, true]) {
|
||||
status.textContent = `Timing ${round + 1}/6: ${chapter ? 'core + chapter' : 'core'}`
|
||||
const started = now()
|
||||
art = new Artplayer(options(chapter))
|
||||
const constructorMs = now() - started
|
||||
await waitFor(() => art.isReady)
|
||||
const readyMs = now() - started
|
||||
const playbackStarted = now()
|
||||
await art.play()
|
||||
const playResolvedMs = now() - playbackStarted
|
||||
const startTime = art.currentTime
|
||||
await waitFor(() => art.currentTime >= startTime + 0.15)
|
||||
const playbackObservedMs = now() - playbackStarted
|
||||
const mediaProgress = art.currentTime - startTime
|
||||
art.pause()
|
||||
const nodes = container.querySelectorAll('*').length
|
||||
const proxyCleanupEntries = art.events.destroyEvents.size
|
||||
const video = art.video
|
||||
const videoWidth = video.videoWidth
|
||||
const destroyStarted = now()
|
||||
art.destroy()
|
||||
const destroyMs = now() - destroyStarted
|
||||
report.samples.push({ configuration: chapter ? 'chapter' : 'core', warmup: round === 0, round, constructorMs, readyMs, playResolvedMs, playbackObservedMs, mediaProgress, videoWidth, nodes, proxyCleanupEntries, destroyMs,
|
||||
afterDestroy: { nodes: container.querySelectorAll('*').length, instances: Artplayer.instances.length, proxyCleanupEntries: art.events.destroyEvents.size, paused: video.paused, sourceAttribute: video.getAttribute('src') } })
|
||||
art = null
|
||||
}
|
||||
}
|
||||
// Instrumentation is deliberately excluded from the timing samples above.
|
||||
for (let round = 0; round < 3; round++) {
|
||||
for (const chapter of [false, true]) {
|
||||
status.textContent = `Resources ${round + 1}/3: ${chapter ? 'core + chapter' : 'core'}`
|
||||
const probe = timers()
|
||||
let destroyed = false
|
||||
let lateResizeEvents = 0
|
||||
let proxyCalls = 0
|
||||
let frames = 0
|
||||
try {
|
||||
Artplayer.USE_RAF = true
|
||||
art = new Artplayer(options(chapter))
|
||||
art.on('raf', () => frames++)
|
||||
art.on('resize', () => { if (destroyed) lateResizeEvents++ })
|
||||
art.proxy(document, 'art-baseline-resource', () => proxyCalls++)
|
||||
await waitFor(() => art.isReady)
|
||||
await art.play()
|
||||
await waitFor(() => frames >= 2)
|
||||
art.notice.show = 'Resource probe'
|
||||
document.dispatchEvent(new Event('art-baseline-resource'))
|
||||
// Controlled pending debounce before teardown, not a trusted user resize.
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
const before = { timers: probe.snapshot(), proxyCleanupEntries: art.events.destroyEvents.size, frames, proxyCalls }
|
||||
art.destroy()
|
||||
destroyed = true
|
||||
probe.markDestroyed()
|
||||
const immediatelyAfter = probe.snapshot()
|
||||
const atDestroyFrames = frames
|
||||
document.dispatchEvent(new Event('art-baseline-resource'))
|
||||
const waitStarted = now()
|
||||
await probe.wait(350)
|
||||
report.resources.push({ configuration: chapter ? 'chapter' : 'core', round, useRaf: true, before, immediatelyAfter,
|
||||
after: { observedMs: now() - waitStarted, timers: probe.snapshot(), lateCallbacks: [...probe.late], lateResizeEvents, framesAfterDestroy: frames - atDestroyFrames, proxyCalls, proxyCleanupEntries: art.events.destroyEvents.size, nodes: container.querySelectorAll('*').length, instances: Artplayer.instances.length } })
|
||||
art = null
|
||||
}
|
||||
finally {
|
||||
if (art && Artplayer.instances.includes(art)) art.destroy()
|
||||
probe.restore()
|
||||
Artplayer.USE_RAF = originalRaf
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) { report.errors.push(error.message) }
|
||||
finally {
|
||||
if (art && Artplayer.instances.includes(art)) art.destroy()
|
||||
Artplayer.USE_RAF = originalRaf
|
||||
removeEventListener('error', onError)
|
||||
removeEventListener('unhandledrejection', onUnhandled)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
report.remainingInstances = Artplayer.instances.length
|
||||
document.querySelector('#result').textContent = JSON.stringify(report, null, 2)
|
||||
const response = await fetch('/reports/performance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(report) })
|
||||
status.textContent = response.ok ? `Saved ${report.samples.length} timing samples and ${report.resources.length} resource probes; errors ${report.errors.length}` : 'Save failed'
|
||||
}
|
||||
})()
|
||||
+9
-8
@@ -4,7 +4,7 @@
|
||||
|
||||
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
|
||||
|
||||
状态:todo 192 / doing 0 / blocked 0 / done 22 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
状态:todo 191 / doing 0 / blocked 0 / done 23 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
|
||||
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
||||
|
||||
@@ -65,8 +65,8 @@
|
||||
| BASE-03 | workspace<br>捕获事件、异步和生命周期 | BASE-02 | ready/restart/destroy、Promise、切源/插件事件 trace;登记契约/版本/固定测试 ID/命令/报告与负责任务 | 成功、失败、重入和多实例有断言,历史缺陷有标记;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务 | H | done |
|
||||
| BASE-04 | workspace<br>捕获 DOM、CSS 和官方 demo | BASE-02 | 核心模板/类名/变量、键盘/焦点/可访问名称基线及全 demo 路径映射;docs-browser-testing.md 中 HTML/编辑器的脚本来源与存储状态;登记契约/版本/固定测试 ID/命令/报告与负责任务 | 核心/试点用户样式和输入用法已记录,其他 demo 运行状态与未知项明确;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务 | M | done |
|
||||
| BASE-05 | workspace<br>捕获包入口、资源和类型消费 | BASE-01 | 全包 manifest/资源路径清单;核心/试点的 UMD/AMD/ESM/CJS/legacy/i18n 和类型消费者基线;登记契约/版本/固定测试 ID/命令/报告与负责任务;区分库/npm 与站点实际分发类别,SSR import/模板/非浏览器构造错误分别捕获 | 核心/试点隔离消费可运行,其他包由各自契约任务验证,thumbnail tool 差异已登记 | H | done |
|
||||
| BASE-06 | workspace<br>记录性能与资源基线 | BASE-03, BASE-04 | 固定媒体/设备测量、包体积、反复装卸资源报告 | 多次采样可重跑,指标和审查阈值确定,不承诺未经测量的收益;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务 | M | todo |
|
||||
| BASE-07 | workspace<br>建立差异和风险台账 | BASE-03, BASE-05 | 销毁/切源/类型差异、vendored 许可和外部 SDK 清单 | 已确认与待复现分开,每项有负责任务及兼容处理路线;纳入 BASE-TYPE-01/02/03/04 和 BASE-DIST-01 的发布/声明/工作区证据 | H | todo |
|
||||
| BASE-06 | workspace<br>记录性能与资源基线 | BASE-03, BASE-04 | 固定媒体/设备测量、包体积、反复装卸资源报告 | 多次采样可重跑,指标和审查阈值确定,不承诺未经测量的收益;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务 | M | done |
|
||||
| BASE-07 | workspace<br>建立差异和风险台账 | BASE-03, BASE-05 | 销毁/切源/类型差异、vendored 许可和外部 SDK 清单 | 已确认与待复现分开,每项有负责任务及兼容处理路线;纳入 BASE-TYPE-01/02/03/04 和 BASE-DIST-01 的发布/声明/工作区证据;纳入 BASE-PERF-01 销毁后 resize/notice 延迟工作 | H | todo |
|
||||
| BASE-08 | workspace<br>建立消费者与真实环境验证矩阵 | BASE-01, BASE-05 | 每包版本/浏览器/codec/SDK/设备/样本、证据和负责验证任务的矩阵 | 格式兼容与运行能力分开,未知/缺环境不算通过,明确哪些检查阻止哪些批次发布 | H | todo |
|
||||
|
||||
## 2 工程保障
|
||||
@@ -81,7 +81,7 @@
|
||||
| ENG-05 | workspace<br>建立真实浏览器测试服务 | ENG-03, BASE-04, BASE-08 | Playwright projects、本地 Range/失败媒体服务;复用 docs 页面/样本的状态隔离、错误采集与候选资源映射 | Chromium/Firefox/WebKit 的基础播放 smoke 和报告可执行;以媒体状态断言判定通过,区分轻量用例与真实编辑器交互 | M | todo |
|
||||
| ENG-06 | workspace<br>支持按包非交互与 JS/TS 构建 | ENG-02, BASE-05 | build/dev 入口解析、指定包参数、原交互保留 | 三种产物、Less/SVG/worker 和本地 8082 demo 正常;保持 BASE-05 的 AMD 同时写入全局行为及 i18n/legacy 入口 | H | todo |
|
||||
| ENG-07 | workspace<br>建立 tarball 消费与产物检查 | ENG-04, ENG-06 | 隔离 npm 消费 fixtures、API/声明/入口差分 | 不借 workspace 源码通过,能识别缺文件与默认导出变化 | H | todo |
|
||||
| ENG-08 | workspace<br>增加覆盖率、资源与性能报告 | ENG-03, ENG-05, BASE-06 | 覆盖率基线、资源清理断言、性能报告与阈值 | 关键生命周期分支有门槛,报告不靠无意义断言堆数量 | M | todo |
|
||||
| ENG-08 | workspace<br>增加覆盖率、资源与性能报告 | ENG-03, ENG-05, BASE-06 | 覆盖率基线、资源清理断言、性能报告与阈值 | 关键生命周期分支有门槛,报告不靠无意义断言堆数量;将 BASE-06 的同环境多组配对、计时/压缩审查阈值与资源异常分开接入候选,不把历史现象冻结成正常要求 | M | todo |
|
||||
| ENG-09 | workspace<br>建立全包依赖影响和文档检查 | ENG-07, DOC-04 | 共享核心/构建影响映射、文档及每任务完成提交的 Git 审计接入;契约-支持版本-测试 ID-命令-候选/报告-任务的覆盖索引 | 核心变化触发必需生态检查;原有 DOC-01 至 04 基线例外明确,后续 done 任务不能缺失独立 commit;计划/已执行/缺证据分开,识别缺少验证归属的公开契约 | M | todo |
|
||||
| ENG-10 | workspace<br>建立历史失败分级和测试可靠性规则 | ENG-03, ENG-04, ENG-05, ENG-07, BASE-07 | 历史失败 ID/环境/旧版复现/负责修复任务、逐模块门槛、受控等待与 trace/retry 规则 | 不靠全局忽略或无理由 skip 隐藏问题,新增回归阻止交付,设备缺口和偶发失败单独可见 | M | todo |
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
| CORE-01 | artplayer<br>迁移纯工具并冻结导出 | ENG-03, ENG-04, ENG-06, PILOT-01 | utils/time/property/format/file/error/subtitle 的分批 TS 迁移 | Artplayer.utils 导出/参数/绑定保持,纯逻辑与类型测试通过 | M | todo |
|
||||
| CORE-02 | artplayer<br>迁移内部 Emitter | CORE-01, BASE-03 | 带类型事件映射的原 Emitter 实现 | ctx、once/off、重入/异常和链式返回保持 | H | todo |
|
||||
| CORE-03 | artplayer<br>建立内部资源作用域 | CORE-02 | 监听/订阅/定时器/RAF/请求/URL 的资源登记 | 实例与操作作用域分离,单项清理失败不阻断其他资源 | H | todo |
|
||||
| CORE-04 | artplayer<br>修复初始化与销毁生命周期 | CORE-03, ENG-05, BASE-07 | 构造失败回收、重复/重入 destroy、instances 修复 | 事件顺序、removeHtml、多实例和销毁中异步有真实回归;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-04/05 并增加候选正向回归 | H | todo |
|
||||
| CORE-04 | artplayer<br>修复初始化与销毁生命周期 | CORE-03, ENG-05, BASE-07 | 构造失败回收、重复/重入 destroy、instances 修复 | 事件顺序、removeHtml、多实例和销毁中异步有真实回归;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-04/05 并增加候选正向回归;协调 BASE-PERF-01 销毁后定时器与回调归属 | H | todo |
|
||||
| CORE-05 | artplayer<br>区分输入配置和内部配置 | CORE-01, ENG-04 | Option/ResolvedOption、默认值与校验整理 | 配置合并/错误时机不变,JS 输入仍运行时校验 | M | todo |
|
||||
| CORE-06 | artplayer<br>建立内部媒体与宿主类型 | CORE-05 | 原生 video、canvas shim、UI host 类型 | 不更改公开 art.video 用法,不以 HTMLVideoElement 断言掩盖 shim | M | todo |
|
||||
| CORE-07 | artplayer<br>逐项协调公开声明差异 | CORE-02, CORE-06, BASE-07 | plugins/toggle/cue/setting/static 的旧新类型对照 | 每项有兼容重载/扩展或待决结论,不能删除旧类型样例;处理 BASE-TYPE-01/04,分别保留旧合法声明消费并验证真实返回 | H | todo |
|
||||
@@ -120,8 +120,8 @@
|
||||
| CORE-14 | artplayer<br>拆分设置树、渲染和布局 | CORE-13 | setting model/selection/render/layout 的分批实现 | 不强行统一控件返回值,嵌套选择、默认高亮、更新清理通过;结合 BASE-DOM-02 验证窄容器设置定位 | H | todo |
|
||||
| CORE-15 | artplayer<br>迁移字幕获取、解析和渲染 | CORE-11, CORE-13 | subtitle 请求/track/DOM/URL 生命周期与类型 | 过期结果、偏移、cue 数组、原生全屏及错误路径通过 | H | todo |
|
||||
| CORE-16 | artplayer<br>迁移全屏、PiP、mini 与尺寸模式 | CORE-11, CORE-12 | 显示模式服务和原公开属性门面 | 模式互斥/恢复位置、手势权限、移动方向和退出失败通过 | H | todo |
|
||||
| CORE-17 | artplayer<br>迁移键盘、手势、焦点和全局事件 | CORE-13, CORE-16 | events/hotkey 的明确 document 与资源归属 | 跨 document 重绑、触摸/键盘、快捷键及销毁无重复响应 | H | todo |
|
||||
| CORE-18 | artplayer<br>迁移内置插件和提示模块 | CORE-14, CORE-16, CORE-17 | autoPlayback/autoOrientation/fastForward/lock/miniProgressBar 及 info/notice/loading/mask | 默认启用条件、storage 格式、定时器/动画清理通过 | M | todo |
|
||||
| CORE-17 | artplayer<br>迁移键盘、手势、焦点和全局事件 | CORE-13, CORE-16 | events/hotkey 的明确 document 与资源归属 | 跨 document 重绑、触摸/键盘、快捷键及销毁无重复响应;修复 BASE-PERF-01 pending resize debounce 销毁后执行,并验证正常 resize 不变 | H | todo |
|
||||
| CORE-18 | artplayer<br>迁移内置插件和提示模块 | CORE-14, CORE-16, CORE-17 | autoPlayback/autoOrientation/fastForward/lock/miniProgressBar 及 info/notice/loading/mask | 默认启用条件、storage 格式、定时器/动画清理通过;结合 BASE-PERF-01 防止销毁后重新安排 notice timer | M | todo |
|
||||
| CORE-19 | artplayer<br>整理进度、质量、缩略图和截图 | CORE-10, CORE-13, CORE-16 | 相关 player/control 功能与纯计算分离 | 截图跨域失败、缩略图布局、quality/进度边界保持 | M | todo |
|
||||
| CORE-20 | artplayer<br>收敛核心入口与依赖方向 | CORE-14, CORE-15, CORE-18, CORE-19 | Artplayer 门面、初始化依赖与最终目录 | 所有核心自有源码迁移,公开描述符/静态接口/事件差分通过 | H | todo |
|
||||
| CORE-21 | artplayer<br>生成核心声明并校验包内容 | CORE-20, ENG-07 | 由 TS 生成的既有入口声明与三种发布产物 | 内部类型不泄漏,旧 JS/TS 消费者与 i18n 通过 | H | todo |
|
||||
@@ -375,7 +375,7 @@
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| MOD-01 | workspace<br>Bun 固定版本干净安装试点 | ENG-09 | 独立目录的 Bun 安装与 Yarn 冻结基线对比,默认仍为用户选定的 Yarn | Node 测试仍通过;安装与资源一致才决定采用;不改 bundler;不自动替换默认 packageManager 或维护锁文件 | H | todo |
|
||||
| MOD-02 | workspace<br>整理剩余开发/构建脚本与插件模板 | ENG-06, SITE-03 | dev/build/utils/create-plugin 的 TS 与可测 CLI,模板同时提供旧 API | 旧脚本入口保留、新插件类型/测试/示例齐全,Lerna 改动单独取证 | M | todo |
|
||||
| MOD-03 | workspace<br>测量并优化核心热路径 | CORE-22, ENG-08 | DOM 读写、进度更新、持久化、初始化的测量与改进 | 相同设备媒体多次比较,契约不变,收益及无效尝试记录 | M | todo |
|
||||
| MOD-03 | workspace<br>测量并优化核心热路径 | CORE-22, ENG-08 | DOM 读写、进度更新、持久化、初始化的测量与改进 | 相同设备媒体多次比较,契约不变,收益及无效尝试记录;复用 BASE-06 的原始样本与测量限制,至少三组同环境旧新配对,不以单次变快宣称收益 | M | todo |
|
||||
| MOD-04 | workspace<br>测量并优化重型插件/proxy | PKG-DANMUKU-09, PKG-MASK-06, PKG-MB-10, ENG-08 | 帧/队列/推理/音画同步与资源长期运行比较 | 不改默认算法/阈值,性能改善有证据;无收益则保留旧实现 | M | todo |
|
||||
| MOD-05 | workspace<br>完成工具链与性能采用决策 | MOD-01, MOD-02, MOD-03, MOD-04 | 最终 runtime/packageManager/构建配置及性能台账 | 干净安装和全包检查通过;Bun 未采用有理由,不为状态强行切换 | M | todo |
|
||||
|
||||
@@ -422,6 +422,7 @@
|
||||
- BASE-03: [记录](changes/2026-09-10-BASE-03-lifecycle.md) [记录](baselines/lifecycle.json) [记录](baselines/lifecycle-coverage.md)
|
||||
- BASE-04: [记录](changes/2026-09-10-BASE-04-dom.md) [记录](baselines/dom.json) [记录](baselines/dom-coverage.md) [记录](baselines/demo-inventory.json)
|
||||
- BASE-05: [记录](changes/2026-09-10-BASE-05-consumers.md) [记录](baselines/consumer-coverage.md) [记录](baselines/consumers.json) [记录](baselines/ssr.json) [记录](baselines/distribution.json)
|
||||
- BASE-06: [记录](changes/2026-09-10-BASE-06-performance.md) [记录](baselines/performance-coverage.md) [记录](baselines/performance.json) [记录](baselines/sizes.json)
|
||||
- ENG-01: [记录](changes/2026-09-10-ENG-01-reproducible-toolchain.md) [记录](baselines/toolchain-validation.json)
|
||||
- ENG-PM-01: [记录](changes/2026-09-10-ENG-PM-01-yarn-toolchain.md) [记录](baselines/yarn-validation.json)
|
||||
- ENG-02: [记录](changes/2026-09-10-ENG-02-readonly-ci.md) [记录](baselines/ci-validation.json)
|
||||
@@ -1,5 +1,11 @@
|
||||
# 进度与证据
|
||||
|
||||
## 当前实施:BASE-06 性能与资源基线完成
|
||||
|
||||
内置浏览器两轮各 12 个计时样本和 6 组资源探针已保存;真实播放及基本清理通过,同时复现销毁后 resize 防抖重新安排 notice timer,已分配 CORE-17/04/18。28 个发布 JS 与 85 个工作区 JS 的压缩体积独立记录。阈值只用于复测/审查,无生产优化或无内存泄漏结论。见 [BASE-06](changes/2026-09-10-BASE-06-performance.md)。
|
||||
|
||||
当前 214 项任务,23 完成、191 待办。BASE-05 已提交 c7a375e1;本轮独立提交 BASE-06。没有生产 TS 迁移、推送或发布;下一任务 BASE-07 差异/外部依赖风险台账。
|
||||
|
||||
## 当前实施:BASE-05 分发与类型消费者完成
|
||||
|
||||
固定发布包隔离消费的 18 项 Node runtime 检查通过;16 项 strict TS 场景中 8 项编译通过、8 项历史失败独立登记,未掩盖 NodeNext ESM 与 chapter 声明问题。内置浏览器两轮各 5 项 SSR 节点复用/媒体/清理检查通过。全包 manifest 与 118 个工作区 dist/types 资源已清点,thumbnail tool 和站点分发差异有后续责任。见 [BASE-05](changes/2026-09-10-BASE-05-consumers.md)。
|
||||
|
||||
@@ -37,7 +37,7 @@ function sendBytes(req, res, bytes, filename) {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, `http://127.0.0.1:${port}`)
|
||||
const reportName = { '/reports/api': 'api', '/reports/lifecycle': 'lifecycle', '/reports/dom': 'dom', '/reports/ssr': 'ssr' }[url.pathname]
|
||||
const reportName = { '/reports/api': 'api', '/reports/lifecycle': 'lifecycle', '/reports/dom': 'dom', '/reports/ssr': 'ssr', '/reports/performance': 'performance' }[url.pathname]
|
||||
if (req.method === 'POST' && reportName) {
|
||||
assert.equal(req.headers.origin, `http://127.0.0.1:${port}`, 'Unexpected report origin')
|
||||
const chunks = []
|
||||
@@ -62,7 +62,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (reportName === 'lifecycle') {
|
||||
report.capture.media = Object.fromEntries(['video.mp4', 'video2.mp4'].map(name => [name, hash(fs.readFileSync(path.join(root, 'docs/assets/sample', name)))]))
|
||||
}
|
||||
if (['dom', 'ssr'].includes(reportName)) report.capture.media = { 'video.mp4': hash(fs.readFileSync(path.join(root, 'docs/assets/sample/video.mp4'))) }
|
||||
if (['dom', 'ssr', 'performance'].includes(reportName)) report.capture.media = { 'video.mp4': hash(fs.readFileSync(path.join(root, 'docs/assets/sample/video.mp4'))) }
|
||||
const output = path.join(refactorDir, '.cache/reports')
|
||||
fs.mkdirSync(output, { recursive: true })
|
||||
fs.writeFileSync(path.join(output, `${reportName}.json`), `${JSON.stringify(report, null, 2)}\n`)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { validatePublishedCapture } from './api.mjs'
|
||||
import { hash, refactorDir } from './releases.mjs'
|
||||
|
||||
export const timingThresholds = {
|
||||
constructorMs: { relative: 0.25, absoluteMs: 2 },
|
||||
readyMs: { relative: 0.25, absoluteMs: 50 },
|
||||
playResolvedMs: { relative: 0.25, absoluteMs: 10 },
|
||||
destroyMs: { relative: 0.25, absoluteMs: 2 },
|
||||
}
|
||||
export function validatePerformance(report) {
|
||||
assert.equal(report.kind, 'performance')
|
||||
validatePublishedCapture(report, 'performance')
|
||||
assert.deepEqual(report.errors, [])
|
||||
assert.deepEqual(report.unhandled, [])
|
||||
assert.equal(report.environment.visibility, 'visible')
|
||||
assert.deepEqual(report.visibilityChanges, [], 'Background timing is not comparable')
|
||||
assert.equal(report.remainingInstances, 0)
|
||||
assert.equal(report.capture.media['video.mp4'], hash(fs.readFileSync(path.resolve(refactorDir, '../docs/assets/sample/video.mp4'))))
|
||||
assert.deepEqual(report.samples.map(({ configuration, round, warmup }) => ({ configuration, round, warmup })), Array.from({ length: 6 }, (_, round) => ['core', 'chapter'].map(configuration => ({ configuration, round, warmup: round === 0 }))).flat())
|
||||
for (const sample of report.samples) {
|
||||
for (const key of [...Object.keys(timingThresholds), 'playbackObservedMs']) assert(Number.isFinite(sample[key]) && sample[key] >= 0, `Invalid ${key}`)
|
||||
assert(sample.readyMs >= sample.constructorMs && sample.playbackObservedMs >= sample.playResolvedMs)
|
||||
assert(Number.isFinite(sample.mediaProgress) && sample.mediaProgress >= 0.15 && Number.isFinite(sample.videoWidth) && sample.videoWidth > 0, 'Missing real playback progress')
|
||||
assert(sample.nodes > 0 && sample.proxyCleanupEntries > 0)
|
||||
assert.deepEqual(sample.afterDestroy, { nodes: 0, instances: 0, proxyCleanupEntries: 0, paused: true, sourceAttribute: null }, 'Basic lifecycle cleanup failed')
|
||||
}
|
||||
assert.deepEqual(report.resources.map(({ configuration, round }) => ({ configuration, round })), Array.from({ length: 3 }, (_, round) => ['core', 'chapter'].map(configuration => ({ configuration, round }))).flat())
|
||||
for (const resource of report.resources) {
|
||||
assert.equal(resource.useRaf, true)
|
||||
assert(resource.before.frames >= 2 && resource.before.proxyCleanupEntries > 0)
|
||||
assert.equal(resource.before.proxyCalls, 1)
|
||||
assert(resource.before.timers.some(timer => timer.kind === 'raf'), 'RAF probe was not active')
|
||||
assert(resource.after.observedMs >= 350, 'Insufficient teardown observation')
|
||||
for (const key of ['nodes', 'instances', 'proxyCleanupEntries', 'framesAfterDestroy']) assert.equal(resource.after[key], 0, `Resource cleanup failed: ${key}`)
|
||||
assert.equal(resource.after.proxyCalls, 1, 'Removed proxy listener still responds')
|
||||
// Late timeout work remains evidence, not a normal candidate cleanup requirement.
|
||||
assert(Array.isArray(resource.after.timers) && Array.isArray(resource.after.lateCallbacks))
|
||||
}
|
||||
}
|
||||
export function summarizePerformance(report) {
|
||||
return Object.fromEntries(['core', 'chapter'].map(configuration => {
|
||||
const samples = report.samples.filter(sample => sample.configuration === configuration && !sample.warmup)
|
||||
return [configuration, Object.fromEntries(Object.keys(timingThresholds).map(metric => {
|
||||
const values = samples.map(sample => sample[metric]).sort((a, b) => a - b)
|
||||
return [metric, { count: values.length, min: values[0], median: values[Math.floor(values.length / 2)], max: values.at(-1) }]
|
||||
}))]
|
||||
}))
|
||||
}
|
||||
export function resourceProfile(report) {
|
||||
return report.resources.map(({ configuration, round, after }) => ({ configuration, round, timers: after.timers, lateCallbacks: after.lateCallbacks, lateResizeEvents: after.lateResizeEvents, framesAfterDestroy: after.framesAfterDestroy, proxyCalls: after.proxyCalls, nodes: after.nodes, instances: after.instances }))
|
||||
}
|
||||
export function comparePerformance(before, after) {
|
||||
const cohort = report => ({ ...report.environment, visibility: undefined })
|
||||
assert.deepEqual(cohort(after), cohort(before), 'Different browser/viewport/hardware cohort; rerun paired samples')
|
||||
const previous = summarizePerformance(before)
|
||||
const current = summarizePerformance(after)
|
||||
const reviewSignals = []
|
||||
for (const configuration of ['core', 'chapter']) {
|
||||
for (const [metric, threshold] of Object.entries(timingThresholds)) {
|
||||
const deltaMs = current[configuration][metric].median - previous[configuration][metric].median
|
||||
const allowanceMs = Math.max(previous[configuration][metric].median * threshold.relative, threshold.absoluteMs)
|
||||
if (deltaMs > allowanceMs) reviewSignals.push({ configuration, metric, deltaMs, allowanceMs })
|
||||
}
|
||||
}
|
||||
return { summary: current, reviewSignals, resourceProfileChanged: JSON.stringify(resourceProfile(after)) !== JSON.stringify(resourceProfile(before)) }
|
||||
}
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const frozen = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/performance.json'), 'utf8'))
|
||||
for (const report of frozen.runs) validatePerformance(report)
|
||||
const index = process.argv.indexOf('--compare')
|
||||
if (index !== -1) {
|
||||
assert(process.argv[index + 1], '--compare requires a report path')
|
||||
const report = JSON.parse(fs.readFileSync(process.argv[index + 1], 'utf8'))
|
||||
validatePerformance(report)
|
||||
console.log(JSON.stringify(comparePerformance(frozen.runs[0], report), null, 2))
|
||||
console.log('Timing signals require repeat paired measurement and review, not automatic baseline replacement')
|
||||
}
|
||||
else {
|
||||
assert(process.argv.slice(2).every(arg => arg === '--check'), 'Use --check or --compare <report>')
|
||||
console.log(`Frozen performance evidence verified: ${frozen.runs.length} runs; no browser rerun or optimization claim`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { comparePerformance, summarizePerformance, validatePerformance } from './performance.mjs'
|
||||
import { publishedSizes, validateSizes } from './sizes.mjs'
|
||||
import { refactorDir } from './releases.mjs'
|
||||
|
||||
const baseline = () => JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/performance.json'), 'utf8'))
|
||||
|
||||
test('Performance evidence rejects background, fake playback and broken teardown', () => {
|
||||
const frozen = baseline()
|
||||
for (const report of frozen.runs) validatePerformance(report)
|
||||
for (const mutate of [
|
||||
report => report.visibilityChanges.push('hidden'),
|
||||
report => report.samples[2].mediaProgress = 0,
|
||||
report => report.samples[2].afterDestroy.instances = 1,
|
||||
report => report.resources[0].after.framesAfterDestroy = 1,
|
||||
report => report.resources[0].after.observedMs = 20,
|
||||
report => report.resources[0].after.proxyCalls = 2,
|
||||
report => report.samples.pop(),
|
||||
]) {
|
||||
const changed = structuredClone(frozen.runs[0])
|
||||
mutate(changed)
|
||||
assert.throws(() => validatePerformance(changed))
|
||||
}
|
||||
})
|
||||
|
||||
test('Performance comparison excludes warm-up, detects large regression and allows documented resource fixes', () => {
|
||||
const report = baseline().runs[0]
|
||||
const warmup = structuredClone(report)
|
||||
warmup.samples[0].constructorMs = 1000
|
||||
assert.deepEqual(summarizePerformance(warmup), summarizePerformance(report))
|
||||
const slower = structuredClone(report)
|
||||
for (const sample of slower.samples) {
|
||||
sample.constructorMs += 10
|
||||
sample.readyMs += 10
|
||||
}
|
||||
assert(comparePerformance(report, slower).reviewSignals.some(signal => signal.metric === 'constructorMs'))
|
||||
const fixed = structuredClone(report)
|
||||
for (const resource of fixed.resources) {
|
||||
resource.after.timers = []
|
||||
resource.after.lateCallbacks = []
|
||||
resource.after.lateResizeEvents = 0
|
||||
}
|
||||
validatePerformance(fixed)
|
||||
assert.equal(comparePerformance(report, fixed).resourceProfileChanged, true)
|
||||
const otherDevice = structuredClone(report)
|
||||
otherDevice.environment.hardwareConcurrency++
|
||||
assert.throws(() => comparePerformance(report, otherDevice))
|
||||
})
|
||||
|
||||
test('Published compression sizes are reproducible and inventory cannot omit a library', async () => {
|
||||
const report = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/sizes.json'), 'utf8'))
|
||||
validateSizes(report)
|
||||
assert.deepEqual(await publishedSizes(), report.published)
|
||||
const missing = structuredClone(report)
|
||||
missing.workspaceObserved = missing.workspaceObserved.filter(item => item.name !== 'artplayer-plugin-chapter')
|
||||
assert.throws(() => validateSizes(missing))
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { brotliCompressSync, constants, gzipSync } from 'node:zlib'
|
||||
import { ensureArchive, hash, readMember, refactorDir } from './releases.mjs'
|
||||
|
||||
const reportPath = path.join(refactorDir, 'baselines/sizes.json')
|
||||
export function measureBytes(bytes) {
|
||||
return { sha256: hash(bytes), raw: bytes.length, gzip9: gzipSync(bytes, { level: 9 }).length, brotli6: brotliCompressSync(bytes, { params: { [constants.BROTLI_PARAM_QUALITY]: 6 } }).length }
|
||||
}
|
||||
export async function publishedSizes() {
|
||||
const releases = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/releases.json'), 'utf8')).releases
|
||||
const result = []
|
||||
for (const release of releases) {
|
||||
const archive = await ensureArchive(release)
|
||||
for (const member of Object.keys(release.files).filter(name => /\.(m?js)$/.test(name)).sort()) {
|
||||
const sizes = measureBytes(readMember(archive, member))
|
||||
assert.equal(sizes.sha256, release.files[member])
|
||||
result.push({ name: release.name, version: release.version, path: member, ...sizes })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
export function validateSizes(report) {
|
||||
assert.equal(report.schemaVersion, 1)
|
||||
for (const source of ['published', 'workspaceObserved']) {
|
||||
for (const item of report[source]) {
|
||||
assert(/^[a-f0-9]{64}$/.test(item.sha256))
|
||||
for (const key of ['raw', 'gzip9', 'brotli6']) assert(Number.isInteger(item[key]) && item[key] > 0, 'Invalid bundle size')
|
||||
}
|
||||
}
|
||||
const packages = JSON.parse(fs.readFileSync(path.join(refactorDir, 'package-inventory.json'), 'utf8')).packages.filter(pkg => pkg.name !== 'artplayer-vitepress').map(pkg => pkg.name).sort()
|
||||
assert.deepEqual([...new Set(report.workspaceObserved.map(item => item.name))].sort(), packages, 'Missing library size inventory')
|
||||
}
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const published = await publishedSizes()
|
||||
if (process.argv.includes('--capture')) {
|
||||
assert(!fs.existsSync(reportPath), 'Do not overwrite historical size measurements')
|
||||
const distribution = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/distribution.json'), 'utf8'))
|
||||
const workspaceObserved = distribution.packages.flatMap(pkg => pkg.resources.filter(resource => resource.path.startsWith('dist/') && /\.(m?js)$/.test(resource.path)).map(resource => {
|
||||
const bytes = fs.readFileSync(path.resolve(refactorDir, '../packages', pkg.name, resource.path))
|
||||
assert.equal(hash(bytes), resource.sha256, 'Observed dist changed since BASE-05; record a new source snapshot')
|
||||
return { name: pkg.name, version: pkg.manifest.version, path: resource.path, ...measureBytes(bytes) }
|
||||
}))
|
||||
const report = { schemaVersion: 1, task: 'BASE-06', sourceCommit: 'c7a375e1', capturedAt: new Date().toISOString(), node: process.version, zlib: process.versions.zlib, brotli: process.versions.brotli,
|
||||
note: 'Independent file compression: gzip level 9, Brotli quality 6. Not production HTTP transfer size. Workspace files match BASE-05 observed hashes and may be stale; do not treat them as verified npm releases. Documentation site has no library bundle entry.',
|
||||
reviewThreshold: { relative: 0.05, absoluteBytes: 1024, rule: 'Review if same-file increase exceeds max(5 percent, 1024 bytes); not an optimization or automatic release verdict' }, published, workspaceObserved }
|
||||
validateSizes(report)
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
}
|
||||
else {
|
||||
assert(process.argv.slice(2).every(arg => arg === '--check'), 'Use --capture or --check')
|
||||
const frozen = JSON.parse(fs.readFileSync(reportPath, 'utf8'))
|
||||
validateSizes(frozen)
|
||||
assert.deepEqual(published, frozen.published, 'Published bundle sizes changed')
|
||||
console.log(`Recomputed ${published.length} published JS sizes; workspace inventory remains historical`)
|
||||
}
|
||||
}
|
||||
+13
-8
@@ -369,11 +369,16 @@
|
||||
"BASE-03",
|
||||
"BASE-04"
|
||||
],
|
||||
"status": "todo",
|
||||
"status": "done",
|
||||
"risk": "M",
|
||||
"deliverable": "固定媒体/设备测量、包体积、反复装卸资源报告",
|
||||
"acceptance": "多次采样可重跑,指标和审查阈值确定,不承诺未经测量的收益;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务",
|
||||
"evidence": []
|
||||
"evidence": [
|
||||
"changes/2026-09-10-BASE-06-performance.md",
|
||||
"baselines/performance-coverage.md",
|
||||
"baselines/performance.json",
|
||||
"baselines/sizes.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "BASE-07",
|
||||
@@ -389,7 +394,7 @@
|
||||
"status": "todo",
|
||||
"risk": "H",
|
||||
"deliverable": "销毁/切源/类型差异、vendored 许可和外部 SDK 清单",
|
||||
"acceptance": "已确认与待复现分开,每项有负责任务及兼容处理路线;纳入 BASE-TYPE-01/02/03/04 和 BASE-DIST-01 的发布/声明/工作区证据",
|
||||
"acceptance": "已确认与待复现分开,每项有负责任务及兼容处理路线;纳入 BASE-TYPE-01/02/03/04 和 BASE-DIST-01 的发布/声明/工作区证据;纳入 BASE-PERF-01 销毁后 resize/notice 延迟工作",
|
||||
"evidence": []
|
||||
},
|
||||
{
|
||||
@@ -568,7 +573,7 @@
|
||||
"status": "todo",
|
||||
"risk": "M",
|
||||
"deliverable": "覆盖率基线、资源清理断言、性能报告与阈值",
|
||||
"acceptance": "关键生命周期分支有门槛,报告不靠无意义断言堆数量",
|
||||
"acceptance": "关键生命周期分支有门槛,报告不靠无意义断言堆数量;将 BASE-06 的同环境多组配对、计时/压缩审查阈值与资源异常分开接入候选,不把历史现象冻结成正常要求",
|
||||
"evidence": []
|
||||
},
|
||||
{
|
||||
@@ -769,7 +774,7 @@
|
||||
"status": "todo",
|
||||
"risk": "H",
|
||||
"deliverable": "构造失败回收、重复/重入 destroy、instances 修复",
|
||||
"acceptance": "事件顺序、removeHtml、多实例和销毁中异步有真实回归;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-04/05 并增加候选正向回归",
|
||||
"acceptance": "事件顺序、removeHtml、多实例和销毁中异步有真实回归;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-04/05 并增加候选正向回归;协调 BASE-PERF-01 销毁后定时器与回调归属",
|
||||
"evidence": []
|
||||
},
|
||||
{
|
||||
@@ -989,7 +994,7 @@
|
||||
"status": "todo",
|
||||
"risk": "H",
|
||||
"deliverable": "events/hotkey 的明确 document 与资源归属",
|
||||
"acceptance": "跨 document 重绑、触摸/键盘、快捷键及销毁无重复响应",
|
||||
"acceptance": "跨 document 重绑、触摸/键盘、快捷键及销毁无重复响应;修复 BASE-PERF-01 pending resize debounce 销毁后执行,并验证正常 resize 不变",
|
||||
"evidence": []
|
||||
},
|
||||
{
|
||||
@@ -1007,7 +1012,7 @@
|
||||
"status": "todo",
|
||||
"risk": "M",
|
||||
"deliverable": "autoPlayback/autoOrientation/fastForward/lock/miniProgressBar 及 info/notice/loading/mask",
|
||||
"acceptance": "默认启用条件、storage 格式、定时器/动画清理通过",
|
||||
"acceptance": "默认启用条件、storage 格式、定时器/动画清理通过;结合 BASE-PERF-01 防止销毁后重新安排 notice timer",
|
||||
"evidence": []
|
||||
},
|
||||
{
|
||||
@@ -3555,7 +3560,7 @@
|
||||
"status": "todo",
|
||||
"risk": "M",
|
||||
"deliverable": "DOM 读写、进度更新、持久化、初始化的测量与改进",
|
||||
"acceptance": "相同设备媒体多次比较,契约不变,收益及无效尝试记录",
|
||||
"acceptance": "相同设备媒体多次比较,契约不变,收益及无效尝试记录;复用 BASE-06 的原始样本与测量限制,至少三组同环境旧新配对,不以单次变快宣称收益",
|
||||
"evidence": []
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user