mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-12 15:26:16 -08:00
test(refactor): [BASE-03] capture media and lifecycle behavior
This commit is contained in:
1 parent
b382ec08f7
commit
9489392905
15 files changed
+1333
-20
No files matched your search
@@ -32,3 +32,7 @@ BASE-01 于 2026-09-10 固定核心与 chapter 的 npm 发布内容,数据在
|
||||
## 公共 API 报告
|
||||
|
||||
BASE-02 已在内置浏览器取得核心/chapter 的同步 API 和描述符快照,见 [覆盖与重跑](api-coverage.md) 和 [冻结报告](public-api.json)。事件/生命周期、DOM、类型、分发和其他包仍由各自任务补齐。
|
||||
|
||||
## 事件与生命周期报告
|
||||
|
||||
BASE-03 已在内置浏览器实测播放推进、暂停/seek/切源、事件及销毁;两次 27 项断言通过,并保存 6 项历史问题。见 [覆盖与复现](lifecycle-coverage.md) 和 [原始报告](lifecycle.json)。受控拒绝与真正媒体播放分别标注,不代替全环境验收。
|
||||
@@ -45,3 +45,5 @@ BASE-02 使用固定 npm artplayer 5.4.0 + artplayer-plugin-chapter 1.1.0 的 UM
|
||||
4. `node refactor/scripts/api.mjs --check` 只核对持久报告和来源/快照摘要;`node --test refactor/scripts/api.test.mjs` 运行报告/比较器负例。两者不启动浏览器,CI 不能把它们当作新一轮真实浏览器执行。
|
||||
|
||||
yarn test:baseline / ci:check 已通过通配入口包含新增 api.test.mjs。负例涵盖缺检查、错误来源、采集失败、描述符变化、删除方法、选项/静态默认值变化和继承 API 删除。正式候选对照和浏览器自动回归由 ENG-05/07 逐步接入,当前夹具仍固定旧发布文件,不自动加载工作区候选。
|
||||
|
||||
BASE-03 已完成首组事件/真实媒体/生命周期采集,覆盖细分与仍未验证部分见 [生命周期基线](lifecycle-coverage.md);上表的后续责任仍按对应细项处理,不自动宣称整个契约通过。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 事件、媒体与生命周期基线
|
||||
|
||||
BASE-03 在 Codex 内置浏览器以固定 core 5.4.0 / chapter 1.1.0 执行,样本为仓库 video.mp4、video2.mp4。来源成员、媒体 SHA-256、夹具 LF 指纹、实际 UA 和采集时间均保存在 [lifecycle.json](lifecycle.json)。
|
||||
|
||||
## 验证方式与范围
|
||||
|
||||
点击 ../fixtures/lifecycle.html 的 Run lifecycle baseline,实际加载媒体、静音播放并观察 currentTime 推进。使用 ready/原生事件/状态条件等待,单步骤超时 12 秒;暂停及销毁后状态采用至少 250ms 的帧观察窗口。这个窗口用于测量稳定性,不是用 sleep 猜测加载完成。
|
||||
|
||||
公开 play 拒绝和切源恢复失败通过临时替换该实例 video.play 返回受控拒绝来验证,随后恢复原生方法。恢复失败发生在真实媒体切换、真实 canplay 之后;它验证包装器的错误传播,不代表测过浏览器自动播放权限、真实网络错误或所有 codec 故障。仅精确匹配受控错误对象的 unhandledrejection 会被探针记录到 expectedUnhandled 并 preventDefault;其他拒绝一律判为采集失败。
|
||||
|
||||
## 固定测试 ID 与契约
|
||||
|
||||
| 契约/场景 | 已执行 ID | 结果或约束 |
|
||||
| --- | --- | --- |
|
||||
| API-04 Emitter | EVENT.chain/context-arguments/once-reentry/dispatch-snapshot/off-original-once/throw-propagation | 同步链式调用、ctx/参数、once 重入、当前派发快照、按原回调移除 once、异常原样抛出且停止余下回调 |
|
||||
| API-01/05 ready | LIFE.ready-callback/ready-once-after-switches | readyCallback 的 this 和参数均为实例,正常切源不重复 ready |
|
||||
| API-02 真正播放 | MEDIA.metadata/play-promise/play-result/time-advances/pause-sync-stable/seek | native video 有有效媒体尺寸和 duration;时间推进;pause 同步返回 undefined,观察期间时间稳定;seek 到达目标 |
|
||||
| API-02 切源 | MEDIA.quality-preserves-time/url-resets-resumes/latest-source、LIFE.restart-url | 暂停中 switchQuality 保留时间和暂停状态;播放中 switchUrl 归零并恢复播放;restart 参数为目标 URL |
|
||||
| API-02 拒绝 | MEDIA.play-rejects-original | 原始受控 media.play 错误由公开 art.play Promise 原样拒绝;不是 codec/权限测试 |
|
||||
| API-06 插件 | PLUGIN.async-promise/async-registry/duplicate-throws | async 工厂返回 Promise,结果随后可见、this/参数为 art,兑现值仍为 registry;重名同步抛错并保留原结果 |
|
||||
| API-05 销毁 | LIFE.destroy-retains-markup/destroy-cleans-proxy/destroy-removes-markup/constructor-plugin-error | destroy(false) 保留 art-destroy 标记,proxy 监听移除、注册表移除;destroy(true) 清空容器;插件初始化异常能传播 |
|
||||
|
||||
完整的 27 个 ID 以 lifecycle.mjs 的 lifecycleChecks 为准,斜线缩写共享同一前缀。其余多插件组合、异步构造失败、真实网络重试、mutex、后台/移动端、字幕/SDK/worker 资源和类型测试仍由 BASE-08、ENG-05 和对应 CORE/包任务补齐。
|
||||
|
||||
记录的事件顺序有语义校验:loadedmetadata 在 ready 前;首次播放 native play -> native playing -> 公开 play;公开 pause 同步发生,native pause 随后到达;质量切换的 canplay 观察回调在 restart 前。原始 trace 保留更完整数据,但不把所有 native 事件计数、绝对媒体时间或跨阶段到达的 seeked 固定为全浏览器契约。
|
||||
|
||||
## 历史问题台账
|
||||
|
||||
这些发现不是必须永久保留的 API,也不是本次已修复。对应任务需要添加候选回归并记录兼容处理;不能通过要求旧缺陷继续发生来判断重构成功。
|
||||
|
||||
| ID | 发布版实测 | 负责修复/核对 | 判断边界 |
|
||||
| --- | --- | --- | --- |
|
||||
| BASE-LIFE-01 | 连续两个 switchUrl 在最后一个源的一次 canplay 后都兑现,旧操作没有独立完成依据 | CORE-09 | 最后源正确;过期操作结果语义需要明确,不直接推断发生了内容覆盖 |
|
||||
| BASE-LIFE-02 | switchUrl 后立即 destroy,Promise 在至少 250ms 观察窗口内仍未结算 | CORE-09,关联 CORE-04 | 这是有界观察;不能仅凭窗口称永远 pending。源码移除 native 事件后缺少取消结算路径 |
|
||||
| BASE-LIFE-03 | 异步插件结果在 destroy 后仍被注册 | CORE-08 | 已确认晚到注册;没有将它夸大为所有插件都泄漏资源 |
|
||||
| BASE-LIFE-04 | A/B 两实例,A 连续 destroy 两次后 B 从 Artplayer.instances 消失 | CORE-04 | B 仍是活实例;探针随后显式销毁 B,避免污染后续用例 |
|
||||
| BASE-LIFE-05 | 构造过程插件抛错后,已初始化的 DOM 和 57 个监听清理项仍存在,但实例未进入注册表 | CORE-04 | 探针持有失败实例并主动清理;57 为本次配置实测,不冻结为跨环境数量 |
|
||||
| BASE-LIFE-06 | 已发布版本切源恢复 play 拒绝,产生未处理拒绝且切源 Promise 在观察窗口内仍 pending | CORE-09 | 受控错误注入。当前工作区 switchMix 已使用 silencePromise,原 playback 测试覆盖该修复;重构必须保持这项已有改进,不能倒退成发布版行为 |
|
||||
|
||||
发布产物中的 switchMix、playMix、Events 与工作区源码已对照。BASE-LIFE-04 的原因是没有检查 instances.indexOf(this) 的 -1 就 splice;BASE-LIFE-03 的注册管理器没有销毁状态检查;BASE-LIFE-05 构造链没有异常回收路径。它们由后续实现任务处理,不在基线采集时修改播放器。
|
||||
|
||||
## 重跑和报告检查
|
||||
|
||||
1. `node refactor/scripts/browser-server.mjs`;默认 http://127.0.0.1:8083/fixtures/lifecycle.html。
|
||||
2. Chrome 不可用时直接用内置浏览器。每次 reload 后点击 Run lifecycle baseline,确认 CAPTURE COMPLETE、errors/unhandled 为空,并阅读 historical findings;这个状态表示采集完成,不表示没有缺陷。
|
||||
3. `node refactor/scripts/lifecycle.mjs --compare refactor/.cache/reports/lifecycle.json`,验证媒体、来源、事件顺序,再比较稳定的发布版观察结果。
|
||||
4. `node refactor/scripts/lifecycle.mjs --check` 只验证保存的报告。`yarn test:baseline` 包含报告校验负例:伪造播放推进、意外拒绝、事件颠倒会失败,合理时钟抖动不会造成快照失败。这些 Node 命令不重新执行浏览器。
|
||||
|
||||
lifecycleProfile 排除波动的时钟值和 native 事件次数,但保留已观察的 Promise、插件、实例注册及失败构造结果。它用于复验固定旧版本;候选修复历史问题后应采用正向回归与差异记录,不要求候选满足同一缺陷 profile。正式自动浏览器框架与候选包矩阵由 ENG-05/07 接续。
|
||||
@@ -0,0 +1,833 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"task": "BASE-03",
|
||||
"sourceCommit": "b382ec08",
|
||||
"browserSurface": "Codex in-app browser",
|
||||
"report": {
|
||||
"kind": "lifecycle",
|
||||
"environment": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36",
|
||||
"language": "zh-CN"
|
||||
},
|
||||
"scripts": [
|
||||
"/releases/artplayer/dist/artplayer.js",
|
||||
"/releases/artplayer-plugin-chapter/dist/artplayer-plugin-chapter.js",
|
||||
"/fixtures/lifecycle.js"
|
||||
],
|
||||
"checks": [
|
||||
{
|
||||
"id": "LIFE.version",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "EVENT.chain",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "EVENT.context-arguments",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "EVENT.once-reentry",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "EVENT.dispatch-snapshot",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "EVENT.off-original-once",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "EVENT.throw-propagation",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "LIFE.ready-callback",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "MEDIA.metadata",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "MEDIA.play-promise",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "MEDIA.play-result",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "MEDIA.time-advances",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "MEDIA.pause-sync-stable",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "MEDIA.seek",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "MEDIA.quality-preserves-time",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "MEDIA.url-resets-resumes",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "LIFE.ready-once-after-switches",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "LIFE.restart-url",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "MEDIA.play-rejects-original",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "PLUGIN.async-promise",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "PLUGIN.async-registry",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "PLUGIN.duplicate-throws",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "MEDIA.latest-source",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "LIFE.destroy-retains-markup",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "LIFE.destroy-cleans-proxy",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "LIFE.destroy-removes-markup",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"id": "LIFE.constructor-plugin-error",
|
||||
"passed": true
|
||||
}
|
||||
],
|
||||
"observations": {
|
||||
"expectedUnhandled": [
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"message": "controlled switch resume rejection"
|
||||
}
|
||||
],
|
||||
"emitterOrder": [
|
||||
"first",
|
||||
"second",
|
||||
"first",
|
||||
"later"
|
||||
],
|
||||
"playback": {
|
||||
"start": 0,
|
||||
"progressed": 0.196562,
|
||||
"duration": 90.046009,
|
||||
"width": 640,
|
||||
"height": 360
|
||||
},
|
||||
"pause": {
|
||||
"at": 0.200309,
|
||||
"observedMs": 253.19999998807907,
|
||||
"maxDelta": 0
|
||||
},
|
||||
"readyCallbacks": 1,
|
||||
"restarts": [
|
||||
"/assets/sample/video2.mp4",
|
||||
"/assets/sample/video.mp4?normal-switch"
|
||||
],
|
||||
"rejectedResume": {
|
||||
"mode": "controlled video.play rejection after real canplay",
|
||||
"switchState": "pending",
|
||||
"observedMs": 252,
|
||||
"expectedUnhandled": 1
|
||||
},
|
||||
"concurrentSwitch": {
|
||||
"outcomes": [
|
||||
"fulfilled",
|
||||
"fulfilled"
|
||||
],
|
||||
"finalSource": "/assets/sample/video2.mp4?race-last",
|
||||
"canplayEvents": 1
|
||||
},
|
||||
"destroyPending": {
|
||||
"switchState": "pending",
|
||||
"observedMs": 256.80000001192093,
|
||||
"latePluginRegistered": true
|
||||
},
|
||||
"repeatedDestroy": {
|
||||
"survivorBefore": true,
|
||||
"survivorAfter": false
|
||||
},
|
||||
"failedConstructor": {
|
||||
"hasMarkup": true,
|
||||
"listeners": 57,
|
||||
"registered": false
|
||||
},
|
||||
"remainingInstances": 0
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"id": "BASE-LIFE-06",
|
||||
"observation": "Published switch leaves an unhandled rejection and an unsettled promise when resume fails",
|
||||
"owner": "CORE-09"
|
||||
},
|
||||
{
|
||||
"id": "BASE-LIFE-01",
|
||||
"observation": "Superseded switch resolves along with the latest source",
|
||||
"owner": "CORE-09"
|
||||
},
|
||||
{
|
||||
"id": "BASE-LIFE-02",
|
||||
"observation": "Switch remains unsettled during the bounded observation after destroy",
|
||||
"owner": "CORE-09"
|
||||
},
|
||||
{
|
||||
"id": "BASE-LIFE-03",
|
||||
"observation": "Async plugin registers its result after destroy",
|
||||
"owner": "CORE-08"
|
||||
},
|
||||
{
|
||||
"id": "BASE-LIFE-04",
|
||||
"observation": "Destroying an instance twice unregisters a different live instance",
|
||||
"owner": "CORE-04"
|
||||
},
|
||||
{
|
||||
"id": "BASE-LIFE-05",
|
||||
"observation": "Failed constructor leaves initialized markup/listeners until explicit probe cleanup",
|
||||
"owner": "CORE-04"
|
||||
}
|
||||
],
|
||||
"trace": [
|
||||
{
|
||||
"phase": "initial",
|
||||
"event": "video:loadstart",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": false,
|
||||
"source": "/assets/sample/video.mp4",
|
||||
"argument": "loadstart"
|
||||
},
|
||||
{
|
||||
"phase": "initial",
|
||||
"event": "video:loadedmetadata",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": false,
|
||||
"source": "/assets/sample/video.mp4",
|
||||
"argument": "loadedmetadata"
|
||||
},
|
||||
{
|
||||
"phase": "initial",
|
||||
"event": "video:loadeddata",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": false,
|
||||
"source": "/assets/sample/video.mp4",
|
||||
"argument": "loadeddata"
|
||||
},
|
||||
{
|
||||
"phase": "initial",
|
||||
"event": "ready",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4",
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "initial",
|
||||
"event": "video:canplay",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4",
|
||||
"argument": "canplay"
|
||||
},
|
||||
{
|
||||
"phase": "play",
|
||||
"event": "video:play",
|
||||
"time": 0,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4",
|
||||
"argument": "play"
|
||||
},
|
||||
{
|
||||
"phase": "play",
|
||||
"event": "video:playing",
|
||||
"time": 0,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4",
|
||||
"argument": "playing"
|
||||
},
|
||||
{
|
||||
"phase": "play",
|
||||
"event": "play",
|
||||
"time": 0,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4",
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "pause",
|
||||
"event": "pause",
|
||||
"time": 0.200309,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4",
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "pause",
|
||||
"event": "video:pause",
|
||||
"time": 0.200309,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4",
|
||||
"argument": "pause"
|
||||
},
|
||||
{
|
||||
"phase": "seek",
|
||||
"event": "video:seeking",
|
||||
"time": 2,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4",
|
||||
"argument": "seeking"
|
||||
},
|
||||
{
|
||||
"phase": "seek",
|
||||
"event": "video:seeked",
|
||||
"time": 2,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4",
|
||||
"argument": "seeked"
|
||||
},
|
||||
{
|
||||
"phase": "quality-paused",
|
||||
"event": "pause",
|
||||
"time": 2,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4",
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "quality-paused",
|
||||
"event": "video:loadstart",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4",
|
||||
"argument": "loadstart"
|
||||
},
|
||||
{
|
||||
"phase": "quality-paused",
|
||||
"event": "video:loadedmetadata",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4",
|
||||
"argument": "loadedmetadata"
|
||||
},
|
||||
{
|
||||
"phase": "quality-paused",
|
||||
"event": "video:loadeddata",
|
||||
"time": 2,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4",
|
||||
"argument": "loadeddata"
|
||||
},
|
||||
{
|
||||
"phase": "quality-paused",
|
||||
"event": "video:canplay",
|
||||
"time": 2,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4",
|
||||
"argument": "canplay"
|
||||
},
|
||||
{
|
||||
"phase": "quality-paused",
|
||||
"event": "restart",
|
||||
"time": 2,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4",
|
||||
"argument": "/assets/sample/video2.mp4"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "video:seeking",
|
||||
"time": 2,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4",
|
||||
"argument": "seeking"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "video:play",
|
||||
"time": 2,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4",
|
||||
"argument": "play"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "video:seeked",
|
||||
"time": 2.000308,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4",
|
||||
"argument": "seeked"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "video:canplay",
|
||||
"time": 2.000344,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4",
|
||||
"argument": "canplay"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "video:playing",
|
||||
"time": 2.000379,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4",
|
||||
"argument": "playing"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "play",
|
||||
"time": 2.000421,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4",
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "pause",
|
||||
"time": 2.220301,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4",
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "video:loadstart",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": "loadstart"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "video:loadedmetadata",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": "loadedmetadata"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "video:loadeddata",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": "loadeddata"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "video:canplay",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": "canplay"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "restart",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": "/assets/sample/video.mp4?normal-switch"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "video:seeking",
|
||||
"time": 0,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": "seeking"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "video:play",
|
||||
"time": 0,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": "play"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "video:seeked",
|
||||
"time": 0.000355,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": "seeked"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "video:canplay",
|
||||
"time": 0.000391,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": "canplay"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "video:playing",
|
||||
"time": 0.000424,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": "playing"
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "play",
|
||||
"time": 0.000473,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "url-playing",
|
||||
"event": "pause",
|
||||
"time": 0.226534,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"event": "video:pause",
|
||||
"time": 0.241612,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": "pause"
|
||||
},
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"event": "video:play",
|
||||
"time": 0.241904,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": "play"
|
||||
},
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"event": "video:playing",
|
||||
"time": 0.242067,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": "playing"
|
||||
},
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"event": "play",
|
||||
"time": 0.242231,
|
||||
"paused": false,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"event": "pause",
|
||||
"time": 0.488549,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?normal-switch",
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"event": "video:loadstart",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?resume-rejection",
|
||||
"argument": "loadstart"
|
||||
},
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"event": "video:loadedmetadata",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?resume-rejection",
|
||||
"argument": "loadedmetadata"
|
||||
},
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"event": "video:loadeddata",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?resume-rejection",
|
||||
"argument": "loadeddata"
|
||||
},
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"event": "video:canplay",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?resume-rejection",
|
||||
"argument": "canplay"
|
||||
},
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"event": "restart",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?resume-rejection",
|
||||
"argument": "/assets/sample/video2.mp4?resume-rejection"
|
||||
},
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"event": "video:seeking",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?resume-rejection",
|
||||
"argument": "seeking"
|
||||
},
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"event": "video:seeked",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?resume-rejection",
|
||||
"argument": "seeked"
|
||||
},
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"event": "video:canplay",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?resume-rejection",
|
||||
"argument": "canplay"
|
||||
},
|
||||
{
|
||||
"phase": "controlled-resume-rejection",
|
||||
"event": "pause",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?resume-rejection",
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "concurrent-switch",
|
||||
"event": "pause",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?resume-rejection",
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "concurrent-switch",
|
||||
"event": "pause",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video.mp4?race-first",
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "concurrent-switch",
|
||||
"event": "video:loadstart",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?race-last",
|
||||
"argument": "loadstart"
|
||||
},
|
||||
{
|
||||
"phase": "concurrent-switch",
|
||||
"event": "video:loadedmetadata",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?race-last",
|
||||
"argument": "loadedmetadata"
|
||||
},
|
||||
{
|
||||
"phase": "concurrent-switch",
|
||||
"event": "video:loadeddata",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?race-last",
|
||||
"argument": "loadeddata"
|
||||
},
|
||||
{
|
||||
"phase": "concurrent-switch",
|
||||
"event": "video:canplay",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?race-last",
|
||||
"argument": "canplay"
|
||||
},
|
||||
{
|
||||
"phase": "concurrent-switch",
|
||||
"event": "restart",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?race-last",
|
||||
"argument": "/assets/sample/video.mp4?race-first"
|
||||
},
|
||||
{
|
||||
"phase": "concurrent-switch",
|
||||
"event": "restart",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?race-last",
|
||||
"argument": "/assets/sample/video2.mp4?race-last"
|
||||
},
|
||||
{
|
||||
"phase": "destroy-pending",
|
||||
"event": "pause",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": "/assets/sample/video2.mp4?race-last",
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "destroy-pending",
|
||||
"event": "destroy",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": true,
|
||||
"source": null,
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "duplicate-destroy",
|
||||
"event": "destroy",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": false,
|
||||
"source": null,
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "duplicate-destroy",
|
||||
"event": "destroy",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": false,
|
||||
"source": null,
|
||||
"argument": null
|
||||
},
|
||||
{
|
||||
"phase": "duplicate-destroy",
|
||||
"event": "destroy",
|
||||
"time": 0,
|
||||
"paused": true,
|
||||
"ready": false,
|
||||
"source": null,
|
||||
"argument": null
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"unhandled": [],
|
||||
"capture": {
|
||||
"capturedAt": "2026-09-10T11:50:21.727Z",
|
||||
"releases": [
|
||||
{
|
||||
"name": "artplayer",
|
||||
"version": "5.4.0",
|
||||
"integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==",
|
||||
"member": "package/dist/artplayer.js",
|
||||
"sha256": "f1db1fa3406aa6d41baa7c554749429a8e79f99d56a794bde23345eb9c07573f"
|
||||
},
|
||||
{
|
||||
"name": "artplayer-plugin-chapter",
|
||||
"version": "1.1.0",
|
||||
"integrity": "sha512-ji66Lr2K2S+OjFMuMby3yq+pefWlOC51Sbpop/DIr2N6w3F0smrF+YZbshN1Dy8/Tx1TYpT7N6WkkZWy5Jix1w==",
|
||||
"member": "package/dist/artplayer-plugin-chapter.js",
|
||||
"sha256": "c2aaee11bf1db765433b01d9f8dd3978e41fa4d39655db00ebd03fc0be3e35e0"
|
||||
}
|
||||
],
|
||||
"fixtureHashAlgorithm": "sha256-lf",
|
||||
"fixtures": {
|
||||
"lifecycle.html": "120851612220cb5b2101f495f3c90c7bfe1541bc81f320d700a76781689c4a0d",
|
||||
"lifecycle.js": "2017800cf809c386dc06879132aa34c5524cfb404a50ecd887ba28c2639044bd"
|
||||
},
|
||||
"media": {
|
||||
"video.mp4": "30a2de667ba267b6bc605edcee1c4bd728c048ed76f500ea0e73d1f1a6f8f980",
|
||||
"video2.mp4": "24b12d40ea21ace8e16a66cdb6085391e0262c3d96f8a972ac76607f48943b64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"repeatedCaptureAt": "2026-09-10T11:52:08.593Z",
|
||||
"profileSha256": "688f99f4d69ed937bc947cf9def2c546de6b644218c9f7509ee4c54dbef17964",
|
||||
"repeatComparison": "equal semantic outcomes; variable media timestamps retained only as observations"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# BASE-03:事件、播放与生命周期实测
|
||||
|
||||
日期:2026-09-10;分支 codex/compatible-modernization;起点 b382ec08。
|
||||
|
||||
## 交付与真实范围
|
||||
|
||||
新增 lifecycle.html/js,通过可见按钮运行固定 npm core 5.4.0 + chapter 1.1.0。真实加载本地两个 MP4,验证 metadata、play Promise、时间推进、pause 稳定、seek、质量/URL 切换、ready/restart、Emitter、async 插件及销毁。公开 play 拒绝和切源恢复失败使用明确的受控 video.play 注入,未冒充真实权限/网络/codec 故障。
|
||||
|
||||
服务器增加白名单 /reports/lifecycle,保留原 API 报告路径;生命周期报告包含发布成员/媒体哈希及夹具 LF 指纹。来源校验从 api.mjs 提取为共用函数,原 API 冻结数据不变。
|
||||
|
||||
## 实测与发现
|
||||
|
||||
Codex 内置浏览器最终两次执行,27 项断言通过,语义结果一致;各有 64 条原始事件记录。首次媒体时间从 0 到 0.196562 秒,尺寸 640x360,duration 90.046009;pause 观察 253.2ms,最大时间偏移为 0。只要求关键顺序和状态,不冻结浮动时钟。
|
||||
|
||||
已复现六项发布版问题:过期切源也兑现、销毁中切源未结算、销毁后晚到插件注册、重复 destroy 注销其他实例、构造失败后未回收 DOM/监听项、恢复播放拒绝后的未处理拒绝与 pending 切源。后两类 pending 均是有界观察,不能凭 250ms 声称永久挂起。恢复失败为受控错误注入;该路径在当前工作区已有 silencePromise 处理,不能让重构撤销已有修复。
|
||||
|
||||
所有正常及失败实例都由探针清理,最终注册数为 0。未预期 errors/unhandled 为空,浏览器 error/warn 日志为空。精确匹配的受控未处理拒绝单独保存在 expectedUnhandled,探针 preventDefault 只用于这一个已登记对象。
|
||||
|
||||
完整报告:[lifecycle.json](../baselines/lifecycle.json)。契约 ID、已测/未测范围、复现、六项问题及 CORE-04/08/09 责任见 [lifecycle-coverage.md](../baselines/lifecycle-coverage.md)。这些缺陷不被定义为必须兼容的正常行为,后续修复需正向候选测试和差异说明。
|
||||
|
||||
## 验证与接续
|
||||
|
||||
新增 lifecycle.mjs 校验及比较命令,两项 Node 负例覆盖假播放推进、意外拒绝、事件颠倒和时钟抖动。原 API、归档/HTTP 基线继续通过;新增检查自动进入 test:baseline。夹具和脚本语法、完整 ci:check、计划/链接与 Git 差异检查通过。
|
||||
|
||||
本轮没有修改生产代码、声明、依赖或分发产物,不代表全生态、网络重试、mutex、移动设备或完整资源生命周期通过。对应责任在覆盖文档中保留,ENG-05 再建立正式自动浏览器/故障注入框架。
|
||||
|
||||
本任务独立本地提交,主题含 BASE-03;可撤销提交恢复旧基线服务,冻结记录保留于 Git 历史。没有推送或发布。下一步 BASE-04 记录 DOM/CSS 与基础交互,再推进试点所需的类型/分发/测试基础。
|
||||
@@ -12,3 +12,7 @@
|
||||
2026-09-10:按用户授权使用内置浏览器后,BASE-02 最终两次采集的 14 项检查均通过,快照相同,errors 为空。持久报告、比较命令、契约覆盖及剩余范围见 [API 基线](../baselines/api-coverage.md)。本页面没有验证真实播放。
|
||||
|
||||
夹具与报告路径由基线任务维护;ENG-05 可复用并扩充受控等待、媒体错误和正式自动化。api.js 依据已核对源码设计断言,真实发布行为如有不同应调查并记录,不能修改断言来盲目通过。
|
||||
|
||||
## 事件与真实媒体
|
||||
|
||||
`/fixtures/lifecycle.html` 的按钮执行 BASE-03 场景,报告写入独立 `/reports/lifecycle`,不会覆盖 api.json。覆盖、受控拒绝边界与六项历史问题见 [生命周期基线](../baselines/lifecycle-coverage.md)。CAPTURE COMPLETE 仅表示采集成功,需同时查看 findings。
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<title>ArtPlayer published lifecycle baseline</title>
|
||||
<style>.player { width: 480px; height: 270px; margin: 8px 0; } pre { white-space: pre-wrap; }</style>
|
||||
<h1>Published lifecycle baseline</h1>
|
||||
<p>This checks real muted playback and records historical lifecycle findings.</p>
|
||||
<button id="run">Run lifecycle baseline</button>
|
||||
<p id="status">Ready to run</p>
|
||||
<div id="players"></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/lifecycle.js"></script>
|
||||
</html>
|
||||
@@ -0,0 +1,240 @@
|
||||
/* global Artplayer, artplayerPluginChapter */
|
||||
document.getElementById('run').addEventListener('click', async (event) => {
|
||||
event.currentTarget.disabled = true
|
||||
document.getElementById('status').textContent = 'Running real-media checks...'
|
||||
const report = { kind: 'lifecycle', environment: { userAgent: navigator.userAgent, language: navigator.language }, scripts: [...document.scripts].map(script => new URL(script.src).pathname), checks: [], observations: {}, findings: [], trace: [], errors: [], unhandled: [] }
|
||||
const players = []
|
||||
let phase = 'emitter'
|
||||
const expectedRejections = new Set()
|
||||
report.observations.expectedUnhandled = []
|
||||
const check = (id, passed) => {
|
||||
report.checks.push({ id, passed: Boolean(passed) })
|
||||
if (!passed) throw new Error(id)
|
||||
}
|
||||
const unhandled = event => {
|
||||
if (expectedRejections.has(event.reason)) {
|
||||
report.observations.expectedUnhandled.push({ phase, message: event.reason.message })
|
||||
event.preventDefault()
|
||||
}
|
||||
else report.unhandled.push(String(event.reason?.message || event.reason))
|
||||
}
|
||||
window.addEventListener('unhandledrejection', unhandled)
|
||||
const bounded = (promise, label, ms = 12000) => {
|
||||
let timer
|
||||
return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Timeout: ${label}`)), ms) })])
|
||||
.finally(() => clearTimeout(timer))
|
||||
}
|
||||
const eventAfter = (art, name, action = () => {}) => {
|
||||
let listener
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
listener = resolve
|
||||
art.on(name, listener)
|
||||
try { action() } catch (error) { reject(error) }
|
||||
})
|
||||
return bounded(promise, name).finally(() => art.off(name, listener))
|
||||
}
|
||||
const advance = (art, start) => {
|
||||
let listener
|
||||
const promise = new Promise((resolve) => {
|
||||
listener = () => { if (art.currentTime >= start + 0.1) resolve(art.currentTime) }
|
||||
art.on('video:timeupdate', listener)
|
||||
listener()
|
||||
})
|
||||
return bounded(promise, 'media time advance').finally(() => art.off('video:timeupdate', listener))
|
||||
}
|
||||
const observeFrames = callback => bounded(new Promise((resolve) => {
|
||||
const start = performance.now()
|
||||
const frame = () => {
|
||||
callback()
|
||||
if (performance.now() - start >= 250) resolve(performance.now() - start)
|
||||
else requestAnimationFrame(frame)
|
||||
}
|
||||
requestAnimationFrame(frame)
|
||||
}), 'frame observation')
|
||||
const create = (option = {}, ready) => {
|
||||
const container = document.createElement('div')
|
||||
container.className = 'player'
|
||||
document.getElementById('players').append(container)
|
||||
const art = new Artplayer({ container, url: '/assets/sample/video.mp4', muted: true, mutex: false, plugins: [artplayerPluginChapter()], ...option }, ready)
|
||||
players.push(art)
|
||||
for (const name of ['ready', 'restart', 'play', 'pause', 'destroy', 'video:loadstart', 'video:loadedmetadata', 'video:loadeddata', 'video:canplay', 'video:play', 'video:playing', 'video:pause', 'video:seeking', 'video:seeked', 'video:error']) {
|
||||
art.on(name, (...args) => report.trace.push({ phase, event: name, time: art.currentTime, paused: art.video.paused, ready: art.isReady, source: art.video.getAttribute('src'), argument: typeof args[0] === 'string' ? args[0] : args[0]?.type || null }))
|
||||
}
|
||||
return art
|
||||
}
|
||||
try {
|
||||
check('LIFE.version', Artplayer.version === '5.4.0')
|
||||
const emitter = new Artplayer.Emitter()
|
||||
const ctx = {}
|
||||
let correctContext = false
|
||||
check('EVENT.chain', emitter.on('ctx', function (a, b) { correctContext = this === ctx && a === 3 && b === 'value' }, ctx).emit('ctx', 3, 'value').off('ctx') === emitter)
|
||||
check('EVENT.context-arguments', correctContext)
|
||||
let onceCount = 0
|
||||
emitter.once('once', () => { onceCount += 1; emitter.emit('once') })
|
||||
emitter.emit('once')
|
||||
check('EVENT.once-reentry', onceCount === 1)
|
||||
const order = []
|
||||
const second = () => order.push('second')
|
||||
const later = () => order.push('later')
|
||||
emitter.on('order', () => { order.push('first'); emitter.off('order', second); emitter.on('order', later) }).on('order', second)
|
||||
emitter.emit('order').emit('order')
|
||||
report.observations.emitterOrder = order
|
||||
check('EVENT.dispatch-snapshot', JSON.stringify(order) === JSON.stringify(['first', 'second', 'first', 'later']))
|
||||
let removedOnce = 0
|
||||
const original = () => { removedOnce += 1 }
|
||||
emitter.once('removed', original).off('removed', original).emit('removed')
|
||||
check('EVENT.off-original-once', removedOnce === 0)
|
||||
const marker = new Error('expected emitter probe')
|
||||
let caught
|
||||
let afterThrow = 0
|
||||
emitter.on('throw', () => { throw marker }).on('throw', () => { afterThrow += 1 })
|
||||
try { emitter.emit('throw') } catch (error) { caught = error }
|
||||
check('EVENT.throw-propagation', caught === marker && afterThrow === 0)
|
||||
|
||||
phase = 'initial'
|
||||
let readyCount = 0
|
||||
let callbackArt
|
||||
let callbackContext
|
||||
const art = create({}, function (value) { readyCount += 1; callbackArt = value; callbackContext = this })
|
||||
await eventAfter(art, 'ready')
|
||||
check('LIFE.ready-callback', readyCount === 1 && callbackArt === art && callbackContext === art && art.isReady)
|
||||
check('MEDIA.metadata', art.duration > 2 && art.video.videoWidth > 0 && art.video.readyState >= 2)
|
||||
phase = 'play'
|
||||
const playStart = art.currentTime
|
||||
const playResult = art.play()
|
||||
check('MEDIA.play-promise', playResult instanceof Promise)
|
||||
check('MEDIA.play-result', await bounded(playResult, 'play') === undefined)
|
||||
const progressed = await advance(art, playStart)
|
||||
report.observations.playback = { start: playStart, progressed, duration: art.duration, width: art.video.videoWidth, height: art.video.videoHeight }
|
||||
check('MEDIA.time-advances', progressed > playStart && !art.video.paused)
|
||||
phase = 'pause'
|
||||
let pauseResult
|
||||
await eventAfter(art, 'video:pause', () => { pauseResult = art.pause() })
|
||||
const pausedAt = art.currentTime
|
||||
let maxDelta = 0
|
||||
const pausedWindow = await observeFrames(() => { maxDelta = Math.max(maxDelta, Math.abs(art.currentTime - pausedAt)) })
|
||||
report.observations.pause = { at: pausedAt, observedMs: pausedWindow, maxDelta }
|
||||
check('MEDIA.pause-sync-stable', pauseResult === undefined && art.video.paused && maxDelta < 0.02)
|
||||
phase = 'seek'
|
||||
const target = Math.min(2, art.duration / 3)
|
||||
await eventAfter(art, 'video:seeked', () => { art.seek = target })
|
||||
check('MEDIA.seek', Math.abs(art.currentTime - target) < 0.1)
|
||||
|
||||
phase = 'quality-paused'
|
||||
const qualityResult = await bounded(art.switchQuality('/assets/sample/video2.mp4'), 'paused quality switch')
|
||||
check('MEDIA.quality-preserves-time', qualityResult === undefined && art.video.paused && Math.abs(art.currentTime - target) < 0.1)
|
||||
phase = 'url-playing'
|
||||
await bounded(art.play(), 'resume')
|
||||
await advance(art, art.currentTime)
|
||||
const switchResult = await bounded(art.switchUrl('/assets/sample/video.mp4?normal-switch'), 'playing URL switch')
|
||||
check('MEDIA.url-resets-resumes', switchResult === undefined && !art.video.paused && art.currentTime < 1)
|
||||
await advance(art, art.currentTime)
|
||||
art.pause()
|
||||
check('LIFE.ready-once-after-switches', readyCount === 1)
|
||||
report.observations.readyCallbacks = readyCount
|
||||
report.observations.restarts = report.trace.filter(item => item.event === 'restart').map(item => item.argument)
|
||||
check('LIFE.restart-url', report.observations.restarts.includes('/assets/sample/video2.mp4') && report.observations.restarts.includes('/assets/sample/video.mp4?normal-switch'))
|
||||
|
||||
phase = 'controlled-play-rejection'
|
||||
const playError = new Error('controlled media play rejection')
|
||||
let publicRejection
|
||||
art.video.play = () => Promise.reject(playError)
|
||||
try { await bounded(art.play(), 'controlled public rejection') } catch (error) { publicRejection = error }
|
||||
finally { delete art.video.play }
|
||||
check('MEDIA.play-rejects-original', publicRejection === playError)
|
||||
phase = 'controlled-resume-rejection'
|
||||
await bounded(art.play(), 'play before rejected resume')
|
||||
await advance(art, art.currentTime)
|
||||
const resumeError = new Error('controlled switch resume rejection')
|
||||
expectedRejections.add(resumeError)
|
||||
let resumeSwitchState = 'pending'
|
||||
art.video.play = () => Promise.reject(resumeError)
|
||||
try {
|
||||
await eventAfter(art, 'video:canplay', () => {
|
||||
art.switchUrl('/assets/sample/video2.mp4?resume-rejection').then(() => { resumeSwitchState = 'fulfilled' }, () => { resumeSwitchState = 'rejected' })
|
||||
})
|
||||
const observedMs = await observeFrames(() => {})
|
||||
report.observations.rejectedResume = { mode: 'controlled video.play rejection after real canplay', switchState: resumeSwitchState, observedMs, expectedUnhandled: report.observations.expectedUnhandled.length }
|
||||
if (resumeSwitchState === 'pending' && report.observations.expectedUnhandled.length) report.findings.push({ id: 'BASE-LIFE-06', observation: 'Published switch leaves an unhandled rejection and an unsettled promise when resume fails', owner: 'CORE-09' })
|
||||
}
|
||||
finally { delete art.video.play }
|
||||
art.pause()
|
||||
|
||||
phase = 'plugins'
|
||||
let pluginContext
|
||||
let pluginArgument
|
||||
const pluginPromise = art.plugins.add(function (value) { pluginContext = this; pluginArgument = value; return Promise.resolve({ name: 'asyncProbe' }) })
|
||||
check('PLUGIN.async-promise', pluginPromise instanceof Promise && !Object.hasOwn(art.plugins, 'asyncProbe'))
|
||||
check('PLUGIN.async-registry', await bounded(pluginPromise, 'async plugin') === art.plugins && pluginContext === art && pluginArgument === art)
|
||||
let duplicate
|
||||
try { art.plugins.add(() => ({ name: 'asyncProbe' })) } catch (error) { duplicate = error }
|
||||
check('PLUGIN.duplicate-throws', duplicate instanceof Error && art.plugins.asyncProbe.name === 'asyncProbe')
|
||||
|
||||
phase = 'concurrent-switch'
|
||||
const first = art.switchUrl('/assets/sample/video.mp4?race-first')
|
||||
const last = art.switchUrl('/assets/sample/video2.mp4?race-last')
|
||||
const outcomes = await bounded(Promise.allSettled([first, last]), 'concurrent switches')
|
||||
report.observations.concurrentSwitch = { outcomes: outcomes.map(item => item.status), finalSource: art.video.getAttribute('src'), canplayEvents: report.trace.filter(item => item.phase === phase && item.event === 'video:canplay').length }
|
||||
check('MEDIA.latest-source', outcomes[1].status === 'fulfilled' && art.url.endsWith('video2.mp4?race-last'))
|
||||
if (outcomes[0].status === 'fulfilled') report.findings.push({ id: 'BASE-LIFE-01', observation: 'Superseded switch resolves along with the latest source', owner: 'CORE-09' })
|
||||
|
||||
phase = 'destroy-pending'
|
||||
let pendingState = 'pending'
|
||||
art.switchUrl('/assets/sample/video.mp4?destroy-pending').then(() => { pendingState = 'fulfilled' }, () => { pendingState = 'rejected' })
|
||||
let finishPlugin
|
||||
const latePlugin = art.plugins.add(() => new Promise(resolve => { finishPlugin = resolve }))
|
||||
let listenerCalls = 0
|
||||
art.proxy(art.video, 'baseline-listener', () => { listenerCalls += 1 })
|
||||
art.video.dispatchEvent(new Event('baseline-listener'))
|
||||
const retainedNode = art.template.$player
|
||||
art.destroy(false)
|
||||
art.video.dispatchEvent(new Event('baseline-listener'))
|
||||
check('LIFE.destroy-retains-markup', retainedNode.isConnected && retainedNode.classList.contains('art-destroy'))
|
||||
check('LIFE.destroy-cleans-proxy', listenerCalls === 1 && art.events.destroyEvents.size === 0 && !Artplayer.instances.includes(art) && art.isDestroy)
|
||||
finishPlugin({ name: 'lateProbe' })
|
||||
await bounded(latePlugin, 'late plugin')
|
||||
const pendingWindow = await observeFrames(() => {})
|
||||
report.observations.destroyPending = { switchState: pendingState, observedMs: pendingWindow, latePluginRegistered: Object.hasOwn(art.plugins, 'lateProbe') }
|
||||
if (pendingState === 'pending') report.findings.push({ id: 'BASE-LIFE-02', observation: 'Switch remains unsettled during the bounded observation after destroy', owner: 'CORE-09' })
|
||||
if (report.observations.destroyPending.latePluginRegistered) report.findings.push({ id: 'BASE-LIFE-03', observation: 'Async plugin registers its result after destroy', owner: 'CORE-08' })
|
||||
|
||||
phase = 'duplicate-destroy'
|
||||
const removed = create()
|
||||
const survivor = create()
|
||||
removed.destroy(true)
|
||||
check('LIFE.destroy-removes-markup', removed.template.$container.childElementCount === 0)
|
||||
const survivorBefore = Artplayer.instances.includes(survivor)
|
||||
removed.destroy(true)
|
||||
const survivorAfter = Artplayer.instances.includes(survivor)
|
||||
report.observations.repeatedDestroy = { survivorBefore, survivorAfter }
|
||||
if (survivorBefore && !survivorAfter) report.findings.push({ id: 'BASE-LIFE-04', observation: 'Destroying an instance twice unregisters a different live instance', owner: 'CORE-04' })
|
||||
survivor.destroy(true)
|
||||
|
||||
phase = 'constructor-failure'
|
||||
let partial
|
||||
const failedContainer = document.createElement('div')
|
||||
document.getElementById('players').append(failedContainer)
|
||||
let initializationError
|
||||
try {
|
||||
new Artplayer({ container: failedContainer, url: '/assets/sample/video.mp4', muted: true, plugins: [value => { partial = value; throw new Error('expected initialization probe') }] })
|
||||
}
|
||||
catch (error) { initializationError = error }
|
||||
check('LIFE.constructor-plugin-error', initializationError?.message === 'expected initialization probe')
|
||||
report.observations.failedConstructor = { hasMarkup: Boolean(failedContainer.querySelector('.art-video-player')), listeners: partial?.events.destroyEvents.size, registered: Artplayer.instances.includes(partial) }
|
||||
if (partial) { players.push(partial); partial.destroy(true) }
|
||||
if (report.observations.failedConstructor.hasMarkup || report.observations.failedConstructor.listeners) report.findings.push({ id: 'BASE-LIFE-05', observation: 'Failed constructor leaves initialized markup/listeners until explicit probe cleanup', owner: 'CORE-04' })
|
||||
}
|
||||
catch (error) { report.errors.push({ phase, message: error.message }) }
|
||||
finally {
|
||||
for (const art of players) {
|
||||
if (!art.isDestroy) {
|
||||
try { art.destroy(true) } catch (error) { report.errors.push({ phase: 'cleanup', message: error.message }) }
|
||||
}
|
||||
}
|
||||
window.removeEventListener('unhandledrejection', unhandled)
|
||||
}
|
||||
report.observations.remainingInstances = Artplayer.instances.length
|
||||
const response = await fetch('/reports/lifecycle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(report) })
|
||||
document.getElementById('status').textContent = response.ok && !report.errors.length && !report.unhandled.length && report.checks.every(item => item.passed) ? `CAPTURE COMPLETE: ${report.checks.length} checks, ${report.findings.length} historical findings` : 'CAPTURE FAILED: inspect report'
|
||||
document.getElementById('result').textContent = JSON.stringify({ checks: report.checks, observations: report.observations, findings: report.findings, errors: report.errors, unhandled: report.unhandled }, null, 2)
|
||||
})
|
||||
+6
-5
@@ -4,7 +4,7 @@
|
||||
|
||||
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
|
||||
|
||||
状态:todo 195 / doing 0 / blocked 0 / done 19 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
状态:todo 194 / doing 0 / blocked 0 / done 20 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
|
||||
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
| BASE-01 | workspace<br>固定核心与试点的最小发布基线 | DOC-07 | 核心及 chapter 的 npm tarball/integrity、源码 SHA、已知支持窗口;其他包标待核对 | 核心/试点来源可重跑;不虚构全生态已验证,各包 01 步负责补齐自身历史发布基线 | H | done |
|
||||
| BASE-HARNESS-01 | workspace<br>建立最小发布包浏览器采集夹具与服务 | BASE-01 | 固定 tarball 静态服务、本地媒体 Range、公开 API 采集页面及受控报告路径 | HTTP 精确内容/范围和路由隔离测试、夹具语法通过;浏览器采集仍由 BASE-02 负责 | M | done |
|
||||
| BASE-02 | workspace<br>捕获公共 API 和属性描述符 | BASE-01, BASE-HARNESS-01 | 核心与试点的构造/默认值/静态和实例 API 快照,其余包由 01/02 步补齐;登记契约/版本/固定测试 ID/命令/报告与负责任务 | 真实发布包反射与旧用户调用可重跑,未验证项明确 | H | done |
|
||||
| BASE-03 | workspace<br>捕获事件、异步和生命周期 | BASE-02 | ready/restart/destroy、Promise、切源/插件事件 trace;登记契约/版本/固定测试 ID/命令/报告与负责任务 | 成功、失败、重入和多实例有断言,历史缺陷有标记;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务 | H | todo |
|
||||
| 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 | todo |
|
||||
| BASE-05 | workspace<br>捕获包入口、资源和类型消费 | BASE-01 | 全包 manifest/资源路径清单;核心/试点的 UMD/AMD/ESM/CJS/legacy/i18n 和类型消费者基线;登记契约/版本/固定测试 ID/命令/报告与负责任务;区分库/npm 与站点实际分发类别,SSR import/模板/非浏览器构造错误分别捕获 | 核心/试点隔离消费可运行,其他包由各自契约任务验证,thumbnail tool 差异已登记 | H | todo |
|
||||
| BASE-06 | workspace<br>记录性能与资源基线 | BASE-03, BASE-04 | 固定媒体/设备测量、包体积、反复装卸资源报告 | 多次采样可重跑,指标和审查阈值确定,不承诺未经测量的收益;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务 | M | todo |
|
||||
@@ -107,12 +107,12 @@
|
||||
| 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、多实例和销毁中异步有真实回归 | 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-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 的旧新类型对照 | 每项有兼容重载/扩展或待决结论,不能删除旧类型样例 | H | todo |
|
||||
| CORE-08 | artplayer<br>整理插件管理器与扩展类型 | CORE-03, CORE-07 | 注册流程、同步/异步结果、插件/事件扩展接口 | 旧插件同步可见性、重名与命名、销毁中异步插件保持明确语义 | H | todo |
|
||||
| CORE-09 | artplayer<br>整理 URL 与切源操作 | CORE-04, CORE-06, CORE-08 | url/switch/customType 的操作身份及取消方案 | 并发切源、错误、同 URL、销毁和 Promise 结算有契约 | H | todo |
|
||||
| CORE-08 | artplayer<br>整理插件管理器与扩展类型 | CORE-03, CORE-07 | 注册流程、同步/异步结果、插件/事件扩展接口 | 旧插件同步可见性、重名与命名、销毁中异步插件保持明确语义;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-03 并增加候选正向回归 | H | todo |
|
||||
| CORE-09 | artplayer<br>整理 URL 与切源操作 | CORE-04, CORE-06, CORE-08 | url/switch/customType 的操作身份及取消方案 | 并发切源、错误、同 URL、销毁和 Promise 结算有契约;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-01/02/06 并增加候选正向回归 | H | todo |
|
||||
| CORE-10 | artplayer<br>迁移播放与时间/状态属性 | CORE-09 | play/pause/toggle/seek/volume/rate/duration 等模块 | 旧返回值、拒绝、mutex、位置恢复、属性描述符与事件通过 | H | todo |
|
||||
| CORE-11 | artplayer<br>整理媒体事件、ready 与重连 | CORE-10 | 媒体转发、重连、UI 响应职责分离 | readiness 次数/顺序、过期重连、原错误参数有回归 | H | todo |
|
||||
| CORE-12 | artplayer<br>迁移模板及公开资源入口 | CORE-06, CORE-04, BASE-04 | template/icons/i18n/style、SSR 与 proxy 挂载 | DOM/CSS/global/样式注入、i18n 子路径及 SSR import 保持;不把 SSR import/useSSR 保持扩大为服务端构造播放器 | H | todo |
|
||||
@@ -419,6 +419,7 @@
|
||||
- BASE-01: [记录](changes/2026-09-10-BASE-01-published-baseline.md) [记录](baselines/releases.json)
|
||||
- BASE-HARNESS-01: [记录](changes/2026-09-10-BASE-HARNESS-01-browser-fixture.md)
|
||||
- BASE-02: [记录](changes/2026-09-10-BASE-02-public-api.md) [记录](baselines/public-api.json) [记录](baselines/api-coverage.md)
|
||||
- BASE-03: [记录](changes/2026-09-10-BASE-03-lifecycle.md) [记录](baselines/lifecycle.json) [记录](baselines/lifecycle-coverage.md)
|
||||
- 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-03 生命周期基线完成
|
||||
|
||||
固定发布包在内置浏览器最终两次执行 27 项断言、真实媒体时间推进与关键事件顺序均通过,语义观察相同,保存 64 条事件 trace 和六项历史问题。受控恢复拒绝单独登记,其余 errors/unhandled 为空;无把历史缺陷当作正常兼容要求。见 [BASE-03](changes/2026-09-10-BASE-03-lifecycle.md) 和 [问题/覆盖台账](baselines/lifecycle-coverage.md)。CORE-04/08/09 验收已链接相应复现。
|
||||
|
||||
当前 214 项任务,20 完成、194 待办。BASE-02 已提交 b382ec08;本轮独立提交 BASE-03。没有生产 TS 迁移、推送或发布。下一任务 BASE-04:DOM/CSS 与基础交互基线。
|
||||
|
||||
## 当前实施:BASE-02 公共 API 基线完成
|
||||
|
||||
内置浏览器最终两次执行固定 core 5.4.0/chapter 1.1.0 的 14 项同步 API 断言,快照相同,error/warn 日志为空。已保存静态配置、默认选项、实例及继承 API、10 个子系统的描述符和来源指纹;比较器能拒绝描述符/方法/默认值等回归。见 [BASE-02](changes/2026-09-10-BASE-02-public-api.md) 与 [契约覆盖](baselines/api-coverage.md)。
|
||||
|
||||
@@ -12,10 +12,7 @@ export const checkIds = [
|
||||
'API-05.destroy-return', 'API-05.instance-cleanup',
|
||||
]
|
||||
|
||||
export function validateApiReport(report) {
|
||||
assert.equal(report.kind, 'public-api')
|
||||
assert.deepEqual(report.errors, [], 'Browser capture failed')
|
||||
assert.deepEqual(report.checks, checkIds.map(id => ({ id, passed: true })), 'Missing or failed API checks')
|
||||
export function validatePublishedCapture(report, fixture) {
|
||||
assert(report.environment.userAgent && report.environment.language, 'Missing browser environment')
|
||||
assert(Number.isFinite(Date.parse(report.capture.capturedAt)), 'Missing capture timestamp')
|
||||
const releases = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/releases.json'), 'utf8')).releases
|
||||
@@ -24,12 +21,19 @@ export function validateApiReport(report) {
|
||||
return { name: release.name, version: release.version, integrity: release.integrity, member, sha256: release.files[member] }
|
||||
})
|
||||
assert.deepEqual(report.capture.releases, expectedSources, 'Published sources differ')
|
||||
assert.deepEqual(report.scripts, [...expectedSources.map(release => `/releases/${release.name}/${release.member.slice('package/'.length)}`), '/fixtures/api.js'], 'Unexpected loaded scripts')
|
||||
assert.deepEqual(report.scripts, [...expectedSources.map(release => `/releases/${release.name}/${release.member.slice('package/'.length)}`), `/fixtures/${fixture}.js`], 'Unexpected loaded scripts')
|
||||
assert.equal(report.capture.fixtureHashAlgorithm, 'sha256-lf')
|
||||
for (const name of ['api.html', 'api.js']) {
|
||||
for (const name of [`${fixture}.html`, `${fixture}.js`]) {
|
||||
const source = fs.readFileSync(path.join(refactorDir, 'fixtures', name), 'utf8').replaceAll('\r\n', '\n')
|
||||
assert.equal(report.capture.fixtures[name], hash(source), `Fixture changed: ${name}; review the baseline explicitly`)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateApiReport(report) {
|
||||
assert.equal(report.kind, 'public-api')
|
||||
assert.deepEqual(report.errors, [], 'Browser capture failed')
|
||||
assert.deepEqual(report.checks, checkIds.map(id => ({ id, passed: true })), 'Missing or failed API checks')
|
||||
validatePublishedCapture(report, 'api')
|
||||
for (const section of ['static', 'prototype', 'emitterPrototype', 'defaults', 'constants', 'config', 'utilityDescriptors', 'instance', 'instanceResolved', 'chapterFactory', 'chapterResult', 'integrations']) {
|
||||
assert(report.snapshot?.[section] && Object.keys(report.snapshot[section]).length, `Missing API section: ${section}`)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ 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}`)
|
||||
if (req.method === 'POST' && url.pathname === '/reports/api') {
|
||||
const reportName = { '/reports/api': 'api', '/reports/lifecycle': 'lifecycle' }[url.pathname]
|
||||
if (req.method === 'POST' && reportName) {
|
||||
assert.equal(req.headers.origin, `http://127.0.0.1:${port}`, 'Unexpected report origin')
|
||||
const chunks = []
|
||||
let size = 0
|
||||
@@ -47,7 +48,7 @@ const server = http.createServer(async (req, res) => {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
const report = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
assert.equal(report.kind, 'public-api')
|
||||
assert.equal(report.kind, reportName === 'api' ? 'public-api' : 'lifecycle')
|
||||
report.capture = {
|
||||
capturedAt: new Date().toISOString(),
|
||||
releases: baseline.releases.map(release => ({
|
||||
@@ -56,11 +57,14 @@ const server = http.createServer(async (req, res) => {
|
||||
sha256: release.files[`package/${release.manifest.main.replace(/^\.\//, '')}`],
|
||||
})),
|
||||
fixtureHashAlgorithm: 'sha256-lf',
|
||||
fixtures: Object.fromEntries(['api.html', 'api.js'].map(name => [name, hash(fs.readFileSync(path.join(refactorDir, 'fixtures', name), 'utf8').replaceAll('\r\n', '\n'))])),
|
||||
fixtures: Object.fromEntries([`${reportName}.html`, `${reportName}.js`].map(name => [name, hash(fs.readFileSync(path.join(refactorDir, 'fixtures', name), 'utf8').replaceAll('\r\n', '\n'))])),
|
||||
}
|
||||
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)))]))
|
||||
}
|
||||
const output = path.join(refactorDir, '.cache/reports')
|
||||
fs.mkdirSync(output, { recursive: true })
|
||||
fs.writeFileSync(path.join(output, 'api.json'), `${JSON.stringify(report, null, 2)}\n`)
|
||||
fs.writeFileSync(path.join(output, `${reportName}.json`), `${JSON.stringify(report, null, 2)}\n`)
|
||||
res.writeHead(204).end()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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 lifecycleChecks = [
|
||||
'LIFE.version', 'EVENT.chain', 'EVENT.context-arguments', 'EVENT.once-reentry',
|
||||
'EVENT.dispatch-snapshot', 'EVENT.off-original-once', 'EVENT.throw-propagation',
|
||||
'LIFE.ready-callback', 'MEDIA.metadata', 'MEDIA.play-promise', 'MEDIA.play-result',
|
||||
'MEDIA.time-advances', 'MEDIA.pause-sync-stable', 'MEDIA.seek',
|
||||
'MEDIA.quality-preserves-time', 'MEDIA.url-resets-resumes',
|
||||
'LIFE.ready-once-after-switches', 'LIFE.restart-url', 'MEDIA.play-rejects-original',
|
||||
'PLUGIN.async-promise', 'PLUGIN.async-registry', 'PLUGIN.duplicate-throws',
|
||||
'MEDIA.latest-source', 'LIFE.destroy-retains-markup', 'LIFE.destroy-cleans-proxy',
|
||||
'LIFE.destroy-removes-markup', 'LIFE.constructor-plugin-error',
|
||||
]
|
||||
|
||||
export function validateLifecycleReport(report) {
|
||||
assert.equal(report.kind, 'lifecycle')
|
||||
assert.deepEqual(report.errors, [], 'Lifecycle capture failed')
|
||||
assert.deepEqual(report.unhandled, [], 'Unexpected unhandled rejection')
|
||||
assert.deepEqual(report.checks, lifecycleChecks.map(id => ({ id, passed: true })), 'Missing or failed lifecycle checks')
|
||||
validatePublishedCapture(report, 'lifecycle')
|
||||
for (const name of ['video.mp4', 'video2.mp4']) {
|
||||
assert.equal(report.capture.media[name], hash(fs.readFileSync(path.resolve(refactorDir, '../docs/assets/sample', name))), `Media fixture changed: ${name}`)
|
||||
}
|
||||
const o = report.observations
|
||||
assert(o.playback.progressed >= o.playback.start + 0.1 && o.playback.width > 0 && o.playback.duration > 2, 'No real-media advancement evidence')
|
||||
assert(o.pause.observedMs >= 250 && o.pause.maxDelta < 0.02, 'No stable pause evidence')
|
||||
assert.equal(o.readyCallbacks, 1, 'Ready callback repeated')
|
||||
assert.equal(o.remainingInstances, 0, 'Probe leaked registered instances')
|
||||
const order = (phase, earlier, later) => {
|
||||
const events = report.trace.filter(item => item.phase === phase).map(item => item.event)
|
||||
assert(events.indexOf(earlier) >= 0 && events.indexOf(earlier) < events.indexOf(later), `Unexpected ${phase} order: ${earlier} -> ${later}`)
|
||||
}
|
||||
order('initial', 'video:loadedmetadata', 'ready')
|
||||
order('play', 'video:play', 'video:playing')
|
||||
order('play', 'video:playing', 'play')
|
||||
order('pause', 'pause', 'video:pause')
|
||||
order('quality-paused', 'video:canplay', 'restart')
|
||||
for (const item of o.expectedUnhandled) {
|
||||
assert.deepEqual(item, { phase: 'controlled-resume-rejection', message: 'controlled switch resume rejection' }, 'Unexpected controlled rejection')
|
||||
}
|
||||
const tasks = JSON.parse(fs.readFileSync(path.join(refactorDir, 'tasks.json'), 'utf8')).tasks
|
||||
assert.equal(new Set(report.findings.map(item => item.id)).size, report.findings.length, 'Duplicate finding IDs')
|
||||
for (const finding of report.findings) assert(tasks.some(task => task.id === finding.owner), `Unknown finding owner: ${finding.owner}`)
|
||||
}
|
||||
|
||||
export function lifecycleProfile(report) {
|
||||
const o = report.observations
|
||||
return {
|
||||
checks: report.checks,
|
||||
emitterOrder: o.emitterOrder,
|
||||
readyCallbacks: o.readyCallbacks,
|
||||
restarts: o.restarts,
|
||||
concurrentSwitch: { outcomes: o.concurrentSwitch.outcomes, finalSource: o.concurrentSwitch.finalSource },
|
||||
destroyPending: { switchState: o.destroyPending.switchState, latePluginRegistered: o.destroyPending.latePluginRegistered },
|
||||
repeatedDestroy: o.repeatedDestroy,
|
||||
failedConstructor: { hasMarkup: o.failedConstructor.hasMarkup, retainedListeners: o.failedConstructor.listeners > 0, registered: o.failedConstructor.registered },
|
||||
rejectedResume: { switchState: o.rejectedResume.switchState, expectedUnhandled: o.rejectedResume.expectedUnhandled },
|
||||
findings: report.findings.map(({ id, owner }) => ({ id, owner })).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyFrozenLifecycle() {
|
||||
const frozen = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/lifecycle.json'), 'utf8'))
|
||||
assert.equal(frozen.schemaVersion, 1)
|
||||
validateLifecycleReport(frozen.report)
|
||||
assert.equal(hash(JSON.stringify(lifecycleProfile(frozen.report))), frozen.verification.profileSha256, 'Frozen lifecycle profile changed')
|
||||
return frozen
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const frozen = verifyFrozenLifecycle()
|
||||
const index = process.argv.indexOf('--compare')
|
||||
if (index !== -1) {
|
||||
assert(process.argv[index + 1], '--compare requires a report path')
|
||||
const captured = JSON.parse(fs.readFileSync(process.argv[index + 1], 'utf8'))
|
||||
validateLifecycleReport(captured)
|
||||
assert.deepEqual(lifecycleProfile(captured), lifecycleProfile(frozen.report), 'Published lifecycle profile differs')
|
||||
console.log('Published lifecycle capture matches semantic baseline; historical findings remain recorded')
|
||||
}
|
||||
else {
|
||||
assert(process.argv.slice(2).every(arg => arg === '--check'), 'Use --check or --compare <report>')
|
||||
console.log(`Frozen lifecycle report verified: ${lifecycleChecks.length} checks; this command does not rerun the browser`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { lifecycleProfile, validateLifecycleReport, verifyFrozenLifecycle } from './lifecycle.mjs'
|
||||
|
||||
test('lifecycle evidence rejects fake progress, unexpected errors and event reordering', () => {
|
||||
const { report } = verifyFrozenLifecycle()
|
||||
const stopped = structuredClone(report)
|
||||
stopped.observations.playback.progressed = stopped.observations.playback.start
|
||||
assert.throws(() => validateLifecycleReport(stopped), /No real-media advancement/)
|
||||
const error = structuredClone(report)
|
||||
error.unhandled.push('unexpected error')
|
||||
assert.throws(() => validateLifecycleReport(error), /Unexpected unhandled/)
|
||||
const reordered = structuredClone(report)
|
||||
const phase = reordered.trace.filter(item => item.phase === 'play')
|
||||
const native = phase.find(item => item.event === 'video:playing')
|
||||
const publicEvent = phase.find(item => item.event === 'play')
|
||||
native.event = 'play'
|
||||
publicEvent.event = 'video:playing'
|
||||
assert.throws(() => validateLifecycleReport(reordered), /Unexpected play order/)
|
||||
})
|
||||
|
||||
test('lifecycle profile ignores media clock jitter but retains lifecycle findings', () => {
|
||||
const { report } = verifyFrozenLifecycle()
|
||||
const jitter = structuredClone(report)
|
||||
jitter.observations.playback.progressed += 0.03
|
||||
jitter.observations.pause.observedMs += 10
|
||||
assert.deepEqual(lifecycleProfile(jitter), lifecycleProfile(report))
|
||||
const changed = structuredClone(report)
|
||||
changed.observations.repeatedDestroy.survivorAfter = true
|
||||
assert.notDeepEqual(lifecycleProfile(changed), lifecycleProfile(report))
|
||||
})
|
||||
+9
-5
@@ -305,11 +305,15 @@
|
||||
"dependsOn": [
|
||||
"BASE-02"
|
||||
],
|
||||
"status": "todo",
|
||||
"status": "done",
|
||||
"risk": "H",
|
||||
"deliverable": "ready/restart/destroy、Promise、切源/插件事件 trace;登记契约/版本/固定测试 ID/命令/报告与负责任务",
|
||||
"acceptance": "成功、失败、重入和多实例有断言,历史缺陷有标记;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务",
|
||||
"evidence": []
|
||||
"evidence": [
|
||||
"changes/2026-09-10-BASE-03-lifecycle.md",
|
||||
"baselines/lifecycle.json",
|
||||
"baselines/lifecycle-coverage.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "BASE-04",
|
||||
@@ -754,7 +758,7 @@
|
||||
"status": "todo",
|
||||
"risk": "H",
|
||||
"deliverable": "构造失败回收、重复/重入 destroy、instances 修复",
|
||||
"acceptance": "事件顺序、removeHtml、多实例和销毁中异步有真实回归",
|
||||
"acceptance": "事件顺序、removeHtml、多实例和销毁中异步有真实回归;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-04/05 并增加候选正向回归",
|
||||
"evidence": []
|
||||
},
|
||||
{
|
||||
@@ -822,7 +826,7 @@
|
||||
"status": "todo",
|
||||
"risk": "H",
|
||||
"deliverable": "注册流程、同步/异步结果、插件/事件扩展接口",
|
||||
"acceptance": "旧插件同步可见性、重名与命名、销毁中异步插件保持明确语义",
|
||||
"acceptance": "旧插件同步可见性、重名与命名、销毁中异步插件保持明确语义;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-03 并增加候选正向回归",
|
||||
"evidence": []
|
||||
},
|
||||
{
|
||||
@@ -840,7 +844,7 @@
|
||||
"status": "todo",
|
||||
"risk": "H",
|
||||
"deliverable": "url/switch/customType 的操作身份及取消方案",
|
||||
"acceptance": "并发切源、错误、同 URL、销毁和 Promise 结算有契约",
|
||||
"acceptance": "并发切源、错误、同 URL、销毁和 Promise 结算有契约;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-01/02/06 并增加候选正向回归",
|
||||
"evidence": []
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user