diff --git a/refactor/baselines/README.md b/refactor/baselines/README.md index fde0ffcd5..198df2d5a 100644 --- a/refactor/baselines/README.md +++ b/refactor/baselines/README.md @@ -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)。受控拒绝与真正媒体播放分别标注,不代替全环境验收。 diff --git a/refactor/baselines/api-coverage.md b/refactor/baselines/api-coverage.md index 624f71215..824e0555b 100644 --- a/refactor/baselines/api-coverage.md +++ b/refactor/baselines/api-coverage.md @@ -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);上表的后续责任仍按对应细项处理,不自动宣称整个契约通过。 diff --git a/refactor/baselines/lifecycle-coverage.md b/refactor/baselines/lifecycle-coverage.md new file mode 100644 index 000000000..0d9848a33 --- /dev/null +++ b/refactor/baselines/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 接续。 diff --git a/refactor/baselines/lifecycle.json b/refactor/baselines/lifecycle.json new file mode 100644 index 000000000..3d8aef2d2 --- /dev/null +++ b/refactor/baselines/lifecycle.json @@ -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" + } +} diff --git a/refactor/changes/2026-09-10-BASE-03-lifecycle.md b/refactor/changes/2026-09-10-BASE-03-lifecycle.md new file mode 100644 index 000000000..347498461 --- /dev/null +++ b/refactor/changes/2026-09-10-BASE-03-lifecycle.md @@ -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 与基础交互,再推进试点所需的类型/分发/测试基础。 diff --git a/refactor/fixtures/README.md b/refactor/fixtures/README.md index 40d3b5745..f438990d7 100644 --- a/refactor/fixtures/README.md +++ b/refactor/fixtures/README.md @@ -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。 diff --git a/refactor/fixtures/lifecycle.html b/refactor/fixtures/lifecycle.html new file mode 100644 index 000000000..115035933 --- /dev/null +++ b/refactor/fixtures/lifecycle.html @@ -0,0 +1,15 @@ + + + +ArtPlayer published lifecycle baseline + +

Published lifecycle baseline

+

This checks real muted playback and records historical lifecycle findings.

+ +

Ready to run

+
+

