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 @@ + + + +
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