From 30de0071027e34bea3c96749434cde436c087cd3 Mon Sep 17 00:00:00 2001 From: Harvey Zhao Date: Sun, 13 Sep 2026 16:35:26 +0800 Subject: [PATCH] test(chromecast): [PKG-CAST-01] freeze published contracts and provenance --- package.json | 5 +- .../chromecast-contract-validation.json | 40 +++ refactor/baselines/chromecast-contract.md | 50 ++++ refactor/baselines/chromecast-release.json | 280 ++++++++++++++++++ .../2026-09-13-PKG-CAST-01-contracts.md | 22 ++ refactor/plan.md | 5 +- refactor/progress.md | 13 + refactor/risk-table.md | 2 + refactor/risks.json | 31 ++ refactor/scripts/chromecast-contract.mjs | 82 +++++ refactor/tasks.json | 9 +- refactor/third-party.json | 33 ++- test/chromecast.test.js | 133 +++++++++ test/helpers/chromecast.js | 135 +++++++++ 14 files changed, 833 insertions(+), 7 deletions(-) create mode 100644 refactor/baselines/chromecast-contract-validation.json create mode 100644 refactor/baselines/chromecast-contract.md create mode 100644 refactor/baselines/chromecast-release.json create mode 100644 refactor/changes/2026-09-13-PKG-CAST-01-contracts.md create mode 100644 refactor/scripts/chromecast-contract.mjs create mode 100644 test/chromecast.test.js create mode 100644 test/helpers/chromecast.js diff --git a/package.json b/package.json index a5098dae3..c72353797 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: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:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs", "test": "yarn test:node && yarn test:baseline", "test:browser": "playwright test", @@ -96,7 +96,8 @@ "test:package:runtime": "node scripts/package-runtime.mjs --canonical", "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:asr-demo": "node refactor/scripts/asr-local-demo.mjs", + "test:chromecast": "node --test test/chromecast.test.js" }, "browserslist": "last 1 Chrome version", "devDependencies": { diff --git a/refactor/baselines/chromecast-contract-validation.json b/refactor/baselines/chromecast-contract-validation.json new file mode 100644 index 000000000..cf268decf --- /dev/null +++ b/refactor/baselines/chromecast-contract-validation.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "PKG-CAST-01", + "capturedAt": "2026-09-13T08:34:48.666Z", + "sourceCommit": "146b78d5767bba88612184869155a39317794537", + "baseline": { + "file": "refactor/baselines/chromecast-release.json", + "sha256": "a68745786d976afee10afb2bb0fd61aeba8f9fffa18737af3e395d25079e3eec" + }, + "verifier": { + "file": "refactor/scripts/chromecast-contract.mjs", + "sha256LF": "6898481ebc4ab0374e80e0e6064a4ee983af912bf7d2880142631a992376ce8d" + }, + "tests": { + "passed": 28, + "failed": 0, + "skipped": 0, + "file": "refactor/.cache/chromecast01-contract.log", + "sha256": "82412503a635cdeb2bf0ebaa64d611a70a9db51ccf151456d1da27c1a22096ef", + "scope": "Real frozen main/legacy 1.0.0 and 1.1.0, frozen source and workspace main/legacy; controlled DOM and SDK, no device session." + }, + "inputs": { + "test/helpers/chromecast.js": "a51ad278dffde582c00d66cd3c0ea868141548d349a80a24a355856378ab5de7", + "test/chromecast.test.js": "be9d42e13ea495d7c266732ffe0273acb96d1a29b8ef330eba32156aaa5eccb8" + }, + "differences": [ + "1.0.0 eager SDK load and name-only result; 1.1.0 lazy load, raw SessionState getters and callbacks.", + "1.0.0 CJS namespace/default and no ESM/module/exports; 1.1.0 callable CJS and ESM.", + "Both registry gitHeads point to differently versioned Git manifests; archive authority retained.", + "Synchronous declarations omit actual asynchronous result and modern callbacks/getters.", + "Commercial Font Awesome Pro notice on the identical embedded icon; entitlement unverified." + ], + "sourceReferences": [ + "https://developers.google.com/cast/docs/reference/web_sender/cast.framework.CastContext", + "https://developers.google.com/cast/docs/web_sender/integrate" + ], + "releaseReady": false, + "deviceValidated": false, + "sourceChanged": false +} diff --git a/refactor/baselines/chromecast-contract.md b/refactor/baselines/chromecast-contract.md new file mode 100644 index 000000000..a901357eb --- /dev/null +++ b/refactor/baselines/chromecast-contract.md @@ -0,0 +1,50 @@ +# Chromecast 历史契约 + +以 [真实发布归档](chromecast-release.json) 为准。1.0.0 和1.1.0 是捕获时 registry 的 +全部稳定版;没有把工作区版本或 bundle banner 当作发布版本。1.0.0 的 banner 写 +2.0.0,实际 tarball manifest 是1.0.0。两版 registry gitHead 对应插件 manifest 又 +分别为2.0.0/1.0.2;逐成员差异独立冻结,来源核心5.1.2/5.3.1仅是提交关联。 + +## 分发与行为 + +| 项目 | 1.0.0 | 1.1.0 / 冻结工作区 | +| --- | --- | --- | +| 入口 | main/legacy/types;没有 module/exports | main/module/legacy/types/exports | +| CJS | 对象.default | 直接工厂 | +| 浏览器全局 | artplayerPluginChromecast | 同名工厂 | +| 工厂参数 | 必传 option,字段可选 | 相同;新增未声明的回调能力 | +| SDK加载 | 工厂覆盖availability回调,注册时加载 | 点击时初始化;注册不请求SDK | +| 注册 | async,SDK脚本onload后添加控件 | async,立即添加控件 | +| 注册结果 | name | name/getCastState/isCasting | +| 声明结果 | 同步、仅name | 仍错误声明同步、仅name | +| 点击 | void,查询当前SDK会话 | async,使用闭包保存的会话 | +| MIME/URL | live读取option或art.option.url | 相同 | +| 状态/错误回调 | 不执行新版回调 | onStateChange/onCastAvailable/onCastStart/onError | + +选择1.1.0的懒加载作为候选正常行为,历史1.0.0 eager加载单独保留;不是要求恢复 +旧版的提前网络访问。旧JS有效的CommonJS default与直接形式都属于后续兼容检查。 +回调以属性方法调用,this为原option;自定义icon保留html包装和两个class。工厂不应 +机械快照url/sdk/mimeType或回调。控件name为chromecast、position为right、tooltip为Chromecast。 + +getCastState实际返回原始SessionState,初始null;isCasting依据session是否为null, +不是接收端是否正在播放。状态回调则使用disconnected/connecting/connected/disconnecting。 +MIME包含mp4/webm/ogg/ogv/mp3/wav/flv/mov/avi/wmv/mpd/m3u8及application/octet-stream +回退;查询/fragment去除与大小写处理保留。这张映射不证明Cast设备支持对应codec。 + +## SDK 与资源范围 + +SDK默认URL仍是gstatic的可变/v1入口,sdk允许覆盖。官方requestSession完成值并非 +CastSession,应取得getCurrentSession;旧1.1.0实现存在相应错误。其他SDK初始状态、 +并发、Promise和销毁缺陷由02冻结,03修复。依据: +[CastContext](https://developers.google.com/cast/docs/reference/web_sender/cast.framework.CastContext)、 +[集成指南](https://developers.google.com/cast/docs/web_sender/integrate)。 + +默认SVG内嵌path与cast.svg相同,后者带Font Awesome Pro6.5.1商业许可标记;包的MIT +声明不能作为仓库已具备该资产授权的证据。VENDOR-10保持未决,03优先用自有图形 +替换,06核对最终包与bundle;历史冻结文件不改写。SDK-06仍缺真实SDK版本和设备会话。 + +## 重放 + +`node refactor/scripts/chromecast-contract.mjs`核验SHA512/SHA256、成员、实际入口、 +Git关联和图标来源;`yarn test:chromecast`重放受控公共行为。测试不发起外部网络 +SDK加载或Cast会话;不等于真实Chrome、接收设备、媒体网络或发布验证。 diff --git a/refactor/baselines/chromecast-release.json b/refactor/baselines/chromecast-release.json new file mode 100644 index 000000000..2acf4494c --- /dev/null +++ b/refactor/baselines/chromecast-release.json @@ -0,0 +1,280 @@ +{ + "schemaVersion": 1, + "registry": "https://registry.npmjs.org/artplayer-plugin-chromecast", + "capturedAt": "2026-09-13T08:26:54.146Z", + "sourceCommit": "146b78d5767bba88612184869155a39317794537", + "sourceNormalization": "Git text normalized to LF; archive members hashed as bytes.", + "source": { + "docs/assets/example/chromecast.js": "edd4d232a667a3ca5bae247dca3c557431975b9ae38dc56cc590b57e327d048f", + "packages/artplayer-plugin-chromecast/README.md": "d277a82e54df2af679f94c92da2250d3779f07e2c37282d7d66e776f54fed323", + "packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.js": "4959661fc7f22eee3d32363bb0392a486c44499ec4a9c608ccfea55de29aaac6", + "packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.legacy.js": "e2ba7aebec099884bb806f3caf77f9ff739ff9f236aefdfd0a4e69a8295c9d01", + "packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.mjs": "b6d4e445a78f00fae53a35d239e62cc4a1448086d76058d69d8f9634a5754768", + "packages/artplayer-plugin-chromecast/package.json": "8157c06e4f764b1662ac108334e1240b07963400b906da6b21874e9a0e415adf", + "packages/artplayer-plugin-chromecast/src/cast.svg": "8c0f9ad82c2d6c70afb70ab5002204c4948dd282fbf52095aed4b07fa8d466d4", + "packages/artplayer-plugin-chromecast/src/index.js": "2ab8967cf7fdbf9fbac8a9afe73713e66047bad7e4d4afc802665ce594fa3274", + "packages/artplayer-plugin-chromecast/types/artplayer-plugin-chromecast.d.ts": "7826de339f5c027025c71215ed3c13995c01174bdf7d7697d01153d922c281a1" + }, + "registrySnapshot": { + "status": 200, + "bodySha256": "357ef2db141c991366e9d1e0f4504a9fa9bb13c9cb99892a6d9e03da48c4d705", + "distTags": { + "latest": "1.1.0" + }, + "stableVersions": [ + "1.0.0", + "1.1.0" + ], + "prereleaseVersions": [], + "scope": "All stable versions listed by the live npm package catalog at capture time; registry signing keys/signatures were not independently verified." + }, + "release": { + "name": "artplayer-plugin-chromecast", + "version": "1.1.0", + "tarball": "https://registry.npmjs.org/artplayer-plugin-chromecast/-/artplayer-plugin-chromecast-1.1.0.tgz", + "integrity": "sha512-3xrHj1CHe0Tt8gNMUB9A1epuW4OrbPsa1T9MNFy4AfQsjhYAhWAmef6hYu1GdHdADDybXBtVHP50gQ+EW+ddcw==", + "sha256": "1941d41055ee02deaa343cbf9eaf714495cb9f04883f57966f5b27f128117c30", + "registryGitHead": "daf133b22630b4a0eecfa3336bbddab0e9119d96", + "publishedAt": "2026-03-12T14:21:25.045Z", + "files": { + "package/README.md": "d277a82e54df2af679f94c92da2250d3779f07e2c37282d7d66e776f54fed323", + "package/dist/artplayer-plugin-chromecast.js": "4959661fc7f22eee3d32363bb0392a486c44499ec4a9c608ccfea55de29aaac6", + "package/dist/artplayer-plugin-chromecast.legacy.js": "e2ba7aebec099884bb806f3caf77f9ff739ff9f236aefdfd0a4e69a8295c9d01", + "package/dist/artplayer-plugin-chromecast.mjs": "b6d4e445a78f00fae53a35d239e62cc4a1448086d76058d69d8f9634a5754768", + "package/package.json": "8157c06e4f764b1662ac108334e1240b07963400b906da6b21874e9a0e415adf", + "package/types/artplayer-plugin-chromecast.d.ts": "7826de339f5c027025c71215ed3c13995c01174bdf7d7697d01153d922c281a1" + }, + "manifest": { + "name": "artplayer-plugin-chromecast", + "version": "1.1.0", + "description": "Chromecast plugin for ArtPlayer", + "author": "Harvey Zhao ", + "license": "MIT", + "homepage": "https://artplayer.org", + "repository": { + "type": "git", + "url": "git+https://github.com/zhw2590582/ArtPlayer.git" + }, + "bugs": { + "url": "https://github.com/zhw2590582/ArtPlayer/issues" + }, + "keywords": [ + "html5", + "video", + "player" + ], + "exports": { + ".": { + "types": "./types/artplayer-plugin-chromecast.d.ts", + "import": "./dist/artplayer-plugin-chromecast.mjs", + "require": "./dist/artplayer-plugin-chromecast.js" + }, + "./legacy": { + "types": "./types/artplayer-plugin-chromecast.d.ts", + "import": "./dist/artplayer-plugin-chromecast.legacy.js", + "require": "./dist/artplayer-plugin-chromecast.legacy.js" + } + }, + "main": "./dist/artplayer-plugin-chromecast.js", + "module": "./dist/artplayer-plugin-chromecast.mjs", + "types": "./types/artplayer-plugin-chromecast.d.ts", + "legacy": "./dist/artplayer-plugin-chromecast.legacy.js", + "browserslist": "last 1 Chrome version" + }, + "historicalCore": { + "commit": "daf133b22630b4a0eecfa3336bbddab0e9119d96", + "file": "packages/artplayer/package.json", + "version": "5.3.1", + "sha256LF": "862f3697876fe915dd0ada178da766ce823aae7563ed30b3e5b11b811202929c" + }, + "entrypoints": { + "main": "./dist/artplayer-plugin-chromecast.js", + "module": "./dist/artplayer-plugin-chromecast.mjs", + "types": "./types/artplayer-plugin-chromecast.d.ts", + "legacy": "./dist/artplayer-plugin-chromecast.legacy.js" + }, + "registrySourceManifest": { + "commit": "daf133b22630b4a0eecfa3336bbddab0e9119d96", + "file": "packages/artplayer-plugin-chromecast/package.json", + "version": "1.0.2", + "sha256LF": "875ba2d244df05666e22bc6a2cc48ac92f14349cef54aa00ff605caff96787a0", + "versionMatchesRelease": false + }, + "sourceAssociationScope": "Actual manifests at the npm metadata gitHead; association only, not a minimum supported core or proof the source matches the published archive.", + "registrySourceComparison": [ + { + "file": "packages/artplayer-plugin-chromecast/README.md", + "sha256LF": "d277a82e54df2af679f94c92da2250d3779f07e2c37282d7d66e776f54fed323", + "matchesArchiveBytes": true + }, + { + "file": "packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.js", + "sha256LF": "48d624932ab98791a4b493d49343cc8419319034b6a0da310c4fad1da7a852c7", + "matchesArchiveBytes": false + }, + { + "file": "packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.legacy.js", + "sha256LF": "b5d87717bc1a566699f9fbda69e346d4f6b378b9c28292447a576b5a23a5c2d3", + "matchesArchiveBytes": false + }, + { + "file": "packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.mjs", + "sha256LF": "0d60f9f4ab2c6cf676c355d6db0e0315fac02e130ce0b1865d12da106c27af26", + "matchesArchiveBytes": false + }, + { + "file": "packages/artplayer-plugin-chromecast/package.json", + "sha256LF": "875ba2d244df05666e22bc6a2cc48ac92f14349cef54aa00ff605caff96787a0", + "matchesArchiveBytes": false + }, + { + "file": "packages/artplayer-plugin-chromecast/types/artplayer-plugin-chromecast.d.ts", + "sha256LF": "7826de339f5c027025c71215ed3c13995c01174bdf7d7697d01153d922c281a1", + "matchesArchiveBytes": true + } + ] + }, + "previous": [ + { + "name": "artplayer-plugin-chromecast", + "version": "1.0.0", + "tarball": "https://registry.npmjs.org/artplayer-plugin-chromecast/-/artplayer-plugin-chromecast-1.0.0.tgz", + "integrity": "sha512-liF1ku8OYJqycgU/9HcAJsmv3amxp9/LG2Ce/RqVcQu+gTw8MoLELRfxeMolBK159s2sesmYp4N1043pDAtkAg==", + "sha256": "aa110388140046912d6da79f9891738a981aae3ec4e14722b987c46c9ecec3b2", + "registryGitHead": "43309b72cff84344bd61f4de505aee0e10a83e6b", + "publishedAt": "2024-05-27T03:32:00.044Z", + "files": { + "package/README.md": "87f2c3eedad755e97a545ddabe3c47fad1b9e7b28c0ae08cb22680b92ba2f905", + "package/dist/artplayer-plugin-chromecast.js": "6d3f0b5b2c2feb16e86dfbbbac13fcf815daea9aaa017d2eb72821bccbbbad4a", + "package/dist/artplayer-plugin-chromecast.legacy.js": "1e3aceff99060c2934ddce16aac3284e2d41d4a6d866dd8cdf3237a2310e941f", + "package/package.json": "1e61fb7ff05f04370cb45dc80cc55f8ab3d2a194bb488bb45d0b0c738d96150a", + "package/src/cast.svg": "8c0f9ad82c2d6c70afb70ab5002204c4948dd282fbf52095aed4b07fa8d466d4", + "package/src/index.js": "e6c66bf803828f1b03e9a52e68ca90eaef0c42431b8e9c5c5504992125db23d9", + "package/types/artplayer-plugin-chromecast.d.ts": "3ed160931cef6843537801b9ffb4887284d67ae46e1f31a5f611f34a15836a72" + }, + "manifest": { + "license": "MIT", + "version": "1.0.0", + "name": "artplayer-plugin-chromecast", + "types": "types/artplayer-plugin-chromecast.d.ts", + "description": "Chromecast plugin for ArtPlayer", + "main": "dist/artplayer-plugin-chromecast.js", + "legacy": "dist/artplayer-plugin-chromecast.legacy.js", + "author": "Harvey Zack ", + "browserslist": "last 1 Chrome version", + "homepage": "https://artplayer.org", + "repository": { + "type": "git", + "url": "git+https://github.com/zhw2590582/ArtPlayer.git" + }, + "bugs": { + "url": "https://github.com/zhw2590582/ArtPlayer/issues" + }, + "keywords": [ + "html5", + "video", + "player" + ] + }, + "historicalCore": { + "commit": "43309b72cff84344bd61f4de505aee0e10a83e6b", + "file": "packages/artplayer/package.json", + "version": "5.1.2", + "sha256LF": "aaf3ba9c7beaaea31170cc50e3958c0bb5d779ab4f90b6a213994ce8c9aace36" + }, + "entrypoints": { + "main": "dist/artplayer-plugin-chromecast.js", + "module": null, + "types": "types/artplayer-plugin-chromecast.d.ts", + "legacy": "dist/artplayer-plugin-chromecast.legacy.js" + }, + "registrySourceManifest": { + "commit": "43309b72cff84344bd61f4de505aee0e10a83e6b", + "file": "packages/artplayer-plugin-chromecast/package.json", + "version": "2.0.0", + "sha256LF": "ff9c26c916d181c0c30eee5a0481780f7b4f250809380c2dcf4be4cda124bf50", + "versionMatchesRelease": false + }, + "sourceAssociationScope": "Actual manifests at the npm metadata gitHead; association only, not a minimum supported core or proof the source matches the published archive.", + "registrySourceComparison": [ + { + "file": "packages/artplayer-plugin-chromecast/README.md", + "sha256LF": "87f2c3eedad755e97a545ddabe3c47fad1b9e7b28c0ae08cb22680b92ba2f905", + "matchesArchiveBytes": true + }, + { + "file": "packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.js", + "sha256LF": "6d3f0b5b2c2feb16e86dfbbbac13fcf815daea9aaa017d2eb72821bccbbbad4a", + "matchesArchiveBytes": true + }, + { + "file": "packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.legacy.js", + "sha256LF": "1e3aceff99060c2934ddce16aac3284e2d41d4a6d866dd8cdf3237a2310e941f", + "matchesArchiveBytes": true + }, + { + "file": "packages/artplayer-plugin-chromecast/package.json", + "sha256LF": "ff9c26c916d181c0c30eee5a0481780f7b4f250809380c2dcf4be4cda124bf50", + "matchesArchiveBytes": false + }, + { + "file": "packages/artplayer-plugin-chromecast/src/cast.svg", + "sha256LF": "8c0f9ad82c2d6c70afb70ab5002204c4948dd282fbf52095aed4b07fa8d466d4", + "matchesArchiveBytes": true + }, + { + "file": "packages/artplayer-plugin-chromecast/src/index.js", + "sha256LF": "e6c66bf803828f1b03e9a52e68ca90eaef0c42431b8e9c5c5504992125db23d9", + "matchesArchiveBytes": true + }, + { + "file": "packages/artplayer-plugin-chromecast/types/artplayer-plugin-chromecast.d.ts", + "sha256LF": "3ed160931cef6843537801b9ffb4887284d67ae46e1f31a5f611f34a15836a72", + "matchesArchiveBytes": true + } + ] + } + ], + "observations": { + "latestArchiveMatchesFrozenSourceFiles": [ + "packages/artplayer-plugin-chromecast/README.md", + "packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.js", + "packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.legacy.js", + "packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.mjs", + "packages/artplayer-plugin-chromecast/package.json", + "packages/artplayer-plugin-chromecast/types/artplayer-plugin-chromecast.d.ts" + ], + "entrypointDifferences": [ + "1.0.0 has main/types/legacy; module and exports are absent, and no ESM .mjs is shipped.", + "1.1.0 adds module and conditional root/legacy exports; it no longer ships src/index.js or src/cast.svg." + ], + "registrySourceMismatch": "Both npm metadata gitHeads disagree with their published plugin versions: 1.0.0 points to a Git manifest declaring 2.0.0 (only manifest differs among shipped members); 1.1.0 points to a Git manifest declaring 1.0.2 (manifest and all three dist members differ). Keep archives and associated Git sources separate; registrySourceComparison records exact differences.", + "typeRuntimeDifferences": [ + "The frozen declaration returns Chromecast synchronously, but the runtime registration function is async.", + "Runtime result exposes getCastState/isCasting; neither method appears in the frozen declaration.", + "Runtime reads onStateChange/onCastAvailable/onCastStart/onError callbacks; Option does not declare them." + ], + "historicalCorePublication": "1.1.0 gitHead associates core workspace 5.3.1. Previous ASR registry evidence found no published stable 5.3.1; this baseline makes no published/minimum-core claim." + }, + "thirdParty": { + "castSdk": { + "url": "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1", + "reference": "packages/artplayer-plugin-chromecast/src/index.js", + "loading": "Runtime script insertion on Cast initialization; consumers may override option.sdk.", + "frozenRuntimeBytes": false, + "version": "Mutable /v1 URL; exact served SDK build and transitive loads not captured.", + "status": "SDK availability, license terms, redistribution rights, sender initialization and real receiver validation remain separate work." + }, + "icon": { + "reference": "packages/artplayer-plugin-chromecast/src/cast.svg", + "notice": "! Font Awesome Pro 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. ", + "pathSha256": "2e154456a8ebae91a32ba65d737baa54706b3d034dd546600cd6d5e3bf413e94", + "samePathInRuntime": true, + "publishedWithNotice": [ + "1.0.0:package/src/cast.svg" + ], + "status": "The asset says Font Awesome Pro 6.5.1 / Commercial License. Repository entitlement and redistribution permission are not established by the package MIT declaration; runtime inlines the same path without this notice." + } + }, + "validationScope": "Archive integrity/member bytes/entrypoint existence and Git source association only. No Cast SDK download, browser, receiver/device, compatibility behavior, or npm publication acceptance." +} diff --git a/refactor/changes/2026-09-13-PKG-CAST-01-contracts.md b/refactor/changes/2026-09-13-PKG-CAST-01-contracts.md new file mode 100644 index 000000000..c9d8d06e8 --- /dev/null +++ b/refactor/changes/2026-09-13-PKG-CAST-01-contracts.md @@ -0,0 +1,22 @@ +# PKG-CAST-01 发布来源与公共契约 + +冻结真实npm1.0.0/1.1.0、全部成员与九个工作区源码/声明/README/demo/产物文件。 +历史banner和registry gitHead均存在版本差异,按真实tarball归档保存,不反推或改写。 +来源核心5.1.2/5.3.1仅为git关联,不能据此宣称对应正式核心发布或完整支持范围。 + +新增只读重放verifier与28项公共行为测试,覆盖两版本main/legacy、冻结source及 +工作区main/legacy,分别验证CJS/global、eager/lazy加载、异步注册、实际结果、live +option、MIME、回调/状态及加载失败。初次统一套用1.1.0流程导致1.0.0等待注册加载, +已终止该测试进程并按真实归档拆开场景;最终28项均通过。不是掩盖生产异常。 + +详见[契约表](../baselines/chromecast-contract.md)、[来源](../baselines/chromecast-release.json) +及[验证摘要](../baselines/chromecast-contract-validation.json)。根脚本增加test:chromecast并 +纳入test:unit;没有生产代码、声明、依赖、版本或分发产物变化,不需要本步重新构建。 + +CAST-TYPE-01登记旧同步声明、缺失回调和查询方法,04保持旧声明形状并增加准确入口。 +VENDOR-10记录商业图标标记与内嵌path匹配,归03替换和06分发核查。README/demo内容 +极少,demo仅相对媒体URL,接收端网络可达性与真实SDK版本/设备验证仍归05/06及SDK-06。 + +独立子代理核验npm与来源,另一个子代理只读对照Google官方规范,主代理审查并建立 +实际归档行为测试。01完成不表示SDK实现正确或已具备npm准入;02/03继续修复已发现 +的问题。没有连接Cast设备、推送或发布。 diff --git a/refactor/plan.md b/refactor/plan.md index 990f5aad4..8de22fbd0 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -4,7 +4,7 @@ 基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 228 项,范围 22 个包及工作区/示例。 -状态:todo 81 / doing 17 / blocked 0 / done 130 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 80 / doing 17 / blocked 0 / done 131 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -287,7 +287,7 @@ | ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 | | --- | --- | --- | --- | --- | --- | --- | -| PKG-CAST-01 | artplayer-plugin-chromecast
核对包契约与历史用法 | BASE-05 | url/sdk/icon/mimeType、loader、会话、全局 SDK 与结果 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | todo | +| 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-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 | @@ -542,6 +542,7 @@ - PKG-VAST-01: [记录](baselines/vast-contract.md) [记录](baselines/vast-release.json) [记录](changes/2026-09-12-PKG-VAST-01-contract.md) [记录](baselines/vast-contract-validation.json) - 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-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 675cbcd17..a5ebb3044 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,18 @@ # 进度与证据 +## PKG-CAST-01 Chromecast 契约与来源完成 + +冻结真实npm1.0.0/1.1.0与9个工作区输入,逐成员、入口及git关联核验通过。1.0.0 +提前加载SDK/仅name结果与1.1.0懒加载/状态回调分别测试,28项公共行为通过; +gitHead和banner版本差异明确记录。VENDOR-10登记商业图标来源,CAST-TYPE-01 +登记同步声明缺口,SDK-06仍缺真实会话。见[变更](changes/2026-09-13-PKG-CAST-01-contracts.md) +与[契约](baselines/chromecast-contract.md)。228项:131 done、17 doing、80 todo; +212风险。下一步02错误基线、03拆分与修复;未改生产代码、未连接设备或发布。 + +用户已确认历史类型冲突统一规则,见[type-compatibility-policy.md](type-compatibility-policy.md); +Canvas/Ambilight/VTT/Multiple Subtitles可继续实施,无需重复等待类型取舍确认。 + + ## PKG-ASR-09 显式捕获归属完成 新增可选capture策略,旧默认与根类型保留;Firefox无需强制异常,显式模式零直接 diff --git a/refactor/risk-table.md b/refactor/risk-table.md index 8478b7b32..de8033267 100644 --- a/refactor/risk-table.md +++ b/refactor/risk-table.md @@ -214,3 +214,5 @@ | ASR-FOREIGN-01 | resolved / 已复现 | Firefox permits parallel native media-element owners instead of selecting capture fallback | PKG-ASR-05, PKG-ASR-09 | | ASR-CORS-01 | open / 已复现 | Default WebAudio binding silences CORS-cross-origin media that otherwise advances normally | PKG-ASR-05 | | 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 | diff --git a/refactor/risks.json b/refactor/risks.json index a8d32c512..4ae5af378 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -4714,6 +4714,37 @@ "refactor/baselines/asr-explicit-capture-validation.json" ], "resolutionRationale": "Two delayed-close red tests now pass in source/main/legacy, with subsequent play verified; native restart and default regressions pass." + }, + { + "id": "VENDOR-10", + "title": "Chromecast embeds a Font Awesome Pro path with no established redistribution entitlement", + "confirmation": "source-observed", + "status": "open", + "owners": [ + "PKG-CAST-03", + "PKG-CAST-06" + ], + "evidence": [ + "packages/artplayer-plugin-chromecast/src/cast.svg", + "refactor/baselines/chromecast-release.json" + ], + "compatibleResolution": "Replace the default embedded artwork with an owned simple Cast indicator while retaining the custom icon option and wrapper classes; keep historical provenance separate.", + "closureCriteria": "Owned or properly licensed default artwork replaces the commercial-marked source and every shipped bundle, with source/provenance and generated-output checks." + }, + { + "id": "CAST-TYPE-01", + "title": "Chromecast declarations describe a synchronous name-only registration and omit existing runtime callbacks and query methods", + "confirmation": "source-observed", + "status": "open", + "owners": [ + "PKG-CAST-04" + ], + "evidence": [ + "packages/artplayer-plugin-chromecast/types/artplayer-plugin-chromecast.d.ts", + "refactor/baselines/chromecast-contract.md" + ], + "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." } ] } diff --git a/refactor/scripts/chromecast-contract.mjs b/refactor/scripts/chromecast-contract.mjs new file mode 100644 index 000000000..1c4e48cde --- /dev/null +++ b/refactor/scripts/chromecast-contract.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { archiveFiles, ensureArchive, hash, readMember, refactorDir } from './releases.mjs' + +const workspace = path.dirname(refactorDir.replace(/[\\/]$/u, '')) + +function gitText(commit, file) { + return execFileSync('git', ['show', `${commit}:${file}`], { cwd: workspace, encoding: 'utf8' }).replaceAll('\r\n', '\n') +} + +function verifyManifestAssociation(association) { + const text = gitText(association.commit, association.file) + assert.equal(hash(text), association.sha256LF, association.file) + assert.equal(JSON.parse(text).version, association.version) +} + +export async function verifyChromecastContract() { + const baseline = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/chromecast-release.json'), 'utf8')) + const archives = new Map() + const releases = [baseline.release, ...baseline.previous] + assert.deepEqual(releases.map(release => release.version).sort(), [...baseline.registrySnapshot.stableVersions].sort()) + for (const release of releases) { + const archive = await ensureArchive(release) + assert.deepEqual(archiveFiles(archive), Object.keys(release.files).sort()) + for (const [file, expected] of Object.entries(release.files)) + assert.equal(hash(readMember(archive, file)), expected, `${release.version}/${file}`) + const manifest = JSON.parse(readMember(archive, 'package/package.json')) + assert.deepEqual(manifest, release.manifest) + assert.equal(manifest.name, release.name) + assert.equal(manifest.version, release.version) + for (const field of ['main', 'module', 'types', 'legacy']) { + assert.equal(manifest[field] ?? null, release.entrypoints[field], `${release.version}/${field}`) + if (manifest[field]) + assert(Object.hasOwn(release.files, `package/${manifest[field].replace(/^\.\//u, '')}`), `${release.version}/${field} member`) + } + // Check actual conditional export targets without inventing exports for 1.0.0. + function checkTargets(value) { + if (typeof value === 'string') + assert(Object.hasOwn(release.files, `package/${value.replace(/^\.\//u, '')}`), `${release.version}/${value}`) + else if (value && typeof value === 'object') + Object.values(value).forEach(checkTargets) + } + checkTargets(manifest.exports) + assert.equal(release.registrySourceManifest.commit, release.registryGitHead) + assert.equal(release.registrySourceManifest.versionMatchesRelease, release.registrySourceManifest.version === release.version) + assert.equal(release.historicalCore.commit, release.registryGitHead) + verifyManifestAssociation(release.registrySourceManifest) + verifyManifestAssociation(release.historicalCore) + for (const comparison of release.registrySourceComparison) { + const digest = hash(gitText(release.registryGitHead, comparison.file)) + assert.equal(digest, comparison.sha256LF) + const member = `package/${comparison.file.slice('packages/artplayer-plugin-chromecast/'.length)}` + assert.equal(comparison.matchesArchiveBytes, digest === release.files[member]) + } + archives.set(release.version, archive) + } + const sources = new Map() + for (const [file, expected] of Object.entries(baseline.source)) { + const source = gitText(baseline.sourceCommit, file) + assert.equal(hash(source), expected, file) + sources.set(file, source) + } + for (const file of baseline.observations.latestArchiveMatchesFrozenSourceFiles) { + const member = `package/${file.slice('packages/artplayer-plugin-chromecast/'.length)}` + assert.equal(hash(sources.get(file)), baseline.release.files[member]) + } + const icon = sources.get(baseline.thirdParty.icon.reference) + const iconPath = icon.match(/ { + const env = chromecastEnvironment(implementation, { script }) + const player = env.player() + const register = env.factory({ icon: 'cast' }) + assert.equal(typeof env.context.__onGCastApiAvailable, 'function') + assert.equal(env.scripts.length, 0) + const pending = register(player.art) + assert.equal(player.controls.length, 0) + assert.equal(env.scripts.length, 1) + env.installSdk() + env.context.__onGCastApiAvailable(true) + env.scripts[0].onload({ type: 'load' }) + const plugin = await pending + assert.deepEqual(Object.keys(plugin), ['name']) + assert.equal(plugin.name, 'artplayerPluginChromecast') + assert.equal(plugin.getCastState, undefined) + assert.equal(player.controls[0].name, 'chromecast') + assert.equal(player.controls[0].html, 'cast') + assert.equal(player.controls[0].click(), undefined) + await env.flush() + assert.equal(env.mediaLoads[0].media.contentId, '/video.mp4') + assert.equal(env.mediaLoads[0].media.contentType, 'video/mp4') + assert.equal(env.sdkListeners.size, 0) + }) + } + test(`Chromecast ${implementation.name}: existing SDK skips loading and live options select media without modern callbacks`, async () => { + const env = chromecastEnvironment(implementation) + env.installSdk() + const player = env.player('/movie.M3U8?token=one#position') + const events = [] + const option = { onCastStart: () => events.push('start') } + await env.factory(option)(player.art) + assert.equal(env.scripts.length, 0) + player.controls[0].click() + await env.flush() + assert.equal(env.mediaLoads[0].media.contentType, 'application/x-mpegURL') + option.url = '/custom' + option.mimeType = 'application/custom' + player.controls[0].click() + await env.flush() + assert.equal(env.mediaLoads.at(-1).media.contentId, '/custom') + assert.equal(env.mediaLoads.at(-1).media.contentType, 'application/custom') + assert.deepEqual(events, []) + assert.equal(player.art.notice.show, '') + }) + test(`Chromecast ${implementation.name}: script failure rejects registration without adding controls`, async () => { + const env = chromecastEnvironment(implementation) + const player = env.player() + const pending = env.factory({})(player.art) + const error = { type: 'error' } + env.scripts[0].onerror(error) + await assert.rejects(pending, value => value === error) + assert.equal(player.controls.length, 0) + }) + continue + } + for (const script of [false, true]) { + test(`Chromecast ${implementation.name}: ${script ? 'global' : 'CJS'} lazy registration and public extraction`, async () => { + const env = chromecastEnvironment(implementation, { script }) + const player = env.player() + const register = env.factory({ icon: 'cast' }) + assert.equal(env.scripts.length, 0) + assert.equal(player.controls.length, 0) + const pending = register(player.art) + assert.equal(typeof pending.then, 'function') + const plugin = await pending + assert.deepEqual(Object.keys(plugin), ['name', 'getCastState', 'isCasting']) + assert.equal(plugin.name, 'artplayerPluginChromecast') + assert.equal(plugin.getCastState(), null) + assert.equal(plugin.isCasting(), false) + assert.equal(player.controls[0].name, 'chromecast') + assert.equal(player.controls[0].position, 'right') + assert.equal(player.controls[0].tooltip, 'Chromecast') + assert.equal(player.controls[0].html, 'cast') + assert.equal(env.scripts.length, 0) + }) + } + + test(`Chromecast ${implementation.name}: session callback order, casting request and MIME remain observable`, async () => { + const env = chromecastEnvironment(implementation) + const player = env.player('/movie.M3U8?token=one#position') + const events = [] + const option = { sdk: '/local-sender.js', onStateChange: state => events.push(['state', state]), onCastAvailable: value => events.push(['available', value]), onCastStart: () => events.push(['start']) } + const plugin = await env.factory(option)(player.art) + const pending = player.controls[0].click() + assert.equal(env.scripts[0].src, '/local-sender.js') + const { session } = env.installSdk() + env.context.__onGCastApiAvailable(true) + env.sessionState('SESSION_STARTED', session) + await pending + await env.flush() + assert.equal(env.requests(), 0) + assert.equal(env.mediaLoads[0].media.contentId, '/movie.M3U8?token=one#position') + assert.equal(env.mediaLoads[0].media.contentType, 'application/x-mpegURL') + assert.equal(player.art.notice.show, 'Casting started') + assert.equal(plugin.getCastState(), 'SESSION_STARTED') + assert.equal(plugin.isCasting(), true) + assert.equal(player.icon.style.color, 'red') + assert.deepEqual(events, [['state', 'connected'], ['start']]) + env.emit('caststatechanged', { castState: 'NO_DEVICES_AVAILABLE' }) + env.emit('caststatechanged', { castState: 'NOT_CONNECTED' }) + assert.deepEqual(events.slice(-2), [['available', false], ['available', true]]) + option.url = 'https://media.test/custom' + option.mimeType = 'application/custom' + await player.controls[0].click() + await env.flush() + assert.equal(env.mediaLoads.at(-1).media.contentId, option.url) + assert.equal(env.mediaLoads.at(-1).media.contentType, option.mimeType) + assert.equal(env.scripts.length, 1) + }) + + test(`Chromecast ${implementation.name}: script failure preserves error identity and does not mark initialized`, async () => { + const env = chromecastEnvironment(implementation) + const player = env.player() + const failures = [] + const plugin = await env.factory({ onError: error => failures.push(error) })(player.art) + const pending = player.controls[0].click() + const error = { type: 'error', target: env.scripts[0] } + env.scripts[0].onerror(error) + await assert.rejects(pending, value => value === error) + assert.equal(failures[0], error) + assert.equal(player.art.notice.show, 'Failed to initialize Cast API') + assert.equal(plugin.isCasting(), false) + }) +} diff --git a/test/helpers/chromecast.js b/test/helpers/chromecast.js new file mode 100644 index 000000000..d0364bd70 --- /dev/null +++ b/test/helpers/chromecast.js @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict' +import vm from 'node:vm' +import { transform } from 'esbuild' +import { verifyChromecastContract } from '../../refactor/scripts/chromecast-contract.mjs' +import { readMember } from '../../refactor/scripts/releases.mjs' + +export async function chromecastHistorical() { + const { baseline, archives, sources } = await verifyChromecastContract() + const implementations = [] + for (const release of [baseline.release, ...baseline.previous]) { + for (const field of ['main', 'legacy']) + implementations.push({ name: `published-${release.version}-${field}`, code: readMember(archives.get(release.version), `package/${release.manifest[field].replace(/^\.\//u, '')}`).toString() }) + } + const prefix = 'packages/artplayer-plugin-chromecast' + const source = sources.get(`${prefix}/src/index.js`) + implementations.push({ name: 'frozen-source', code: (await transform(source, { format: 'cjs', target: 'es2020' })).code, scriptCode: (await transform(source, { format: 'iife', globalName: 'artplayerPluginChromecast', target: 'es2020' })).code }) + for (const suffix of ['js', 'legacy.js']) + implementations.push({ name: `frozen-workspace-${suffix}`, code: sources.get(`${prefix}/dist/artplayer-plugin-chromecast.${suffix}`) }) + return implementations +} + +export function chromecastEnvironment(implementation, { script = false } = {}) { + const scripts = [] + const icons = [] + const controls = [] + const logs = [] + const sdkListeners = new Map() + const pendingMedia = [] + const mediaLoads = [] + const sdkOptions = [] + let requests = 0 + let sdk + const context = vm.createContext({ + console: { log: (...args) => logs.push(args), warn: (...args) => logs.push(args), error: (...args) => logs.push(args) }, + document: { + createElement: tag => ({ tag, remove() { scripts.splice(scripts.indexOf(this), 1) } }), + body: { appendChild(element) { scripts.push(element) } }, + querySelector: selector => selector === '.art-icon-cast' ? icons[0] : null, + }, + }) + context.window = context + if (!script) { + context.module = { exports: {} } + context.exports = context.module.exports + } + vm.runInContext(script ? implementation.scriptCode || implementation.code : `(function () {\n${implementation.code}\n}).call(globalThis)`, context, { timeout: 1000 }) + const exported = script ? context.artplayerPluginChromecast : context.module.exports + const factory = typeof exported === 'function' ? exported : exported.default + assert.equal(typeof factory, 'function') + + function player(url = '/video.mp4') { + const listeners = new Map() + const localControls = [] + const icon = { style: {} } + icons.push(icon) + const art = { + option: { url }, + notice: { show: '' }, + isDestroy: false, + template: { $player: { querySelector: () => icon } }, + controls: { + add(option) { + controls.push(option) + localControls.push(option) + return { querySelector: () => icon } + }, + remove(name) { + const index = localControls.findIndex(control => control.name === name) + if (index >= 0) + localControls.splice(index, 1) + }, + }, + on(name, callback) { listeners.set(name, [...listeners.get(name) || [], callback]) }, + off(name, callback) { listeners.set(name, (listeners.get(name) || []).filter(value => value !== callback)) }, + } + return { art, icon, controls: localControls, listeners, destroy() { + art.isDestroy = true + for (const callback of listeners.get('destroy') || []) callback() + } } + } + + // Observe SDK-returned promise chains without changing the plugin's Promise globals. + function watched(promise) { + pendingMedia.push(promise) + promise.catch(() => {}) + return { then: (...args) => watched(promise.then(...args)), catch: (...args) => watched(promise.catch(...args)) } + } + + function installSdk({ requestResult, requestError, loadError } = {}) { + const session = { loadMedia(request) { + mediaLoads.push(request) + return watched(loadError ? Promise.reject(loadError) : Promise.resolve()) + } } + const castContext = { + setOptions(value) { sdkOptions.push(value) }, + addEventListener(name, listener) { sdkListeners.set(name, [...sdkListeners.get(name) || [], listener]) }, + removeEventListener(name, listener) { sdkListeners.set(name, (sdkListeners.get(name) || []).filter(value => value !== listener)) }, + getCurrentSession: () => session, + requestSession() { + requests++ + return requestError ? Promise.reject(requestError) : Promise.resolve(requestResult) + }, + } + context.chrome = { cast: { AutoJoinPolicy: { ORIGIN_SCOPED: 'origin_scoped' }, media: { + DEFAULT_MEDIA_RECEIVER_APP_ID: 'CC1AD845', + MediaInfo: class { + constructor(contentId, contentType) { + this.contentId = contentId + this.contentType = contentType + } + }, + LoadRequest: class { + constructor(media) { this.media = media } + }, + } } } + context.cast = { framework: { + CastContext: { getInstance: () => castContext }, + CastContextEventType: { SESSION_STATE_CHANGED: 'sessionstatechanged', CAST_STATE_CHANGED: 'caststatechanged' }, + SessionState: Object.fromEntries(['NO_SESSION', 'SESSION_STARTING', 'SESSION_STARTED', 'SESSION_ENDING', 'SESSION_ENDED', 'SESSION_RESUMED'].map(value => [value, value])), + CastState: Object.fromEntries(['NO_DEVICES_AVAILABLE', 'NOT_CONNECTED', 'CONNECTING', 'CONNECTED'].map(value => [value, value])), + } } + sdk = { session, castContext } + return sdk + } + function emit(name, value) { + for (const callback of sdkListeners.get(name) || []) callback(value) + } + function sessionState(state, session = sdk.session) { + emit('sessionstatechanged', { sessionState: state, session }) + } + async function flush() { + for (let index = 0; index < 8; index++) await Promise.resolve() + } + return { context, exported, factory, scripts, icons, controls, logs, sdkListeners, mediaLoads, pendingMedia, sdkOptions, player, installSdk, emit, sessionState, flush, requests: () => requests } +}