+
+
+
+
diff --git a/refactor/fixtures/lifecycle.js b/refactor/fixtures/lifecycle.js
new file mode 100644
index 000000000..9dd157b70
--- /dev/null
+++ b/refactor/fixtures/lifecycle.js
@@ -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)
+})
diff --git a/refactor/plan.md b/refactor/plan.md
index 919a97010..fd5a1447f 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -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
固定核心与试点的最小发布基线 | DOC-07 | 核心及 chapter 的 npm tarball/integrity、源码 SHA、已知支持窗口;其他包标待核对 | 核心/试点来源可重跑;不虚构全生态已验证,各包 01 步负责补齐自身历史发布基线 | H | done | | BASE-HARNESS-01 | workspace
建立最小发布包浏览器采集夹具与服务 | BASE-01 | 固定 tarball 静态服务、本地媒体 Range、公开 API 采集页面及受控报告路径 | HTTP 精确内容/范围和路由隔离测试、夹具语法通过;浏览器采集仍由 BASE-02 负责 | M | done | | BASE-02 | workspace
捕获公共 API 和属性描述符 | BASE-01, BASE-HARNESS-01 | 核心与试点的构造/默认值/静态和实例 API 快照,其余包由 01/02 步补齐;登记契约/版本/固定测试 ID/命令/报告与负责任务 | 真实发布包反射与旧用户调用可重跑,未验证项明确 | H | done | -| BASE-03 | workspace
捕获事件、异步和生命周期 | BASE-02 | ready/restart/destroy、Promise、切源/插件事件 trace;登记契约/版本/固定测试 ID/命令/报告与负责任务 | 成功、失败、重入和多实例有断言,历史缺陷有标记;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务 | H | todo | +| BASE-03 | workspace
捕获事件、异步和生命周期 | BASE-02 | ready/restart/destroy、Promise、切源/插件事件 trace;登记契约/版本/固定测试 ID/命令/报告与负责任务 | 成功、失败、重入和多实例有断言,历史缺陷有标记;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务 | H | done | | BASE-04 | workspace
捕获 DOM、CSS 和官方 demo | BASE-02 | 核心模板/类名/变量、键盘/焦点/可访问名称基线及全 demo 路径映射;docs-browser-testing.md 中 HTML/编辑器的脚本来源与存储状态;登记契约/版本/固定测试 ID/命令/报告与负责任务 | 核心/试点用户样式和输入用法已记录,其他 demo 运行状态与未知项明确;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务 | M | todo | | BASE-05 | workspace
捕获包入口、资源和类型消费 | BASE-01 | 全包 manifest/资源路径清单;核心/试点的 UMD/AMD/ESM/CJS/legacy/i18n 和类型消费者基线;登记契约/版本/固定测试 ID/命令/报告与负责任务;区分库/npm 与站点实际分发类别,SSR import/模板/非浏览器构造错误分别捕获 | 核心/试点隔离消费可运行,其他包由各自契约任务验证,thumbnail tool 差异已登记 | H | todo | | BASE-06 | workspace
记录性能与资源基线 | BASE-03, BASE-04 | 固定媒体/设备测量、包体积、反复装卸资源报告 | 多次采样可重跑,指标和审查阈值确定,不承诺未经测量的收益;使用已发布包和最小可重跑探针启动,不等待后续 ENG 服务 | M | todo | @@ -107,12 +107,12 @@ | CORE-01 | artplayer
迁移纯工具并冻结导出 | ENG-03, ENG-04, ENG-06, PILOT-01 | utils/time/property/format/file/error/subtitle 的分批 TS 迁移 | Artplayer.utils 导出/参数/绑定保持,纯逻辑与类型测试通过 | M | todo | | CORE-02 | artplayer
迁移内部 Emitter | CORE-01, BASE-03 | 带类型事件映射的原 Emitter 实现 | ctx、once/off、重入/异常和链式返回保持 | H | todo | | CORE-03 | artplayer
建立内部资源作用域 | CORE-02 | 监听/订阅/定时器/RAF/请求/URL 的资源登记 | 实例与操作作用域分离,单项清理失败不阻断其他资源 | H | todo | -| CORE-04 | artplayer
修复初始化与销毁生命周期 | CORE-03, ENG-05, BASE-07 | 构造失败回收、重复/重入 destroy、instances 修复 | 事件顺序、removeHtml、多实例和销毁中异步有真实回归 | H | todo | +| CORE-04 | artplayer
修复初始化与销毁生命周期 | CORE-03, ENG-05, BASE-07 | 构造失败回收、重复/重入 destroy、instances 修复 | 事件顺序、removeHtml、多实例和销毁中异步有真实回归;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-04/05 并增加候选正向回归 | H | todo | | CORE-05 | artplayer
区分输入配置和内部配置 | CORE-01, ENG-04 | Option/ResolvedOption、默认值与校验整理 | 配置合并/错误时机不变,JS 输入仍运行时校验 | M | todo | | CORE-06 | artplayer
建立内部媒体与宿主类型 | CORE-05 | 原生 video、canvas shim、UI host 类型 | 不更改公开 art.video 用法,不以 HTMLVideoElement 断言掩盖 shim | M | todo | | CORE-07 | artplayer
逐项协调公开声明差异 | CORE-02, CORE-06, BASE-07 | plugins/toggle/cue/setting/static 的旧新类型对照 | 每项有兼容重载/扩展或待决结论,不能删除旧类型样例 | H | todo | -| CORE-08 | artplayer
整理插件管理器与扩展类型 | CORE-03, CORE-07 | 注册流程、同步/异步结果、插件/事件扩展接口 | 旧插件同步可见性、重名与命名、销毁中异步插件保持明确语义 | H | todo | -| CORE-09 | artplayer
整理 URL 与切源操作 | CORE-04, CORE-06, CORE-08 | url/switch/customType 的操作身份及取消方案 | 并发切源、错误、同 URL、销毁和 Promise 结算有契约 | H | todo | +| CORE-08 | artplayer
整理插件管理器与扩展类型 | CORE-03, CORE-07 | 注册流程、同步/异步结果、插件/事件扩展接口 | 旧插件同步可见性、重名与命名、销毁中异步插件保持明确语义;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-03 并增加候选正向回归 | H | todo | +| CORE-09 | artplayer
整理 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
迁移播放与时间/状态属性 | CORE-09 | play/pause/toggle/seek/volume/rate/duration 等模块 | 旧返回值、拒绝、mutex、位置恢复、属性描述符与事件通过 | H | todo | | CORE-11 | artplayer
整理媒体事件、ready 与重连 | CORE-10 | 媒体转发、重连、UI 响应职责分离 | readiness 次数/顺序、过期重连、原错误参数有回归 | H | todo | | CORE-12 | artplayer
迁移模板及公开资源入口 | 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) diff --git a/refactor/progress.md b/refactor/progress.md index f72c07929..b6a8ce5a0 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -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)。 diff --git a/refactor/scripts/api.mjs b/refactor/scripts/api.mjs index 9d0f388f2..2d1581e34 100644 --- a/refactor/scripts/api.mjs +++ b/refactor/scripts/api.mjs @@ -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}`) } diff --git a/refactor/scripts/browser-server.mjs b/refactor/scripts/browser-server.mjs index 8deed93ae..5dc956308 100644 --- a/refactor/scripts/browser-server.mjs +++ b/refactor/scripts/browser-server.mjs @@ -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 } diff --git a/refactor/scripts/lifecycle.mjs b/refactor/scripts/lifecycle.mjs new file mode 100644 index 000000000..0fc3101fb --- /dev/null +++ b/refactor/scripts/lifecycle.mjs @@ -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 ') + console.log(`Frozen lifecycle report verified: ${lifecycleChecks.length} checks; this command does not rerun the browser`) + } +} diff --git a/refactor/scripts/lifecycle.test.mjs b/refactor/scripts/lifecycle.test.mjs new file mode 100644 index 000000000..53efdd529 --- /dev/null +++ b/refactor/scripts/lifecycle.test.mjs @@ -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)) +}) diff --git a/refactor/tasks.json b/refactor/tasks.json index dfa804276..87fb47d3a 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -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": [] }, {