From c723c70251222ae73c1af6f6acee589f2cd6545a Mon Sep 17 00:00:00 2001 From: Harvey Zhao Date: Sun, 13 Sep 2026 16:42:46 +0800 Subject: [PATCH] test(chromecast): [PKG-CAST-02] reproduce SDK and lifecycle failures --- package.json | 4 +- .../chromecast-failures-validation.json | 63 +++++ .../2026-09-13-PKG-CAST-02-failures.md | 31 +++ refactor/plan.md | 5 +- refactor/progress.md | 11 + refactor/risk-table.md | 5 + refactor/risks.json | 80 ++++++ refactor/tasks.json | 7 +- test/chromecast-failures.test.js | 250 ++++++++++++++++++ 9 files changed, 450 insertions(+), 6 deletions(-) create mode 100644 refactor/baselines/chromecast-failures-validation.json create mode 100644 refactor/changes/2026-09-13-PKG-CAST-02-failures.md create mode 100644 test/chromecast-failures.test.js diff --git a/package.json b/package.json index c72353797..5f9d7b7b4 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports", "test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js test/asr-distribution.test.js", "typecheck": "node scripts/typecheck.mjs", - "test:unit": "node --test test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js", + "test:unit": "node --test test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js", "test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs", "test": "yarn test:node && yarn test:baseline", "test:browser": "playwright test", @@ -97,7 +97,7 @@ "test:asr": "node --test test/asr.test.js test/asr-audio.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-distribution.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js", "test:asr-types-package": "node refactor/scripts/asr-package-types.mjs", "test:asr-demo": "node refactor/scripts/asr-local-demo.mjs", - "test:chromecast": "node --test test/chromecast.test.js" + "test:chromecast": "node --test test/chromecast.test.js test/chromecast-failures.test.js" }, "browserslist": "last 1 Chrome version", "devDependencies": { diff --git a/refactor/baselines/chromecast-failures-validation.json b/refactor/baselines/chromecast-failures-validation.json new file mode 100644 index 000000000..1556aaab5 --- /dev/null +++ b/refactor/baselines/chromecast-failures-validation.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 1, + "task": "PKG-CAST-02", + "capturedAt": "2026-09-13T08:39:20.323Z", + "sourceCommit": "146b78d5767bba88612184869155a39317794537", + "tests": { + "passed": 93, + "public": 28, + "additional": 65, + "failed": 0, + "skipped": 0, + "cancelled": 0, + "file": "refactor/.cache/chromecast02-tests.log", + "sha256": "47c5203986e2dbabd372fe27349d986bde52e77be1cae4462b7c827d73b2a466" + }, + "inputs": { + "test/helpers/chromecast.js": "a51ad278dffde582c00d66cd3c0ea868141548d349a80a24a355856378ab5de7", + "test/chromecast.test.js": "be9d42e13ea495d7c266732ffe0273acb96d1a29b8ef330eba32156aaa5eccb8", + "test/chromecast-failures.test.js": "53660df362e5d55d9bd87ff1a038073ce39982e3307f79ee2c41f91bd8fe4bd2", + "refactor/baselines/chromecast-release.json": "a68745786d976afee10afb2bb0fd61aeba8f9fffa18737af3e395d25079e3eec" + }, + "observations": [ + { + "id": "CAST-SDK-01", + "title": "Cast requestSession completion is incorrectly passed to loadMedia as a session", + "confirmed": "Controlled SDK and real frozen plugin code; not a physical Cast session." + }, + { + "id": "CAST-LOADER-01", + "title": "Cast readiness waits hang or overwrite each other across ready SDKs, repeated clicks and multiple instances", + "confirmed": "Controlled SDK and real frozen plugin code; not a physical Cast session." + }, + { + "id": "CAST-LIFE-01", + "title": "Cast listeners and continuations survive player destruction and update another player icon", + "confirmed": "Controlled SDK and real frozen plugin code; not a physical Cast session." + }, + { + "id": "CAST-STATE-01", + "title": "Cast terminal session states leave the normalized callback and icon connected", + "confirmed": "Controlled SDK and real frozen plugin code; not a physical Cast session." + }, + { + "id": "CAST-ERROR-01", + "title": "Cast media loading rejection escapes on a detached Promise while the click has already fulfilled", + "confirmed": "Controlled SDK and real frozen plugin code; not a physical Cast session." + } + ], + "preserved": [ + "Latest getter returns raw SessionState, initially null.", + "Callbacks receive their original option as this and are looked up live.", + "Current art.option.url is used when option.url is absent.", + "1.0 eager registration and name-only result are tested separately." + ], + "sourceReferences": [ + "https://developers.google.com/cast/docs/reference/web_sender/cast.framework.CastContext", + "https://developers.google.com/cast/docs/reference/web_sender/cast.framework.CastSession#loadMedia" + ], + "sourceChanged": false, + "deviceValidated": false, + "sdkNetworkLoaded": false, + "releaseReady": false +} diff --git a/refactor/changes/2026-09-13-PKG-CAST-02-failures.md b/refactor/changes/2026-09-13-PKG-CAST-02-failures.md new file mode 100644 index 000000000..521efb404 --- /dev/null +++ b/refactor/changes/2026-09-13-PKG-CAST-02-failures.md @@ -0,0 +1,31 @@ +# PKG-CAST-02 受控 SDK 与错误基线 + +新增65项1.1.0及冻结工作区缺陷/边界观察,连同01的28项公共行为共93项通过。 +这些测试验证历史错误确实可复现,不代表候选已修复;所有SDK/DOM均明确受控, +没有连接真实Cast设备、请求远程SDK或验证实际媒体播放。 + +## 已复现与下一步修正 + +| 风险 | 精确复现 | 03的修复方向 | +| --- | --- | --- | +| CAST-SDK-01 | requestSession成功返回undefined,旧click把它当session后在loadMedia处TypeError | 请求结算后读取getCurrentSession并处理null和过期归属 | +| CAST-LOADER-01 | 已就绪Framework仍等待过去的availability;两实例覆盖handler、插两脚本、首click不结算;回调中setOptions异常逃逸 | 共享可重用加载协调、真实ready检测、保留外部handler、单一错误边界 | +| CAST-LIFE-01 | 无destroy订阅/解绑,销毁后仍回调和加载;第二实例改第一图标;复用工厂共享状态 | 实例范围控制器、控件节点归属、代际取消及独立监听释放,不擅自结束共享会话 | +| CAST-STATE-01 | SESSION_ENDED更新原始getter但图标/归一化回调仍connected | 完整终态映射,保持getter返回SessionState | +| CAST-ERROR-01 | loadMedia拒绝另起脱离链,click已fulfilled而末端Promise拒绝 | 返回并await完整加载链,原错误与单次onError、notice有明确归属 | + +另验证false availability、用户拒绝会话、脚本失败后重试残留、当前art.option.url、 +live回调替换与this===option。SDK自身返回的Promise链由测试独立观察并附拒绝处理, +没有修改插件全局Promise或把孤立拒绝伪装成click拒绝。 + +来源规范:[CastContext](https://developers.google.com/cast/docs/reference/web_sender/cast.framework.CastContext)、 +[CastSession.loadMedia](https://developers.google.com/cast/docs/reference/web_sender/cast.framework.CastSession#loadMedia)、 +[SessionState](https://developers.google.com/cast/docs/reference/web_sender/cast.framework#SessionState)。 +1.0.0的提前SDK加载、仅name结果、已有SDK可立即注册及原始加载拒绝在01中单独冻结; +不把1.1.0新增的回调/getter测试强行套给1.0.0,也不将它列为skip。 + +新增失败文件接入test:chromecast及test:unit。精确输入和日志哈希见 +[证据](../baselines/chromecast-failures-validation.json)。生产源码/声明/产物未改,不进行 +无意义重构建。03接着拆分loader/SDK适配/实例会话/UI与清理,并解决VENDOR-10图标 +来源问题;04处理精确类型,05保留真实HTTPS Chrome/接收设备/媒体可达性门槛。 +这些问题按已有任务修复,不仅记录后搁置;本步单独提交,不推送或发布。 diff --git a/refactor/plan.md b/refactor/plan.md index 8de22fbd0..736032aaf 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -4,7 +4,7 @@ 基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 228 项,范围 22 个包及工作区/示例。 -状态:todo 80 / doing 17 / blocked 0 / done 131 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 79 / doing 17 / blocked 0 / done 132 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -288,7 +288,7 @@ | ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 | | --- | --- | --- | --- | --- | --- | --- | | PKG-CAST-01 | artplayer-plugin-chromecast
核对包契约与历史用法 | BASE-05 | url/sdk/icon/mimeType、loader、会话、全局 SDK 与结果 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done | -| PKG-CAST-02 | artplayer-plugin-chromecast
建立特有行为与错误测试 | PKG-CAST-01, ENG-03, ENG-05 | SDK 脚本加载失败/重入、无设备/拒绝、重复实例与销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | todo | +| PKG-CAST-02 | artplayer-plugin-chromecast
建立特有行为与错误测试 | PKG-CAST-01, ENG-03, ENG-05 | SDK 脚本加载失败/重入、无设备/拒绝、重复实例与销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done | | PKG-CAST-03 | artplayer-plugin-chromecast
整理内部职责与资源 | PKG-CAST-02, CORE-11, CORE-13 | SDK singleton/实例订阅/会话与控件职责分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo | | PKG-CAST-04 | artplayer-plugin-chromecast
迁移自有源码和公开类型 | PKG-CAST-03, ENG-04, ENG-06, CORE-07 | Cast 能力 adapter、全局对象和元数据类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo | | PKG-CAST-05 | artplayer-plugin-chromecast
验证新旧核心和组合 | PKG-CAST-04, CORE-22 | stub 测试之外记录实际 Cast 设备会话、源更新和断开 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo | @@ -543,6 +543,7 @@ - PKG-VAST-02: [记录](changes/2026-09-12-PKG-VAST-02-tests.md) [记录](baselines/vast-behavior-validation.json) [记录](baselines/vast-core.json) [记录](vast-validation.md) - PKG-VAST-03: [记录](changes/2026-09-12-PKG-VAST-03-lifecycle.md) [记录](baselines/vast-lifecycle-validation.json) [记录](vast-compatibility-decision.md) - PKG-CAST-01: [记录](baselines/chromecast-release.json) [记录](baselines/chromecast-contract.md) [记录](baselines/chromecast-contract-validation.json) [记录](changes/2026-09-13-PKG-CAST-01-contracts.md) +- PKG-CAST-02: [记录](changes/2026-09-13-PKG-CAST-02-failures.md) [记录](baselines/chromecast-failures-validation.json) - PKG-DPIP-01: [记录](changes/2026-09-12-PKG-DPIP-01-contract.md) [记录](baselines/dpip-release.json) [记录](baselines/dpip-contract.md) [记录](baselines/dpip-contract-validation.json) [记录](dpip-validation.md) - PKG-DPIP-02: [记录](changes/2026-09-12-PKG-DPIP-02-tests.md) [记录](baselines/dpip-behavior-validation.json) [记录](dpip-validation.md) - PKG-DPIP-03: [记录](changes/2026-09-12-PKG-DPIP-03-lifecycle.md) [记录](baselines/dpip-lifecycle-validation.json) [记录](dpip-validation.md) diff --git a/refactor/progress.md b/refactor/progress.md index a5ebb3044..0b155b0e5 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,16 @@ # 进度与证据 +## PKG-CAST-02 Chromecast 错误基线完成 + +93项受控历史测试通过,其中65项新增错误/生命周期观察。首次会话结果误用、已就绪 +SDK悬空/多实例覆盖、错图标与销毁后加载、终态遗漏、脱离媒体Promise均已复现并 +登记03修复任务。live选项/回调this/原始SessionState继续保留。见[变更](changes/2026-09-13-PKG-CAST-02-failures.md) +与[证据](baselines/chromecast-failures-validation.json)。生产仍未改,真实Cast设备与 +远程SDK尚未验证。228项:132 done、17 doing、79 todo;217风险。 +下一步03生产模块与资源修复;已获授权的Canvas/Ambilight类型修复正由两个子代理 +独立实施,父代理协调共享类型检查和编辑器生成。没有推送或发布。 + + ## PKG-CAST-01 Chromecast 契约与来源完成 冻结真实npm1.0.0/1.1.0与9个工作区输入,逐成员、入口及git关联核验通过。1.0.0 diff --git a/refactor/risk-table.md b/refactor/risk-table.md index de8033267..a4f68a477 100644 --- a/refactor/risk-table.md +++ b/refactor/risk-table.md @@ -216,3 +216,8 @@ | ASR-RESTART-01 | resolved / 已复现 | Delayed captured-source restart resumes recognition after a newer pause or stop | PKG-ASR-09 | | VENDOR-10 | open / 源码/产物事实 | Chromecast embeds a Font Awesome Pro path with no established redistribution entitlement | PKG-CAST-03, PKG-CAST-06 | | CAST-TYPE-01 | open / 源码/产物事实 | Chromecast declarations describe a synchronous name-only registration and omit existing runtime callbacks and query methods | PKG-CAST-04 | +| CAST-SDK-01 | open / 已复现 | Cast requestSession completion is incorrectly passed to loadMedia as a session | PKG-CAST-03, PKG-CAST-05 | +| CAST-LOADER-01 | open / 已复现 | Cast readiness waits hang or overwrite each other across ready SDKs, repeated clicks and multiple instances | PKG-CAST-03, PKG-CAST-05 | +| CAST-LIFE-01 | open / 已复现 | Cast listeners and continuations survive player destruction and update another player icon | PKG-CAST-03, PKG-CAST-05 | +| CAST-STATE-01 | open / 已复现 | Cast terminal session states leave the normalized callback and icon connected | PKG-CAST-03, PKG-CAST-05 | +| CAST-ERROR-01 | open / 已复现 | Cast media loading rejection escapes on a detached Promise while the click has already fulfilled | PKG-CAST-03, PKG-CAST-05 | diff --git a/refactor/risks.json b/refactor/risks.json index 4ae5af378..6fa773082 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -4745,6 +4745,86 @@ ], "compatibleResolution": "Preserve the latest published root factory type and provide a precise runtime type view; verify valid historical JavaScript module forms independently.", "closureCriteria": "Frozen type consumers and replacements pass, accurate async/callback/state declarations work through an additive entry, and actual installed exports are tested." + }, + { + "id": "CAST-SDK-01", + "title": "Cast requestSession completion is incorrectly passed to loadMedia as a session", + "confirmation": "reproduced", + "status": "open", + "owners": [ + "PKG-CAST-03", + "PKG-CAST-05" + ], + "evidence": [ + "test/chromecast-failures.test.js", + "refactor/baselines/chromecast-failures-validation.json" + ], + "compatibleResolution": "Retrieve the actual current session after request success and validate live instance ownership.", + "closureCriteria": "Successful undefined/null request result loads through the current session; absence and refusal preserve appropriate original errors." + }, + { + "id": "CAST-LOADER-01", + "title": "Cast readiness waits hang or overwrite each other across ready SDKs, repeated clicks and multiple instances", + "confirmation": "reproduced", + "status": "open", + "owners": [ + "PKG-CAST-03", + "PKG-CAST-05" + ], + "evidence": [ + "test/chromecast-failures.test.js", + "refactor/baselines/chromecast-failures-validation.json" + ], + "compatibleResolution": "Coordinate one reusable loader per document with readiness detection, shared pending work and complete error settlement.", + "closureCriteria": "Ready, pending, failure, retry, external callback and multiple instances are covered without duplicate scripts or stranded callers." + }, + { + "id": "CAST-LIFE-01", + "title": "Cast listeners and continuations survive player destruction and update another player icon", + "confirmation": "reproduced", + "status": "open", + "owners": [ + "PKG-CAST-03", + "PKG-CAST-05" + ], + "evidence": [ + "test/chromecast-failures.test.js", + "refactor/baselines/chromecast-failures-validation.json" + ], + "compatibleResolution": "Use per-registration lifecycle ownership, scoped control nodes and obsolete-operation checks without ending a page-shared session.", + "closureCriteria": "Destroyed players get no late media loads or callbacks; subscriptions are removed and separate players keep independent UI." + }, + { + "id": "CAST-STATE-01", + "title": "Cast terminal session states leave the normalized callback and icon connected", + "confirmation": "reproduced", + "status": "open", + "owners": [ + "PKG-CAST-03", + "PKG-CAST-05" + ], + "evidence": [ + "test/chromecast-failures.test.js", + "refactor/baselines/chromecast-failures-validation.json" + ], + "compatibleResolution": "Map terminal states without changing the raw SessionState query API.", + "closureCriteria": "Ended and failed states update UI and callbacks while getCastState keeps original SessionState values." + }, + { + "id": "CAST-ERROR-01", + "title": "Cast media loading rejection escapes on a detached Promise while the click has already fulfilled", + "confirmation": "reproduced", + "status": "open", + "owners": [ + "PKG-CAST-03", + "PKG-CAST-05" + ], + "evidence": [ + "test/chromecast-failures.test.js", + "refactor/baselines/chromecast-failures-validation.json" + ], + "compatibleResolution": "Await the complete media-loading chain and preserve one original error notification.", + "closureCriteria": "loadMedia failure reaches the click promise exactly once, no orphan rejection or duplicated error callback; success ordering remains documented." } ] } diff --git a/refactor/tasks.json b/refactor/tasks.json index e7af2ab7f..c4f09c051 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -3015,11 +3015,14 @@ "ENG-03", "ENG-05" ], - "status": "todo", + "status": "done", "risk": "H", "deliverable": "SDK 脚本加载失败/重入、无设备/拒绝、重复实例与销毁", "acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言", - "evidence": [] + "evidence": [ + "changes/2026-09-13-PKG-CAST-02-failures.md", + "baselines/chromecast-failures-validation.json" + ] }, { "id": "PKG-CAST-03", diff --git a/test/chromecast-failures.test.js b/test/chromecast-failures.test.js new file mode 100644 index 000000000..8b2c5ec59 --- /dev/null +++ b/test/chromecast-failures.test.js @@ -0,0 +1,250 @@ +import assert from 'node:assert/strict' +// eslint-disable-next-line test/no-import-node-test -- Historical Chromecast defects remain explicit observations. +import test from 'node:test' +import { chromecastEnvironment, chromecastHistorical } from './helpers/chromecast.js' + +for (const implementation of await chromecastHistorical()) { + // Version 1.0 loads during registration and has no query/callback surface. + // Its distinct public behavior is frozen in chromecast.test.js. + if (implementation.name.startsWith('published-1.0.0-')) + continue + test(`Chromecast ${implementation.name}: available Framework still waits for an availability callback that already happened`, async () => { + const env = chromecastEnvironment(implementation) + env.installSdk() + const player = env.player() + await env.factory({})(player.art) + let settled = false + player.controls[0].click().then(() => { + settled = true + }, () => { + settled = true + }) + await env.flush() + assert.equal(settled, false) + assert.equal(env.scripts.length, 0) + assert.equal(env.sdkOptions.length, 0) + assert.equal(typeof env.context.__onGCastApiAvailable, 'function') + }) + + test(`Chromecast ${implementation.name}: successful requestSession completion is incorrectly used as a session`, async () => { + const env = chromecastEnvironment(implementation) + const player = env.player() + const errors = [] + await env.factory({ onError: error => errors.push(error) })(player.art) + const pending = player.controls[0].click() + env.installSdk({ requestResult: undefined }) + env.context.__onGCastApiAvailable(true) + await assert.rejects(pending, error => error.name === 'TypeError' && error.message.includes('loadMedia')) + assert.equal(env.requests(), 1) + assert.equal(env.mediaLoads.length, 0) + assert.equal(errors.length, 1) + assert.equal(player.art.notice.show, 'Error connecting to cast session') + }) + + test(`Chromecast ${implementation.name}: concurrent instances overwrite availability and strand the first click`, async () => { + const env = chromecastEnvironment(implementation) + const first = env.player() + const second = env.player() + await env.factory({})(first.art) + await env.factory({})(second.art) + let firstSettled = false + first.controls[0].click().then(() => { + firstSettled = true + }, () => { + firstSettled = true + }) + const firstHandler = env.context.__onGCastApiAvailable + const next = second.controls[0].click() + assert.notEqual(env.context.__onGCastApiAvailable, firstHandler) + assert.equal(env.scripts.length, 2) + env.installSdk() + env.context.__onGCastApiAvailable(true) + env.sessionState('SESSION_STARTED') + await next + await env.flush() + assert.equal(firstSettled, false) + assert.equal(first.icon.style.color, 'red', 'Second instance modifies the first global icon') + assert.equal(second.icon.style.color, undefined) + }) + + test(`Chromecast ${implementation.name}: reusing one factory shares session state across players`, async () => { + const env = chromecastEnvironment(implementation) + const first = env.player() + const second = env.player() + const register = env.factory({}) + const a = await register(first.art) + const b = await register(second.art) + const pending = second.controls[0].click() + env.installSdk() + env.context.__onGCastApiAvailable(true) + env.sessionState('SESSION_STARTED') + await pending + assert.equal(a.getCastState(), 'SESSION_STARTED') + assert.equal(a.isCasting(), true) + assert.equal(b.isCasting(), true) + assert.equal(first.icon.style.color, 'red') + assert.equal(second.icon.style.color, undefined) + }) + + test(`Chromecast ${implementation.name}: ended state changes the raw getter but leaves callback and icon connected`, async () => { + const env = chromecastEnvironment(implementation) + const player = env.player() + const states = [] + const plugin = await env.factory({ onStateChange: value => states.push(value) })(player.art) + const pending = player.controls[0].click() + env.installSdk() + env.context.__onGCastApiAvailable(true) + env.sessionState('SESSION_STARTED') + await pending + env.sessionState('SESSION_ENDED', null) + assert.equal(plugin.getCastState(), 'SESSION_ENDED') + assert.equal(plugin.isCasting(), false) + assert.deepEqual(states, ['connected']) + assert.equal(player.icon.style.color, 'red') + }) + + test(`Chromecast ${implementation.name}: media rejection is detached from the fulfilled click promise`, async () => { + const env = chromecastEnvironment(implementation) + const player = env.player() + const error = new Error('receiver rejected media') + const errors = [] + await env.factory({ onError: value => errors.push(value) })(player.art) + const pending = player.controls[0].click() + env.installSdk({ loadError: error }) + env.context.__onGCastApiAvailable(true) + env.sessionState('SESSION_STARTED') + assert.equal(await pending, undefined) + await env.flush() + await assert.rejects(env.pendingMedia.at(-1), value => value === error) + assert.deepEqual(errors, [error]) + assert.equal(player.art.notice.show, 'Error casting media') + }) + + test(`Chromecast ${implementation.name}: destroy leaves SDK listeners and callbacks active`, async () => { + const env = chromecastEnvironment(implementation) + const player = env.player() + const availability = [] + await env.factory({ onCastAvailable: value => availability.push(value) })(player.art) + const pending = player.controls[0].click() + env.installSdk() + env.context.__onGCastApiAvailable(true) + env.sessionState('SESSION_STARTED') + await pending + player.destroy() + env.emit('caststatechanged', { castState: 'NO_DEVICES_AVAILABLE' }) + assert.deepEqual(availability, [false]) + assert.equal(player.listeners.size, 0) + assert.equal(env.sdkListeners.get('sessionstatechanged').length, 1) + assert.equal(env.sdkListeners.get('caststatechanged').length, 1) + }) + + test(`Chromecast ${implementation.name}: exception inside availability escapes without settling initialization`, async () => { + const env = chromecastEnvironment(implementation) + const player = env.player() + await env.factory({})(player.art) + let settled = false + player.controls[0].click().then(() => { + settled = true + }, () => { + settled = true + }) + const error = new Error('CastOptions failed') + const { castContext } = env.installSdk() + castContext.setOptions = () => { + throw error + } + assert.throws(() => env.context.__onGCastApiAvailable(true), value => value === error) + await env.flush() + assert.equal(settled, false) + }) + + test(`Chromecast ${implementation.name}: unavailable API rejects with one original callback error`, async () => { + const env = chromecastEnvironment(implementation) + const player = env.player() + const errors = [] + const option = { onError(error) { + errors.push({ error, receiver: this }) + } } + await env.factory(option)(player.art) + const pending = player.controls[0].click() + env.context.__onGCastApiAvailable(false) + await assert.rejects(pending, error => error.message === 'Cast API is not available' && error === errors[0].error) + assert.equal(errors.length, 1) + assert.equal(errors[0].receiver, option) + assert.equal(env.scripts.length, 1, 'The failed loader retains its script') + }) + + test(`Chromecast ${implementation.name}: session request rejection preserves original error and notice`, async () => { + const env = chromecastEnvironment(implementation) + const player = env.player() + const error = new Error('User declined the session') + const errors = [] + await env.factory({ onError: value => errors.push(value) })(player.art) + const pending = player.controls[0].click() + env.installSdk({ requestError: error }) + env.context.__onGCastApiAvailable(true) + await assert.rejects(pending, value => value === error) + assert.deepEqual(errors, [error]) + assert.equal(env.requests(), 1) + assert.equal(env.mediaLoads.length, 0) + assert.equal(player.art.notice.show, 'Error connecting to cast session') + }) + + test(`Chromecast ${implementation.name}: current player source and live callback receiver are used on subsequent casting`, async () => { + const env = chromecastEnvironment(implementation) + const player = env.player('/first.mp4') + const calls = [] + const option = { onCastStart() { + calls.push(this) + } } + await env.factory(option)(player.art) + const pending = player.controls[0].click() + env.installSdk() + env.context.__onGCastApiAvailable(true) + env.sessionState('SESSION_STARTED') + await pending + await env.flush() + player.art.option.url = '/next.webm' + option.onCastStart = function () { + calls.push(['replacement', this]) + } + await player.controls[0].click() + await env.flush() + assert.equal(env.mediaLoads.at(-1).media.contentId, '/next.webm') + assert.equal(env.mediaLoads.at(-1).media.contentType, 'video/webm') + assert.equal(calls[0], option) + assert.equal(calls[1][0], 'replacement') + assert.equal(calls[1][1], option) + }) + + test(`Chromecast ${implementation.name}: failed script retry retains old script and replaces the global callback`, async () => { + const env = chromecastEnvironment(implementation) + const player = env.player() + await env.factory({})(player.art) + const first = player.controls[0].click() + const previous = env.context.__onGCastApiAvailable + const error = new Error('script failed') + env.scripts[0].onerror(error) + await assert.rejects(first, value => value === error) + const second = player.controls[0].click() + assert.equal(env.scripts.length, 2) + assert.notEqual(env.context.__onGCastApiAvailable, previous) + env.scripts[1].onerror(error) + await assert.rejects(second, value => value === error) + }) + + test(`Chromecast ${implementation.name}: late SDK availability after destroy still loads media`, async () => { + const env = chromecastEnvironment(implementation) + const player = env.player() + await env.factory({})(player.art) + const pending = player.controls[0].click() + player.destroy() + env.installSdk() + env.context.__onGCastApiAvailable(true) + env.sessionState('SESSION_STARTED') + await pending + await env.flush() + assert.equal(env.mediaLoads.length, 1) + assert.equal(player.art.notice.show, 'Casting started') + }) +}