diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml
index 931e5b012..c427dd809 100644
--- a/.github/workflows/nodejs.yml
+++ b/.github/workflows/nodejs.yml
@@ -135,7 +135,14 @@ jobs:
run: yarn test:react-consumer 2>&1 | tee refactor/.cache/ci/react-consumer.log
- name: Verify Vue installed consumers
run: yarn test:vue-consumer 2>&1 | tee refactor/.cache/ci/vue-consumer.log
- - name: Run all three engines
+ - name: Prepare installed browser plugin packages
+ # Node/framework consumers above retain their two-package fixture scope.
+ # These extra installed packages are required by the browser helpers.
+ # Other ecosystem installation/SDK gates remain tracked by CI-01.
+ run: |
+ yarn test:package --include=artplayer-plugin-ambilight,artplayer-proxy-canvas 2>&1 | tee refactor/.cache/ci/browser-packages.log
+ node --input-type=module -e 'import fs from "node:fs"; const { output } = JSON.parse(fs.readFileSync("refactor/.cache/packages/latest.json")); fs.appendFileSync(process.env.GITHUB_ENV, `ARTPLAYER_BROWSER_ARTIFACTS=${output}/browser-artifacts.json\n`);'
+ - name: Run installed browser checks
run: yarn test:browser 2>&1 | tee refactor/.cache/ci/browser.log
- name: Verify iframe cached history and interrupted navigation
run: yarn test:iframe-history 2>&1 | tee refactor/.cache/ci/iframe-history.log
diff --git a/packages/artplayer-plugin-ambilight/ARCHITECTURE.md b/packages/artplayer-plugin-ambilight/ARCHITECTURE.md
index 9913635bd..5f7ea9d0e 100644
--- a/packages/artplayer-plugin-ambilight/ARCHITECTURE.md
+++ b/packages/artplayer-plugin-ambilight/ARCHITECTURE.md
@@ -89,5 +89,15 @@ resized output geometry. Browser tests play real video with the actual workspace
proxy and paint a uniform nine-color palette through its public post-processing
callback; getImageData is native. Actual core 5.1.7 has no proxy option and uses
VIDEO, so its case checks that capability boundary and native playback. Only 5.4.0
-and candidate cases validate the canvas proxy combination. These tests do not
-establish that the unrefactored proxy's own async draw loop is terminally safe.
+and candidate cases validate the canvas proxy combination. The combination now
+observes both native RAF owners and verifies that no proxy callback runs after
+destroy. Canvas-specific tests additionally cover paused seeks, source switching,
+native pixels and destruction inside the post-processing callback.
+
+For installed-package verification run
+`yarn test:package --include=artplayer-plugin-ambilight,artplayer-proxy-canvas`,
+set `ARTPLAYER_BROWSER_ARTIFACTS` to its `browser-artifacts.json`, then run the
+Ambilight lifecycle/proxy and Canvas lifecycle browser suites. Helpers verify the
+installed hashes and current package/build inputs; an explicit artifact map never
+falls back to a source build. This supplements source tests. It does not replace
+physical mobile devices, npm entrypoint/type matrices or the remaining 05/06 gates.
diff --git a/refactor/ambilight-validation.md b/refactor/ambilight-validation.md
index d8893949c..8b8b1f7aa 100644
--- a/refactor/ambilight-validation.md
+++ b/refactor/ambilight-validation.md
@@ -1,5 +1,19 @@
# Ambilight 测试维护
+## 已安装产物检查点
+
+`yarn test:package --include=artplayer-plugin-ambilight,artplayer-proxy-canvas`
+生成包含两包的实际隔离安装及 browser-artifacts.json。把该路径传给
+ARTPLAYER_BROWSER_ARTIFACTS 后,Ambilight/Canvas helper 校验当前源码与构建快照
+及安装文件摘要,不回退内存构建。原生生命周期/代理组合测试可直接复用;同目录
+的 legacy map 必须指向 tarball 中已登记的 legacy 文件。
+
+现代45项、增强组合销毁断言后的现代9项、legacy45项三引擎全部通过;49+57+10项
+专项/工程检查通过。两个原生 RAF owner 的销毁后归零和 proxy 绘制停止都有断言。
+见[安装证据](baselines/ambilight-installed-validation.json)与
+[检查点记录](changes/2026-09-14-PKG-AMBILIGHT-05-installed-combination.md)。
+05仍doing,物理移动设备与完整分发验收未完成;以下早期记录保留其当时范围。
+
先读 [发布契约](baselines/ambilight-contract.md)。ambilight-release.json冻结两个真实
发布及工作区Git来源,verifyAmbilightContract逐成员核对,不随候选更新历史哈希。
diff --git a/refactor/baselines/ambilight-installed-validation.json b/refactor/baselines/ambilight-installed-validation.json
new file mode 100644
index 000000000..c6d0a338e
--- /dev/null
+++ b/refactor/baselines/ambilight-installed-validation.json
@@ -0,0 +1,732 @@
+{
+ "schemaVersion": 1,
+ "task": "PKG-AMBILIGHT-05",
+ "status": "checkpoint",
+ "environment": {
+ "node": "24.21.0",
+ "yarn": "1.22.22",
+ "browsers": {
+ "chromium": "153.0.8010.12",
+ "firefox": "155.0",
+ "webkit": "26.6"
+ },
+ "physicalDevices": false,
+ "remoteCI": false
+ },
+ "packageRuns": [
+ {
+ "path": "refactor/.cache/packages/run-IstjHZ",
+ "reportSha256": "ac1d90ef3fe400d81bfc3dda9ee3098c611d6bf0f8a51c59f233210b40459568"
+ },
+ {
+ "path": "refactor/.cache/packages/run-IIEOt4",
+ "reportSha256": "6efd5011592a1f2d81b785934c5365f6bb3cd4d05568a40c8465e9255b2df2a1"
+ }
+ ],
+ "packages": [
+ {
+ "name": "artplayer",
+ "archive": "artplayer.tgz",
+ "sha256": "a4e53c0c08850e02585c025581af30c8a91f3c8871471375d2228ffc246fd454",
+ "changedSinceInitialRun": [],
+ "runtime": {
+ "package/dist/artplayer.js": "9148a369636f814b03e6a1ed2a7db222a31b159f93e8a94d52c5a9dde2dfe6a6",
+ "package/dist/artplayer.legacy.js": "9bde35a6d64234cae15300e37b03204c075ef26126058f48bb3bf97d6e002449",
+ "package/dist/artplayer.mjs": "265bca0ad3f8373acc84333c19cdded0cbd6b309ede9e51c0bec35685c77eebd",
+ "package/dist/i18n/ar.js": "bd0bef65f4f7dc3f8ed562904cc6fa9071ff4537fb64c1e0fc3d554141bcd3cb",
+ "package/dist/i18n/ar.mjs": "59310b7bbeea83bbb7d73aadc5ca82a6ec269c3530d19aa741c94d5c64fc5da8",
+ "package/dist/i18n/cs.js": "6e2c93daba24c2e48dfa206bcf4f2f2a8a02713b856e404d2e4e4ecd8309ca2f",
+ "package/dist/i18n/cs.mjs": "c73fba18797d62d7b2581e67fe7340c35fe84510b2250ff1ecaad253d9cabbac",
+ "package/dist/i18n/es.js": "f4133ac6ea06b69a474b33c4a1809a4066e2376bb9464a3f3aa8330f01f1944a",
+ "package/dist/i18n/es.mjs": "1390bca3def2be115e811de337df0a0c3f63998e51359f283023246245c552a7",
+ "package/dist/i18n/fa.js": "8d3ef2e0f0cc0ea480c2167b6fcb87cdf444e90759c3519e8daa139fec576da4",
+ "package/dist/i18n/fa.mjs": "ab441e8a2d79043b29ceca87c783bf06874bb9d3a149c1604bc167eb481be2a9",
+ "package/dist/i18n/fr.js": "89dc4194a6c0a962b9ce756b52c90401c1acf3b159f265f3bc0d7d6d7d987f03",
+ "package/dist/i18n/fr.mjs": "99491878430fb7b6f2b7bf93da0588a831704b412e01116f23ba1d9d45863816",
+ "package/dist/i18n/id.js": "4c6dc86db741bcc2857df55a9d037e82e91d375fa6fd22e0562809ff621b92db",
+ "package/dist/i18n/id.mjs": "383e5803a6b6686c6de4e867770241de75b6f517dcca2bdedc6f2004bf6e1fb3",
+ "package/dist/i18n/pl.js": "3a38828b1c3d719c0aca9906cf6a46c23555a8411f8d57645f0e0ce8147dc664",
+ "package/dist/i18n/pl.mjs": "079ed96732979035297af387c64a7b99519b5a43107fb86735a654ea4fe8e9b5",
+ "package/dist/i18n/ru.js": "62fac9dddb302eb16382e6e9301fc4b2c66f5f69d4a358a26b67026a5fa4e787",
+ "package/dist/i18n/ru.mjs": "a893aeaee1d79409db72e324fbff200e3864f33ed168f04d91d25f7cf2efa765",
+ "package/dist/i18n/tr.js": "1492c36b069a7d73db4f3913bd18085faff7afd8b3c158138fe264e31600e38c",
+ "package/dist/i18n/tr.mjs": "a90478cac078836b76c2a98261907610fee07f3e4b28671b8e7dbf533778f918",
+ "package/dist/i18n/vi.js": "c7216ef7e608a79b7e2c479a07bf310d4ab79577947ac4a62d676faaced0e9cf",
+ "package/dist/i18n/vi.mjs": "66270fdfa0b5da396322d1769169b7df37a030ba15ac18eb30bd87b3af8074ec",
+ "package/dist/i18n/zh-tw.js": "11c49857d6d40a5ffd224ffa59c9026e71621b345f63c5a086326db80a22371a",
+ "package/dist/i18n/zh-tw.mjs": "a38e630cb96de96c15d824686aec750e25cf6c8ada01285a292442a81566314f"
+ }
+ },
+ {
+ "name": "artplayer-plugin-chapter",
+ "archive": "artplayer-plugin-chapter.tgz",
+ "sha256": "9c0a399b4b48db3f30bef69bfedd24245e9328f39151b1e1f670c4d357f4fb05",
+ "changedSinceInitialRun": [],
+ "runtime": {
+ "package/dist/artplayer-plugin-chapter.js": "179c3ea90ddc85da27b46f000110e32d464dfa5c8c394e98316dc1b75d6f2a72",
+ "package/dist/artplayer-plugin-chapter.legacy.js": "dade919ed044cbf46ac16e505f538f86a86edccf177da2e14d79d111559d4bef",
+ "package/dist/artplayer-plugin-chapter.mjs": "754fbcfc60add74d6b6ad3bc8db253df3eb14ace13ac8a5439be0592bd5512d0"
+ }
+ },
+ {
+ "name": "artplayer-plugin-ambilight",
+ "archive": "artplayer-plugin-ambilight.tgz",
+ "sha256": "a72d5805ca34ebcad615cfd155bafdbe8ddbdeb03e10a154142f8927be80f3b7",
+ "changedSinceInitialRun": [
+ "package/ARCHITECTURE.md"
+ ],
+ "runtime": {
+ "package/dist/artplayer-plugin-ambilight.js": "21ee5227f26cfff6e82ee6932f6eafc9741fa3be0ec2f5252a47b53ceea14599",
+ "package/dist/artplayer-plugin-ambilight.legacy.js": "c3ed1a54e44a0202811d70378cb19edf87b1ebd010d05fe96ebe64d0b4301d5d",
+ "package/dist/artplayer-plugin-ambilight.mjs": "ef126959819c4973da27dbb1e60ccccc54386c23f8c7750ce1ba4d38f306aaaa"
+ }
+ },
+ {
+ "name": "artplayer-proxy-canvas",
+ "archive": "artplayer-proxy-canvas.tgz",
+ "sha256": "09936e35e18188cab011f933d8063cac5e0f491a1960353ca9d5c65b9b42b65d",
+ "changedSinceInitialRun": [],
+ "runtime": {
+ "package/dist/artplayer-proxy-canvas.js": "a901e5c5fc00a64a6c2ae9d4b2b846950412710ec9672d30640d40987d4f9e73",
+ "package/dist/artplayer-proxy-canvas.legacy.js": "052263fdd7ab657a0183d874a706c2cf82527233c5e60b58158caa0677718b97",
+ "package/dist/artplayer-proxy-canvas.mjs": "66ca7271f52545a8796f563f2d5ca04581bed8d60d218463923325acf5b73ccf"
+ }
+ }
+ ],
+ "runtimeTypeScope": [
+ "artplayer",
+ "artplayer-plugin-chapter"
+ ],
+ "additionalBrowserPackages": [
+ "artplayer-plugin-ambilight",
+ "artplayer-proxy-canvas"
+ ],
+ "runtimeChecks": 36,
+ "legacyTypeModes": 5,
+ "preciseTypeModes": 8,
+ "sourceInputs": [
+ {
+ "path": "scripts/package-check.mjs",
+ "sha256": "d55266708fd2f6d23743daaf0a54c6543b8c2987503eb1fd54a05bbb11420a1a"
+ },
+ {
+ "path": "scripts/installed-artifacts.mjs",
+ "sha256": "0b1c85a48117551c7a3f06f5df8426fea19e302f874dd6ca2e44418dfed92b3b"
+ },
+ {
+ "path": "scripts/performance-artifacts.mjs",
+ "sha256": "4e9b9b3149b710eed20a1513f8bc1e13772ebcf14d84424b780addccdace8519"
+ },
+ {
+ "path": "test/helpers/ambilight.js",
+ "sha256": "56cd57e6cc52a4ef233492c45d9a51d16713e9fb40de123df1619395d0fb1723"
+ },
+ {
+ "path": "test/helpers/canvas.js",
+ "sha256": "fb1e86db48e098aa5cb0469241f814ddfaed2c35151f58028bcda19d8feed14a"
+ },
+ {
+ "path": "test/browser/ambilight-proxy.spec.js",
+ "sha256": "9ef43bb84ef4440bfa4257ec3998018a71e857d7bcb61c415923a8c0b2291e20"
+ },
+ {
+ "path": "test/browser/ambilight-lifecycle.spec.js",
+ "sha256": "321693a24d33f0c7aee02fd4137b55175386562a645ba138e15c3a51d6348d2c"
+ },
+ {
+ "path": "test/browser/canvas-lifecycle.spec.js",
+ "sha256": "5c50ad4f11d1df91c4247dc24c07edd2c9540e128e00fd07ee096406174512a7"
+ },
+ {
+ "path": ".github/workflows/nodejs.yml",
+ "sha256": "5e7d3cbaee3d96e9088b53205657614d8386a528a1a2fd9cc0687c90b4e7dfd3"
+ },
+ {
+ "path": "refactor/scripts/ci-workflow.mjs",
+ "sha256": "35148f6c9b75f3a4ee0fd20fd13211acddd45ca2f187f1a5c2581ce903796521"
+ },
+ {
+ "path": "refactor/scripts/ci-workflow.test.mjs",
+ "sha256": "923718fcc4897088ed92554cf4db5b95b82b8e1099db9c8b897806a87b08095a"
+ },
+ {
+ "path": "test/package-check.test.js",
+ "sha256": "e4e2010a00c6b6319ad36340a656e9d5f8821d76a30e87df6de7375a54cc2f35"
+ },
+ {
+ "path": "test/performance-report.test.js",
+ "sha256": "0304a90a07a86b2f71bde01ae28bf535ec7b9922cd463e16e066cea47a9af1d9"
+ }
+ ],
+ "browserRuns": [
+ {
+ "path": "refactor/.cache/ambilight05-installed-main-initial-browser/report.json",
+ "sha256": "bde43cc3bc56c2ff7ff128a85364e5d2f83fdc1a6dc5ae4bf869f78537b5281f",
+ "stats": {
+ "startTime": "2026-09-14T09:37:03.054Z",
+ "duration": 92475.65999999999,
+ "expected": 45,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ },
+ "cases": [
+ {
+ "browser": "chromium",
+ "title": "published-5.1.7: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published-5.1.7: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "candidate: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "candidate: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published-5.1.7: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published-5.1.7: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "candidate: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "candidate: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published-5.1.7: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published-5.1.7: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "candidate: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "candidate: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published-5.1.7: historical core has no proxy option and retains native Ambilight playback",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "candidate: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published-5.1.7: historical core has no proxy option and retains native Ambilight playback",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "candidate: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published-5.1.7: historical core has no proxy option and retains native Ambilight playback",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "candidate: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published: candidate canvas paints native pixels, resizes and switches media",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published: candidate paused seek draws the new video frame without restarting RAF",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published: candidate destroy inside callback leaves no media or native RAF and escaped play is inert",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "candidate: candidate canvas paints native pixels, resizes and switches media",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "candidate: candidate paused seek draws the new video frame without restarting RAF",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "candidate: candidate destroy inside callback leaves no media or native RAF and escaped play is inert",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published: candidate canvas paints native pixels, resizes and switches media",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published: candidate paused seek draws the new video frame without restarting RAF",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published: candidate destroy inside callback leaves no media or native RAF and escaped play is inert",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "candidate: candidate canvas paints native pixels, resizes and switches media",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "candidate: candidate paused seek draws the new video frame without restarting RAF",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "candidate: candidate destroy inside callback leaves no media or native RAF and escaped play is inert",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published: candidate canvas paints native pixels, resizes and switches media",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published: candidate paused seek draws the new video frame without restarting RAF",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published: candidate destroy inside callback leaves no media or native RAF and escaped play is inert",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "candidate: candidate canvas paints native pixels, resizes and switches media",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "candidate: candidate paused seek draws the new video frame without restarting RAF",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "candidate: candidate destroy inside callback leaves no media or native RAF and escaped play is inert",
+ "status": "expected"
+ }
+ ]
+ },
+ {
+ "path": "refactor/.cache/ambilight05-installed-legacy-browser/report.json",
+ "sha256": "7f8ffd2ddee4ecd026a674161f5055e367ffed2681f9a7a06b806a3a4ec9c797",
+ "stats": {
+ "startTime": "2026-09-14T09:40:52.738Z",
+ "duration": 93430.18999999999,
+ "expected": 45,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ },
+ "cases": [
+ {
+ "browser": "chromium",
+ "title": "published-5.1.7: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published-5.1.7: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "candidate: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "candidate: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published-5.1.7: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published-5.1.7: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "candidate: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "candidate: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published-5.1.7: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published-5.1.7: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "candidate: Ambilight ready starts native sampling and destruction releases only its frames and view",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "candidate: Ambilight recovers from native cross-origin taint after a same-origin source switch",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published-5.1.7: historical core has no proxy option and retains native Ambilight playback",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "candidate: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published-5.1.7: historical core has no proxy option and retains native Ambilight playback",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "candidate: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published-5.1.7: historical core has no proxy option and retains native Ambilight playback",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "candidate: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published: candidate canvas paints native pixels, resizes and switches media",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published: candidate paused seek draws the new video frame without restarting RAF",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published: candidate destroy inside callback leaves no media or native RAF and escaped play is inert",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "candidate: candidate canvas paints native pixels, resizes and switches media",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "candidate: candidate paused seek draws the new video frame without restarting RAF",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "candidate: candidate destroy inside callback leaves no media or native RAF and escaped play is inert",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published: candidate canvas paints native pixels, resizes and switches media",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published: candidate paused seek draws the new video frame without restarting RAF",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published: candidate destroy inside callback leaves no media or native RAF and escaped play is inert",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "candidate: candidate canvas paints native pixels, resizes and switches media",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "candidate: candidate paused seek draws the new video frame without restarting RAF",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "candidate: candidate destroy inside callback leaves no media or native RAF and escaped play is inert",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published: candidate canvas paints native pixels, resizes and switches media",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published: candidate paused seek draws the new video frame without restarting RAF",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published: candidate destroy inside callback leaves no media or native RAF and escaped play is inert",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "candidate: candidate canvas paints native pixels, resizes and switches media",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "candidate: candidate paused seek draws the new video frame without restarting RAF",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "candidate: candidate destroy inside callback leaves no media or native RAF and escaped play is inert",
+ "status": "expected"
+ }
+ ]
+ },
+ {
+ "path": "refactor/.cache/ambilight05-installed-main-final-browser/report.json",
+ "sha256": "606ac1f446d62c379bd37aff2ef2e23804ffc59572ec59ad6e8462a43e77bf13",
+ "stats": {
+ "startTime": "2026-09-14T09:43:17.253Z",
+ "duration": 18682.41,
+ "expected": 9,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ },
+ "cases": [
+ {
+ "browser": "chromium",
+ "title": "published-5.1.7: historical core has no proxy option and retains native Ambilight playback",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "published: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "chromium",
+ "title": "candidate: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published-5.1.7: historical core has no proxy option and retains native Ambilight playback",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "published: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "firefox",
+ "title": "candidate: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published-5.1.7: historical core has no proxy option and retains native Ambilight playback",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "published: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ },
+ {
+ "browser": "webkit",
+ "title": "candidate: Ambilight samples all nine canvas proxy regions after output resizing",
+ "status": "expected"
+ }
+ ]
+ }
+ ],
+ "unit": {
+ "ambilight": 49,
+ "canvas": 57,
+ "packageAndPerformance": 10,
+ "failures": 0,
+ "ciWorkflow": 32
+ },
+ "maps": [
+ {
+ "path": "refactor/.cache/packages/run-IIEOt4/browser-artifacts.json",
+ "sha256": "8a62e2910193654b510c948aece932b3328966650b27b997e20c80776909b36b"
+ },
+ {
+ "path": "refactor/.cache/packages/run-IIEOt4/browser-artifacts-legacy.json",
+ "sha256": "9eca95c02a0abb16cb9397b71ab71ca4c7080865c791eb7730a3a48cebd1d42d"
+ }
+ ],
+ "limits": [
+ "Only the Ambilight ARCHITECTURE.md changed between the two installations; runtime files are byte-identical.",
+ "36 runtime and 13 type mode groups are core/chapter consumers, not whole-ecosystem consumer acceptance.",
+ "Nine-color Canvas palette is painted through the public post-processing callback, not inferred from natural video frames.",
+ "Physical mobile devices, full package distribution, Canvas Document PiP/subtitle combinations and remote CI remain open."
+ ],
+ "checks": {
+ "strictToolchain": "passed",
+ "rootLint": {
+ "errors": 0,
+ "warnings": 1
+ },
+ "ciConfiguration": "passed",
+ "engineeringLog": "refactor/.cache/ambilight05-final-engineering.log"
+ }
+}
diff --git a/refactor/changes/2026-09-14-PKG-AMBILIGHT-05-installed-combination.md b/refactor/changes/2026-09-14-PKG-AMBILIGHT-05-installed-combination.md
new file mode 100644
index 000000000..9df890c70
--- /dev/null
+++ b/refactor/changes/2026-09-14-PKG-AMBILIGHT-05-installed-combination.md
@@ -0,0 +1,50 @@
+# PKG-AMBILIGHT-05 已安装产物组合检查点
+
+## 解决的证据缺口
+
+此前 Ambilight/Canvas 回归通过 helper 在内存中构建源码。即使指定核心的已安装
+artifact map,插件仍会构建源码,不能据此宣称整个组合验证了 npm 安装内容。
+本次扩展既有隔离打包流程,纳入有冻结发布契约的 Ambilight 和 Canvas:源快照
+重建、Yarn pack、仓库外离线安装、冻结重装、逐文件摘要验证后保存浏览器产物。
+
+使用 `yarn test:package --include=artplayer-plugin-ambilight,artplayer-proxy-canvas`。
+未审核的额外包与重复包名被拒绝;额外包模式不允许 `--release`。报告明确 36 项
+runtime 与 5 组旧/8 组精确类型检查仍只覆盖 core/chapter;另外两包的打包安装
+成功不自动成为其全部运行时/类型/发布验收。原有逐包类型矩阵仍独立保留。
+
+## 结构与兼容
+
+把性能模块中通用的安装来源验证提取到 `scripts/installed-artifacts.mjs`,原
+`verifyPerformanceArtifacts` 名称作为兼容导出保留。通用验证可指定包集合,检查
+安装文件摘要、当前 src/public/package.json 与构建快照,以及锁和构建工具输入。
+Ambilight/Canvas helper 收到明确 map 后只加载这些已验证文件;缺包、文件变化、
+源码漂移或同时启用 frozen-workspace 都失败,不再回退源码。未设置 map 的原源码
+测试保留。没有新增依赖、改变 bundler、库生产代码、公开 API 或声明。
+
+CI 在 Node/React/Vue 消费者之后、浏览器之前额外准备两包的安装产物并更新 map;
+避免默认 core/chapter map 触发缺包。性能兼容导出只返回其已校验的两包,不把
+额外插件自动加入旧性能基线。配置反例保护新增步骤不能缺失或被跳过;其他插件的
+installed/SDK 全矩阵仍归 CI-01,不宣称这次能使整个远端 CI 全绿。
+
+组合测试额外观察两个独立 RAF owner。真实视频解码后通过公开 Canvas callback
+绘制九色区域,验证输出 resize 和九格取色,再检查销毁后两队列为空且没有新绘制。
+这仍是真实 DOM/Canvas/getImageData/RAF;颜色来自显式后处理 callback,不声称是
+自然视频画面的取色结果。5.1.7 核心没有 proxy 配置,明确只测原生 video 能力边界。
+
+## 验证与余项
+
+结果、两次打包差异、产物映射与报告摘要见
+[机器证据](../baselines/ambilight-installed-validation.json)。现代首轮 45 项,增强
+组合销毁断言后的现代 9 项与 legacy 45 项均使用实际安装产物;legacy 指向同一
+安装目录的真实 legacy 文件,其摘要仍须匹配 tarball。三个引擎的旧核心是冻结
+已发布版本,新核心/插件/代理来自该次安装;没有用浏览器模拟替代 SDK 或真机。
+
+Ambilight 49、Canvas 57、打包/来源/性能回归 10 项通过。新增反例保护额外包不能
+误标 release-ready、显式 map 不得回退源码以及额外插件的源码漂移。包维护说明与
+测试流程同批更新。另32项CI配置回归通过,工程合计42项。首轮 lint 的新导入
+顺序/未用 import 等已修正;根lint保留1条既有生成声明unused-disable warning。
+
+PKG-AMBILIGHT-05 继续 doing:物理移动设备、剩余完整组合/分发仍缺证据;本次
+没有关闭 PKG-CANVAS-05 的字幕/Document PiP/真机要求,也不关闭 CI-01 的全包
+消费者与远端矩阵。不推送、部署或发布。回退本检查点可恢复原默认 core/chapter
+安装流程与源码 helper;注意不能再把旧 helper 的运行当成完整 installed 组合。
diff --git a/refactor/plan.md b/refactor/plan.md
index a5574e776..8fb06b4aa 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 256 项,范围 22 个包及工作区/示例。
-状态:todo 51 / doing 18 / blocked 0 / done 187 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
+状态:todo 50 / doing 19 / blocked 0 / done 187 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -155,7 +155,7 @@
| PKG-AMBILIGHT-03 | artplayer-plugin-ambilight
整理内部职责与资源 | PKG-AMBILIGHT-02, CORE-03, CORE-12 | 取色计算与绘帧分离,统一 RAF 和 DOM 清理 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | M | done |
| PKG-AMBILIGHT-04 | artplayer-plugin-ambilight
迁移自有源码和公开类型 | PKG-AMBILIGHT-03, ENG-04, ENG-06, CORE-07 | canvas 上下文、参数及 start/stop 的明确类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | M | done |
| PKG-AMBILIGHT-PROXY-01 | artplayer-plugin-ambilight, artplayer-proxy-canvas
修复Canvas代理输出尺寸与取色区域 | PKG-AMBILIGHT-04, CORE-22 | 按Canvas实际输出buffer取色,原生video保留intrinsic尺寸,明确5.1.7没有proxy配置 | 旧错误复现、三引擎实际代理九色区域对照及原生视频回归通过;不把本子项当作最终代理/设备验收 | M | done |
-| PKG-AMBILIGHT-05 | artplayer-plugin-ambilight
验证新旧核心和组合 | PKG-AMBILIGHT-04, CORE-22, PKG-AMBILIGHT-PROXY-01, PKG-CANVAS-04, PKG-FACTORY-01 | 原生 video、canvas proxy 的能力边界及销毁无帧循环 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | M | todo |
+| PKG-AMBILIGHT-05 | artplayer-plugin-ambilight
验证新旧核心和组合 | PKG-AMBILIGHT-04, CORE-22, PKG-AMBILIGHT-PROXY-01, PKG-CANVAS-04, PKG-FACTORY-01 | 原生 video、canvas proxy 的能力边界及销毁无帧循环 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | M | doing |
| PKG-AMBILIGHT-06 | artplayer-plugin-ambilight
验证分发并同步文档 | PKG-AMBILIGHT-05, ENG-07 | ambilight.js 示例及样式/分发验证 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | M | todo |
## 5 包迁移:artplayer-plugin-audio-track
@@ -534,7 +534,7 @@
- PKG-AMBILIGHT-03: [记录](changes/2026-09-12-PKG-AMBILIGHT-03-lifecycle.md) [记录](ambilight-validation.md) [记录](baselines/ambilight-lifecycle-validation.json)
- PKG-AMBILIGHT-04: [记录](changes/2026-09-12-PKG-AMBILIGHT-04-types.md) [记录](ambilight-validation.md) [记录](baselines/ambilight-types-validation.json)
- PKG-AMBILIGHT-PROXY-01: [记录](changes/2026-09-12-PKG-AMBILIGHT-PROXY-01-sampling.md) [记录](ambilight-validation.md) [记录](baselines/ambilight-proxy-validation.json)
-- PKG-AMBILIGHT-05: [记录](changes/2026-09-12-PKG-AMBILIGHT-PROXY-01-sampling.md) [记录](ambilight-validation.md)
+- PKG-AMBILIGHT-05: [记录](changes/2026-09-12-PKG-AMBILIGHT-PROXY-01-sampling.md) [记录](ambilight-validation.md) [记录](changes/2026-09-14-PKG-AMBILIGHT-05-installed-combination.md) [记录](baselines/ambilight-installed-validation.json)
- PKG-AUDIO-01: [记录](changes/2026-09-12-PKG-AUDIO-01-contract.md) [记录](baselines/audio-track-contract.md) [记录](baselines/audio-track-release.json)
- PKG-AUDIO-02: [记录](changes/2026-09-12-PKG-AUDIO-02-tests.md) [记录](audio-validation.md) [记录](baselines/audio-validation.json)
- PKG-AUDIO-03: [记录](changes/2026-09-12-PKG-AUDIO-03-lifecycle.md) [记录](baselines/audio-lifecycle-validation.json)
diff --git a/refactor/progress.md b/refactor/progress.md
index 6c97159b5..3a9977831 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -1,5 +1,21 @@
# 进度与证据
+## PKG-AMBILIGHT-05 已安装产物组合检查点(仍doing)
+
+隔离构建/打包流程显式纳入 Ambilight/Canvas,仓库外离线安装与冻结重装后校验
+全部文件;加载器指定 map 时校验安装摘要和源码/构建输入,拒绝缺包或源码 fallback。
+现代首轮45项、增强销毁检查后现代9项、legacy45项三引擎回归全部通过;覆盖实际
+播放/取色、尺寸变化、跨域恢复、暂停寻址、切源与销毁。组合额外验证插件/代理两
+个 RAF 队列均归零且无后续绘制。49+57+10项专项/工程测试、工具链与lint通过。
+CI在Node/框架消费者后准备额外浏览器安装,新增顺序/不可跳过校验;另32项CI回归
+通过。性能检查保持core/chapter两包范围,不向其他插件自动扩张性能基线。
+两次安装仅 Ambilight 架构文档变化,运行时文件一致;36 runtime、5旧+8精确类型
+组只属于 core/chapter,报告已明确范围,额外包模式禁止误用严格 release 准入。
+见[变更](changes/2026-09-14-PKG-AMBILIGHT-05-installed-combination.md)与
+[证据](baselines/ambilight-installed-validation.json)。256项:187 done、19 doing、50 todo。
+Ambilight移动真机、Canvas字幕/Document PiP/真机、完整分发与远端CI仍未完成;
+本检查点不关闭其他任务,不推送/部署/发布。下一步继续剩余组合与分包消费者验收。
+
## SITE-VCONSOLE-01 站点控制台生命周期修复完成
冻结 vConsole 3.15.0 原始 bundle,以严格 TS 和六个唯一构建替换点修复日志 RAF、
diff --git a/refactor/scripts/ci-workflow.mjs b/refactor/scripts/ci-workflow.mjs
index 0f809b6d6..fb4d03826 100644
--- a/refactor/scripts/ci-workflow.mjs
+++ b/refactor/scripts/ci-workflow.mjs
@@ -90,6 +90,10 @@ export function validateCIWorkflow(source) {
const vueIndex = browser.steps.findIndex(step => step.run === 'yarn test:vue-consumer 2>&1 | tee refactor/.cache/ci/vue-consumer.log')
assert(vueIndex > consumerIndex && !Object.hasOwn(browser.steps[vueIndex], 'if'), 'Run Vue installed consumers after restoring canonical Node')
assert(browser.steps.some(step => step.uses?.startsWith('actions/upload-artifact@') && step.if === 'always()' && step.with.path.split('\n').includes('refactor/.cache/vue-consumer-*/')), 'Retain Vue consumer failure evidence')
+ const extraIndex = browser.steps.findIndex(step => step.run?.startsWith('yarn test:package --include=artplayer-plugin-ambilight,artplayer-proxy-canvas '))
+ const engineIndex = browser.steps.findIndex(step => step.run?.startsWith('yarn test:browser '))
+ assert(extraIndex > Math.max(consumerIndex, reactIndex, vueIndex) && extraIndex < engineIndex, 'Prepare installed Ambilight/Canvas after consumer probes and before browser checks')
+ assert(!Object.hasOwn(browser.steps[extraIndex], 'if') && browser.steps[extraIndex].run.includes('GITHUB_ENV') && browser.steps[extraIndex].run.includes('ARTPLAYER_BROWSER_ARTIFACTS='), 'Always select the additional installed browser artifact map')
const pages = workflow.jobs.checks.steps.find(step => step.uses?.startsWith('actions/upload-pages-artifact@'))
assert.equal(pages?.if, 'inputs.pages-artifact && github.ref == \'refs/heads/master\' && matrix.os == \'ubuntu-latest\'', 'Only one trusted matrix leg can prepare Pages')
const prepareIndex = workflow.jobs.checks.steps.findIndex(step => step.id === 'pages')
diff --git a/refactor/scripts/ci-workflow.test.mjs b/refactor/scripts/ci-workflow.test.mjs
index dffbd130b..969a0dd99 100644
--- a/refactor/scripts/ci-workflow.test.mjs
+++ b/refactor/scripts/ci-workflow.test.mjs
@@ -33,6 +33,8 @@ for (const [name, mutate] of [
['conditional Node setup', w => w.jobs.checks.steps.find(s => s.uses?.startsWith('actions/setup-node@')).if = 'false'],
['wrong consumer Node version', w => w.jobs['browser-smoke'].steps.find(s => s.id === 'consumer-node-20').with['node-version'] = '24'],
['skipped installed consumer', w => w.jobs['browser-smoke'].steps.find(s => s.run?.includes('--expected-node 22.12.0')).if = 'false'],
+ ['missing browser plugin installation', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:package --include=')).run = 'echo omitted'],
+ ['skipped browser plugin installation', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:package --include=')).if = 'false'],
['browser runtime not restored', w => w.jobs['browser-smoke'].steps.find(s => s.id === 'restore-canonical-node').with = { 'node-version': '22.12.0', 'package-manager-cache': false }],
['skipped React consumer', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:react-consumer')).if = 'false'],
['removed React consumer', w => w.jobs['browser-smoke'].steps = w.jobs['browser-smoke'].steps.filter(s => !s.run?.startsWith('yarn test:react-consumer'))],
diff --git a/refactor/tasks.json b/refactor/tasks.json
index c58f0d1a4..24bdc9b18 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -1584,13 +1584,15 @@
"PKG-CANVAS-04",
"PKG-FACTORY-01"
],
- "status": "todo",
+ "status": "doing",
"risk": "M",
"deliverable": "原生 video、canvas proxy 的能力边界及销毁无帧循环",
"acceptance": "最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成",
"evidence": [
"changes/2026-09-12-PKG-AMBILIGHT-PROXY-01-sampling.md",
- "ambilight-validation.md"
+ "ambilight-validation.md",
+ "changes/2026-09-14-PKG-AMBILIGHT-05-installed-combination.md",
+ "baselines/ambilight-installed-validation.json"
]
},
{
diff --git a/scripts/installed-artifacts.mjs b/scripts/installed-artifacts.mjs
new file mode 100644
index 000000000..9e5e6d7ab
--- /dev/null
+++ b/scripts/installed-artifacts.mjs
@@ -0,0 +1,67 @@
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import process from 'node:process'
+import { hash } from '../refactor/scripts/releases.mjs'
+
+const read = file => JSON.parse(fs.readFileSync(file, 'utf8'))
+const walk = directory => fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => entry.isDirectory() ? walk(path.join(directory, entry.name)) : [path.join(directory, entry.name)])
+function digest(file) {
+ const bytes = fs.readFileSync(file)
+ return hash(/\.(?:[cm]?[jt]s|json|css|less|html|svg|txt)$/.test(file) ? bytes.toString('utf8').replaceAll('\r\n', '\n') : bytes)
+}
+
+export function verifyInstalledArtifacts(root, mapFile, names = ['artplayer', 'artplayer-plugin-chapter']) {
+ assert(mapFile, 'Run yarn test:package and set ARTPLAYER_BROWSER_ARTIFACTS to its browser-artifacts.json')
+ const directory = path.dirname(path.resolve(mapFile))
+ const artifacts = read(mapFile)
+ const report = read(path.join(directory, 'report.json'))
+ assert.equal(report.task, 'ENG-07', 'Browser checks require isolated installation evidence')
+ assert.equal(report.knownTypeBlockers, 0)
+ assert.equal(report.node, process.versions.node, 'Build and browser-check Node versions differ')
+ const toolchain = {}
+ const currentManifest = read(path.join(root, 'package.json'))
+ const buildManifest = read(path.join(directory, 'build/package.json'))
+ for (const key of ['packageManager', 'devDependencies', 'resolutions'])
+ assert.deepEqual(buildManifest[key], currentManifest[key], `Build toolchain changed: ${key}`)
+ for (const file of ['yarn.lock', 'scripts/build.js', 'scripts/utils.js', 'scripts/projects.js']) {
+ toolchain[file] = digest(path.join(root, file))
+ assert.equal(digest(path.join(directory, 'build', file)), toolchain[file], `Stale build input: ${file}`)
+ }
+ const libraryInputs = location => Object.fromEntries(walk(path.join(location, 'scripts/library')).filter(file => /\.(?:[cm]?[jt]s|json)$/.test(file)).sort().map(file => [path.relative(location, file).replaceAll('\\', '/'), digest(file)]))
+ const currentLibrary = libraryInputs(root)
+ assert.deepEqual(libraryInputs(path.join(directory, 'build')), currentLibrary, 'Stale library build inputs; rerun yarn test:package')
+ Object.assign(toolchain, currentLibrary)
+ const inputs = []
+ for (const name of names) {
+ const pkg = report.packages.find(pkg => pkg.name === name)
+ assert(pkg && artifacts[name], `Missing installed package: ${name}`)
+ const file = path.resolve(directory, artifacts[name])
+ const member = `package/dist/${path.basename(file)}`
+ assert.equal(hash(fs.readFileSync(file)), pkg.files[member], `Installed browser artifact changed: ${name}`)
+ const sourceHashes = {}
+ for (const folder of ['src', 'public']) {
+ const current = path.join(root, 'packages', name, folder)
+ const snapshot = path.join(directory, 'build/packages', name, folder)
+ if (!fs.existsSync(current)) {
+ assert(!fs.existsSync(snapshot), `Source directory was removed: ${name}/${folder}`)
+ continue
+ }
+ const inventory = location => Object.fromEntries(walk(location).map(file => [path.relative(location, file).replaceAll('\\', '/'), digest(file)]))
+ const expected = inventory(current)
+ assert.deepEqual(inventory(snapshot), expected, `Stale package source: ${name}/${folder}; rerun yarn test:package`)
+ Object.assign(sourceHashes, Object.fromEntries(Object.entries(expected).map(([file, digest]) => [`${folder}/${file}`, digest])))
+ }
+ for (const file of ['package.json', 'THIRD_PARTY_NOTICES', 'tsconfig.json']) {
+ const current = path.join(root, 'packages', name, file)
+ const snapshot = path.join(directory, 'build/packages', name, file)
+ assert.equal(fs.existsSync(current), fs.existsSync(snapshot), `Package input removed or added: ${name}/${file}`)
+ if (fs.existsSync(current)) {
+ sourceHashes[file] = digest(current)
+ assert.equal(digest(snapshot), sourceHashes[file], `Stale package input: ${name}/${file}`)
+ }
+ }
+ inputs.push({ name, file, member, sha256: pkg.files[member], archiveSha256: pkg.sha256, sourceHashes })
+ }
+ return { directory, inputs, toolchain, packages: report.packages.filter(pkg => names.includes(pkg.name)) }
+}
diff --git a/scripts/package-check.mjs b/scripts/package-check.mjs
index acfce69ea..55ca9851b 100644
--- a/scripts/package-check.mjs
+++ b/scripts/package-check.mjs
@@ -5,7 +5,7 @@ import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { ensureArchive, hash, readMember } from '../refactor/scripts/releases.mjs'
-import { consumerDirectory, names, readJson, removeConsumer, run, runtimeConsumer, typeConsumers, workspace, writeJson } from './package-consumer.mjs'
+import { consumerDirectory, names as defaultNames, readJson, removeConsumer, run, runtimeConsumer, typeConsumers, workspace, writeJson } from './package-consumer.mjs'
export function checkFiles(manifest, files, historical = []) {
for (const field of ['main', 'module', 'types', 'legacy']) {
@@ -67,10 +67,23 @@ export async function publishedConsumer() {
}
}
-export async function checkPackages({ release = false } = {}) {
+export async function checkPackages({ release = false, include = [] } = {}) {
+ assert(!release || include.length === 0, 'Additional package browser preparation is not full release acceptance')
assert.equal(process.env.npm_config_user_agent?.split(' ')[0], 'yarn/1.22.22', 'Run yarn test:package with the pinned Yarn')
const yarn = process.env.npm_execpath
assert(yarn && fs.existsSync(yarn), 'Missing Yarn executable')
+ const supported = {
+ 'artplayer-plugin-ambilight': async () => (await import('../refactor/scripts/ambilight-contract.mjs')).verifyAmbilightContract(),
+ 'artplayer-proxy-canvas': async () => (await import('../refactor/scripts/canvas-contract.mjs')).verifyCanvasContract(),
+ }
+ assert(new Set(include).size === include.length, 'Duplicate additional package')
+ const historicalFiles = new Map()
+ for (const name of include) {
+ assert(Object.hasOwn(supported, name), `Additional package needs a reviewed contract: ${name}`)
+ const contract = await supported[name]()
+ historicalFiles.set(name, Object.keys(contract.baseline.release.files))
+ }
+ const names = [...defaultNames, ...include]
const parent = path.join(workspace, 'refactor/.cache/packages')
fs.mkdirSync(parent, { recursive: true })
const output = fs.mkdtempSync(path.join(parent, 'run-'))
@@ -98,7 +111,7 @@ export async function checkPackages({ release = false } = {}) {
const manifest = JSON.parse(readMember(archive, 'package/package.json'))
assert.equal(manifest.name, name)
assert(!['preinstall', 'install', 'postinstall'].some(hook => manifest.scripts?.[hook]), 'Add lifecycle fixtures before accepting install hooks')
- checkFiles(manifest, files, Object.keys(baseline.releases.find(r => r.name === name).files))
+ checkFiles(manifest, files, historicalFiles.get(name) || Object.keys(baseline.releases.find(r => r.name === name).files))
packages.push({ name, version: manifest.version, archive: path.basename(archive), sha256: hash(fs.readFileSync(archive)), files: Object.fromEntries(files.map(file => [file, hash(readMember(archive, file))])) })
}
writeJson(path.join(installed, 'package.json'), { private: true, name: 'artplayer-isolated-consumer', dependencies: Object.fromEntries(packages.map(pkg => [pkg.name, `file:${path.join(output, pkg.archive).replaceAll('\\', '/')}`])) })
@@ -123,11 +136,13 @@ export async function checkPackages({ release = false } = {}) {
const preciseTypes = typeConsumers(installed, { precise: true })
const source = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: workspace, encoding: 'utf8' }).trim()
const publishedPackages = baseline.releases.map(({ name, version, files }) => ({ name, version, files }))
- const report = { task: 'ENG-07', capturedAt: new Date().toISOString(), source, toolchain: { yarn: '1.22.22', yarnPath: yarn }, node: process.versions.node, packages, runtime, publishedPackages, publishedRuntime: oldRuntime, types, preciseTypes, knownRuntimeBlockers: runtime.observations.defaultsWithoutNavigator.resolved ? 0 : 1, knownTypeBlockers: [...types, ...preciseTypes].reduce((sum, result) => sum + result.diagnostics.length, 0) }
+ const report = { task: 'ENG-07', capturedAt: new Date().toISOString(), source, toolchain: { yarn: '1.22.22', yarnPath: yarn }, node: process.versions.node, packages, runtimeScope: defaultNames, typeScope: defaultNames, additionalBrowserPackages: include, runtime, publishedPackages, publishedRuntime: oldRuntime, types, preciseTypes, knownRuntimeBlockers: runtime.observations.defaultsWithoutNavigator.resolved ? 0 : 1, knownTypeBlockers: [...types, ...preciseTypes].reduce((sum, result) => sum + result.diagnostics.length, 0) }
writeJson(path.join(output, 'report.json'), report)
writeJson(path.join(output, 'browser-artifacts.json'), artifacts)
writeJson(path.join(parent, 'latest.json'), { output: path.relative(workspace, output).replaceAll('\\', '/') })
console.log(`Installed tarball contracts passed: ${runtime.checks.length} runtime checks; ${types.filter(t => !t.diagnostics.length).length}/${types.length} legacy type modes; ${preciseTypes.filter(t => !t.diagnostics.length).length}/${preciseTypes.length} precise type modes. Report: ${output}`)
+ if (include.length)
+ console.log(`Runtime/type fixture scope: ${defaultNames.join(', ')}. Additional packages require their own consumer and browser acceptance: ${include.join(', ')}.`)
if (release) {
assert.equal(report.knownTypeBlockers, 0, 'Known type blockers remain; this candidate is not release-ready')
assert.equal(report.knownRuntimeBlockers, 0, 'Known runtime blockers remain; this candidate is not release-ready')
@@ -141,7 +156,8 @@ export async function checkPackages({ release = false } = {}) {
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
- checkPackages({ release: process.argv.includes('--release') }).catch((error) => {
+ const include = process.argv.find(arg => arg.startsWith('--include='))?.slice('--include='.length).split(',') || []
+ checkPackages({ release: process.argv.includes('--release'), include }).catch((error) => {
console.error(error)
process.exitCode = 1
})
diff --git a/scripts/performance-artifacts.mjs b/scripts/performance-artifacts.mjs
index 894f017f7..08cafe601 100644
--- a/scripts/performance-artifacts.mjs
+++ b/scripts/performance-artifacts.mjs
@@ -1,71 +1,11 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
-import process from 'node:process'
-import { ensureArchive, hash, readMember } from '../refactor/scripts/releases.mjs'
+import { ensureArchive, readMember } from '../refactor/scripts/releases.mjs'
import { measureBytes } from '../refactor/scripts/sizes.mjs'
+export { verifyInstalledArtifacts as verifyPerformanceArtifacts } from './installed-artifacts.mjs'
const read = file => JSON.parse(fs.readFileSync(file, 'utf8'))
-const walk = directory => fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => entry.isDirectory() ? walk(path.join(directory, entry.name)) : [path.join(directory, entry.name)])
-function digest(file) {
- const bytes = fs.readFileSync(file)
- return hash(/\.(?:[cm]?[jt]s|json|css|less|html|svg|txt)$/.test(file) ? bytes.toString('utf8').replaceAll('\r\n', '\n') : bytes)
-}
-
-export function verifyPerformanceArtifacts(root, mapFile) {
- assert(mapFile, 'Run yarn test:package and set ARTPLAYER_BROWSER_ARTIFACTS to its browser-artifacts.json')
- const directory = path.dirname(path.resolve(mapFile))
- const artifacts = read(mapFile)
- const report = read(path.join(directory, 'report.json'))
- assert.equal(report.task, 'ENG-07', 'Performance requires isolated installation evidence')
- assert.equal(report.knownTypeBlockers, 0)
- assert.equal(report.node, process.versions.node, 'Build and benchmark Node versions differ')
- const toolchain = {}
- const currentManifest = read(path.join(root, 'package.json'))
- const buildManifest = read(path.join(directory, 'build/package.json'))
- for (const key of ['packageManager', 'devDependencies', 'resolutions'])
- assert.deepEqual(buildManifest[key], currentManifest[key], `Build toolchain changed: ${key}`)
- for (const file of ['yarn.lock', 'scripts/build.js', 'scripts/utils.js', 'scripts/projects.js']) {
- toolchain[file] = digest(path.join(root, file))
- assert.equal(digest(path.join(directory, 'build', file)), toolchain[file], `Stale build input: ${file}`)
- }
- const libraryInputs = location => Object.fromEntries(walk(path.join(location, 'scripts/library')).filter(file => /\.(?:[cm]?[jt]s|json)$/.test(file)).sort().map(file => [path.relative(location, file).replaceAll('\\', '/'), digest(file)]))
- const currentLibrary = libraryInputs(root)
- assert.deepEqual(libraryInputs(path.join(directory, 'build')), currentLibrary, 'Stale library build inputs; rerun yarn test:package')
- Object.assign(toolchain, currentLibrary)
- const inputs = []
- for (const name of ['artplayer', 'artplayer-plugin-chapter']) {
- const pkg = report.packages.find(pkg => pkg.name === name)
- assert(pkg && artifacts[name], `Missing installed package: ${name}`)
- const file = path.resolve(directory, artifacts[name])
- const member = `package/dist/${path.basename(file)}`
- assert.equal(hash(fs.readFileSync(file)), pkg.files[member], `Installed performance artifact changed: ${name}`)
- const sourceHashes = {}
- for (const folder of ['src', 'public']) {
- const current = path.join(root, 'packages', name, folder)
- const snapshot = path.join(directory, 'build/packages', name, folder)
- if (!fs.existsSync(current)) {
- assert(!fs.existsSync(snapshot), `Source directory was removed: ${name}/${folder}`)
- continue
- }
- const inventory = location => Object.fromEntries(walk(location).map(file => [path.relative(location, file).replaceAll('\\', '/'), digest(file)]))
- const expected = inventory(current)
- assert.deepEqual(inventory(snapshot), expected, `Stale package source: ${name}/${folder}; rerun yarn test:package`)
- Object.assign(sourceHashes, Object.fromEntries(Object.entries(expected).map(([file, digest]) => [`${folder}/${file}`, digest])))
- }
- for (const file of ['package.json', 'THIRD_PARTY_NOTICES', 'tsconfig.json']) {
- const current = path.join(root, 'packages', name, file)
- const snapshot = path.join(directory, 'build/packages', name, file)
- assert.equal(fs.existsSync(current), fs.existsSync(snapshot), `Package input removed or added: ${name}/${file}`)
- if (fs.existsSync(current)) {
- sourceHashes[file] = digest(current)
- assert.equal(digest(snapshot), sourceHashes[file], `Stale package input: ${name}/${file}`)
- }
- }
- inputs.push({ name, file, member, sha256: pkg.files[member], archiveSha256: pkg.sha256, sourceHashes })
- }
- return { directory, inputs, toolchain, packages: report.packages }
-}
export async function compareArtifactSizes(root, artifacts) {
const releases = read(path.join(root, 'refactor/baselines/releases.json')).releases
diff --git a/test/browser/ambilight-proxy.spec.js b/test/browser/ambilight-proxy.spec.js
index 72cee6d6c..dafa0225a 100644
--- a/test/browser/ambilight-proxy.spec.js
+++ b/test/browser/ambilight-proxy.spec.js
@@ -11,8 +11,20 @@ for (const core of ['published-5.1.7', 'published', 'candidate']) {
const scenario = core === 'published-5.1.7' ? 'historical core has no proxy option and retains native Ambilight playback' : 'Ambilight samples all nine canvas proxy regions after output resizing'
test(`${core}: ${scenario}`, async ({ page }, testInfo) => {
await page.goto(`/test/player.html?core=${core}`)
- for (const [name, source] of [['ambilightFactory', implementation.source], ['canvasProxy', proxy]])
- await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${source}; window.${name} = module.exports.default || module.exports; })();` })
+ for (const [name, source] of [['ambilightFactory', implementation.source], ['canvasProxy', proxy]]) {
+ await page.addScriptTag({ content: `(() => {
+ const pending = new Set();
+ const requestAnimationFrame = callback => {
+ const id = window.requestAnimationFrame(time => { pending.delete(id); callback(time); });
+ pending.add(id); return id;
+ };
+ const cancelAnimationFrame = id => { pending.delete(id); window.cancelAnimationFrame(id); };
+ const module = { exports: {} }; const exports = module.exports;
+ ${source};
+ window.${name} = module.exports.default || module.exports;
+ (window.combinationFrames ||= {})['${name}'] = pending;
+ })();` })
+ }
await page.evaluate(() => {
window.palette = ['rgb(255, 0, 0)', 'rgb(0, 255, 0)', 'rgb(0, 0, 255)', 'rgb(255, 255, 0)', 'rgb(0, 255, 255)', 'rgb(255, 0, 255)', 'rgb(120, 0, 0)', 'rgb(0, 120, 0)', 'rgb(0, 0, 120)']
window.drawCount = 0
@@ -61,8 +73,14 @@ for (const core of ['published-5.1.7', 'published', 'candidate']) {
expect(dimensions.canvas).not.toEqual(dimensions.video)
await testInfo.attach('ambilight-proxy-inputs', { contentType: 'application/json', body: JSON.stringify({ core, dimensions, plugin: hash(implementation.source), proxy: hash(proxySource), media: '/test/pattern.mp4', scope: 'real video decode and proxy post-processing callback paints a nine-cell palette using native Canvas' }) })
await expect.poll(() => page.evaluate(() => [...document.querySelectorAll('.artplayer-plugin-ambilight > div')].map(node => node.style.backgroundColor))).toEqual(await page.evaluate(() => window.palette))
- await page.evaluate(() => window.art.destroy())
+ const drawsAtDestroy = await page.evaluate(async () => {
+ window.art.destroy()
+ const draws = window.drawCount
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
+ return draws
+ })
expect(await page.locator('.artplayer-plugin-ambilight').count()).toBe(0)
+ expect(await page.evaluate(() => ({ draws: window.drawCount, pluginFrames: window.combinationFrames.ambilightFactory.size, proxyFrames: window.combinationFrames.canvasProxy.size }))).toEqual({ draws: drawsAtDestroy, pluginFrames: 0, proxyFrames: 0 })
})
}
diff --git a/test/helpers/ambilight.js b/test/helpers/ambilight.js
index 8f2b0b28e..a05ee1bf2 100644
--- a/test/helpers/ambilight.js
+++ b/test/helpers/ambilight.js
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict'
+import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
@@ -6,12 +7,19 @@ import vm from 'node:vm'
import { build, transform } from 'esbuild'
import { verifyAmbilightContract } from '../../refactor/scripts/ambilight-contract.mjs'
import { readMember } from '../../refactor/scripts/releases.mjs'
+import { verifyInstalledArtifacts } from '../../scripts/installed-artifacts.mjs'
import { getEntryFile } from '../../scripts/projects.js'
export async function ambilightCandidate() {
+ const root = fileURLToPath(new URL('../../', import.meta.url))
+ if (process.env.ARTPLAYER_BROWSER_ARTIFACTS) {
+ assert.notEqual(process.env.ARTPLAYER_AMBILIGHT_BASELINE, '1', 'Installed candidate cannot use the frozen workspace')
+ const { inputs } = verifyInstalledArtifacts(root, process.env.ARTPLAYER_BROWSER_ARTIFACTS, ['artplayer', 'artplayer-plugin-chapter', 'artplayer-plugin-ambilight'])
+ const input = inputs.find(item => item.name === 'artplayer-plugin-ambilight')
+ return { name: 'candidate-installed', source: fs.readFileSync(input.file, 'utf8'), format: 'artifact', provenance: input }
+ }
if (process.env.ARTPLAYER_AMBILIGHT_BASELINE === '1')
return (await ambilightHistorical()).find(item => item.name === 'frozen-workspace')
- const root = fileURLToPath(new URL('../../', import.meta.url))
const result = await build({ entryPoints: [getEntryFile(path.join(root, 'packages/artplayer-plugin-ambilight'))], bundle: true, write: false, platform: 'browser', format: 'cjs', target: 'es2020' })
return { name: 'candidate-source', source: result.outputFiles[0].text, format: 'artifact' }
}
diff --git a/test/helpers/canvas.js b/test/helpers/canvas.js
index 0c19ba842..6e395b70d 100644
--- a/test/helpers/canvas.js
+++ b/test/helpers/canvas.js
@@ -1,3 +1,5 @@
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
@@ -5,12 +7,19 @@ import vm from 'node:vm'
import { build, transform } from 'esbuild'
import { verifyCanvasContract } from '../../refactor/scripts/canvas-contract.mjs'
import { readMember } from '../../refactor/scripts/releases.mjs'
+import { verifyInstalledArtifacts } from '../../scripts/installed-artifacts.mjs'
import { getEntryFile } from '../../scripts/projects.js'
export async function canvasCandidate() {
+ const root = fileURLToPath(new URL('../../', import.meta.url))
+ if (process.env.ARTPLAYER_BROWSER_ARTIFACTS) {
+ assert.notEqual(process.env.ARTPLAYER_CANVAS_BASELINE, '1', 'Installed candidate cannot use the frozen workspace')
+ const { inputs } = verifyInstalledArtifacts(root, process.env.ARTPLAYER_BROWSER_ARTIFACTS, ['artplayer', 'artplayer-plugin-chapter', 'artplayer-proxy-canvas'])
+ const input = inputs.find(item => item.name === 'artplayer-proxy-canvas')
+ return { name: 'candidate-installed', source: fs.readFileSync(input.file, 'utf8'), format: 'artifact', provenance: input }
+ }
if (process.env.ARTPLAYER_CANVAS_BASELINE === '1')
return (await canvasHistorical()).find(item => item.name === 'frozen-workspace')
- const root = fileURLToPath(new URL('../../', import.meta.url))
const result = await build({ entryPoints: [getEntryFile(path.join(root, 'packages/artplayer-proxy-canvas'))], bundle: true, write: false, platform: 'browser', format: 'cjs', target: 'es2020' })
return { name: 'candidate-source', source: result.outputFiles[0].text, format: 'artifact' }
}
diff --git a/test/package-check.test.js b/test/package-check.test.js
index 5f0b5b6e9..83ed960c3 100644
--- a/test/package-check.test.js
+++ b/test/package-check.test.js
@@ -1,11 +1,42 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
+import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- This fixture exercises the repository's Node test runner.
import { test } from 'node:test'
-import { checkFiles, publishedConsumer } from '../scripts/package-check.mjs'
+import { checkFiles, checkPackages, publishedConsumer } from '../scripts/package-check.mjs'
import { removeConsumer, runtimeConsumer } from '../scripts/package-consumer.mjs'
import { emitterContracts } from './contracts/emitter.js'
+import { ambilightCandidate } from './helpers/ambilight.js'
+import { canvasCandidate } from './helpers/canvas.js'
+
+test('Additional browser packages cannot be mistaken for full release consumer acceptance', async () => {
+ await assert.rejects(checkPackages({ release: true, include: ['artplayer-plugin-ambilight'] }), /not full release acceptance/)
+})
+
+test('Explicit installed plugin maps never fall back to source or frozen workspace', async () => {
+ const keys = ['ARTPLAYER_BROWSER_ARTIFACTS', 'ARTPLAYER_AMBILIGHT_BASELINE', 'ARTPLAYER_CANVAS_BASELINE']
+ const previous = Object.fromEntries(keys.map(key => [key, process.env[key]]))
+ try {
+ process.env.ARTPLAYER_BROWSER_ARTIFACTS = path.resolve('refactor/.cache/absent-installed-plugin-map.json')
+ assert(!fs.existsSync(process.env.ARTPLAYER_BROWSER_ARTIFACTS))
+ delete process.env.ARTPLAYER_AMBILIGHT_BASELINE
+ delete process.env.ARTPLAYER_CANVAS_BASELINE
+ await assert.rejects(ambilightCandidate(), { code: 'ENOENT' })
+ await assert.rejects(canvasCandidate(), { code: 'ENOENT' })
+ process.env.ARTPLAYER_AMBILIGHT_BASELINE = '1'
+ process.env.ARTPLAYER_CANVAS_BASELINE = '1'
+ await assert.rejects(ambilightCandidate(), /cannot use the frozen workspace/)
+ await assert.rejects(canvasCandidate(), /cannot use the frozen workspace/)
+ }
+ finally {
+ for (const key of keys) {
+ if (previous[key] === undefined)
+ delete process.env[key]
+ else process.env[key] = previous[key]
+ }
+ }
+})
test('Package checks reject missing files, missing wildcard exports and internal configuration', () => {
const manifest = { name: 'fixture', main: './dist/main.js', module: './dist/main.mjs', types: './types/main.d.ts', legacy: './dist/legacy.js', exports: { '.': './dist/main.js', './lang/*': './dist/lang/*.js' } }
diff --git a/test/package/README.md b/test/package/README.md
index 8b2c00b5e..374348c17 100644
--- a/test/package/README.md
+++ b/test/package/README.md
@@ -1,8 +1,14 @@
# Installed package checks
Run `yarn test:package` with the pinned Node and Yarn after a frozen install. The
-initial scope is core and chapter; extend `names` in `scripts/package-consumer.mjs`
-and add package-specific consumers when migrating another package.
+default consumer scope is core and chapter. For Ambilight/Canvas browser work,
+run `yarn test:package --include=artplayer-plugin-ambilight,artplayer-proxy-canvas`.
+These two additional packages have reviewed published contracts and use the same
+source snapshot/build/pack/offline-install/frozen-reinstall pipeline. Installed
+files are copied only after archive hash checks. The report explicitly separates
+the core/chapter runtime/type scope from the additional browser package scope;
+`--release` rejects additional packages until complete consumer coverage exists.
+Other packages need a reviewed contract and their own consumers before inclusion.
`scripts/package-check.mjs` copies source into an ignored build snapshot, rebuilds
all three formats and core languages using repository scripts, and runs Yarn pack.
@@ -72,6 +78,13 @@ and run `yarn test:browser`. The service then uses installed package bytes witho
falling back to source. CI performs this sequence and uploads only reports and
artifacts, excluding the build snapshot's node_modules link.
+`scripts/installed-artifacts.mjs` verifies selected packages against the installed
+report and the current source/build inputs; performance retains its existing
+two-package wrapper. Ambilight and Canvas browser helpers use this verifier when
+an artifact map is supplied, including optional legacy maps pointing to checked
+legacy members in the same installation. Missing packages, stale source, modified
+bundles or contradictory frozen-workspace flags fail instead of rebuilding source.
+
`test/package-check.test.js` verifies missing targets, leaked configuration and
removed historical files, then deliberately removes an actual published default
export and required entry in an isolated consumer to prove runtime failures are
diff --git a/test/performance-report.test.js b/test/performance-report.test.js
index 6a6991e4c..9bc97c9cd 100644
--- a/test/performance-report.test.js
+++ b/test/performance-report.test.js
@@ -6,6 +6,7 @@ import process from 'node:process'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { hash } from '../refactor/scripts/releases.mjs'
+import { verifyInstalledArtifacts } from '../scripts/installed-artifacts.mjs'
import { verifyPerformanceArtifacts } from '../scripts/performance-artifacts.mjs'
import { performanceHtml, performanceScript, waitForObservation } from '../scripts/performance-fixture.mjs'
import { validatePairedPerformance } from '../scripts/performance-report.mjs'
@@ -128,7 +129,7 @@ test('performance refuses stale sources, build tools and altered installed bundl
write(path.join(root, file), content)
write(path.join(snapshot, file), content)
}
- const names = ['artplayer', 'artplayer-plugin-chapter']
+ const names = ['artplayer', 'artplayer-plugin-chapter', 'artplayer-plugin-ambilight']
const packages = names.map((name) => {
const entry = `packages/${name}/src/index.ts`
write(path.join(root, entry), 'export default 1\n')
@@ -144,6 +145,13 @@ test('performance refuses stale sources, build tools and altered installed bundl
write(map, JSON.stringify(Object.fromEntries(names.map(name => [name, `artifacts/${name}/dist/${name}.js`]))))
write(path.join(output, 'report.json'), JSON.stringify({ task: 'ENG-07', knownTypeBlockers: 0, node: process.versions.node, packages }))
assert.equal(verifyPerformanceArtifacts(root, map).inputs.length, 2)
+ assert.equal(verifyPerformanceArtifacts(root, map).packages.length, 2)
+ assert.equal(verifyInstalledArtifacts(root, map, names).inputs.length, 3)
+ assert.throws(() => verifyInstalledArtifacts(root, map, [...names, 'artplayer-proxy-canvas']), /Missing installed package/)
+ const pluginSource = path.join(root, 'packages/artplayer-plugin-ambilight/src/index.ts')
+ write(pluginSource, 'export default 2\n')
+ assert.throws(() => verifyInstalledArtifacts(root, map, names), /Stale package source/)
+ write(pluginSource, 'export default 1\n')
const source = path.join(root, 'packages/artplayer/src/index.ts')
write(source, 'export default 2\n')
assert.throws(() => verifyPerformanceArtifacts(root, map), /Stale package source/)
@@ -158,5 +166,5 @@ test('performance refuses stale sources, build tools and altered installed bundl
assert.throws(() => verifyPerformanceArtifacts(root, map), /Stale library build inputs/)
fs.unlinkSync(path.join(snapshot, 'scripts/library/removed.ts'))
write(path.join(output, 'artifacts/artplayer/dist/artplayer.js'), 'changed bundle\n')
- assert.throws(() => verifyPerformanceArtifacts(root, map), /Installed performance artifact changed/)
+ assert.throws(() => verifyPerformanceArtifacts(root, map), /Installed browser artifact changed/)
})