diff --git a/package.json b/package.json index 75f14f389..4cca802c8 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", "typecheck": "node scripts/typecheck.mjs", - "test:unit": "node --test 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:unit": "node --test test/ambilight.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:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs", "test": "yarn test:node && yarn test:baseline", "test:browser": "playwright test", @@ -69,7 +69,8 @@ "check:commits": "node refactor/scripts/commit-audit.mjs", "check:impact": "node refactor/scripts/impact.mjs", "check:contracts": "node refactor/scripts/contracts.mjs", - "test:contracts": "node refactor/scripts/run-contracts.mjs" + "test:contracts": "node refactor/scripts/run-contracts.mjs", + "test:ambilight": "node --test test/ambilight.test.js" }, "browserslist": "last 1 Chrome version", "devDependencies": { diff --git a/refactor/ambilight-validation.md b/refactor/ambilight-validation.md index 248993bd3..6d688eaf3 100644 --- a/refactor/ambilight-validation.md +++ b/refactor/ambilight-validation.md @@ -11,3 +11,16 @@ 复现问题;04兼容类型及旧CJS.default;05/06做真实媒体、代理、浏览器和安装包验证。 stop保留最后画面,核心destroy终止资源。zIndex仍忽略,参数省略及两版声明差异分别 处理;Git关联不构成连续核心支持范围。 + +## 02历史回归 + +`yarn test:ambilight`运行30项Node回归并接入test:unit;正常参数/频率/暂停与已确认的 +历史问题分别断言。`yarn test:browser test/browser/ambilight.spec.js`运行三实现/三核心/ +三引擎真实媒体;核心标签published是实际npm5.4.0,published-5.1.7是实际npm5.1.7, +candidate是当前源码;以browser-evidence的manifest和实际version为准。 +跨origin使用同一服务的localhost与127.0.0.1,读像素抛原生SecurityError;换回同源 +后持续错误是待03修复的历史污染。Node零尺寸记录只证明未保护,未模拟为浏览器结论。 + +02最终30项Node、54项三引擎/三核心浏览器通过,未skip;新建Canvas能读取已恢复的 +同源视频,而旧插件Canvas仍抛SecurityError。主CI955项通过(另44项重复观察)。 +完整报告与输入来源见baselines/ambilight-behavior-validation.json。03开始修复这些问题。 diff --git a/refactor/baselines/ambilight-behavior-validation.json b/refactor/baselines/ambilight-behavior-validation.json new file mode 100644 index 000000000..9ae36cc96 --- /dev/null +++ b/refactor/baselines/ambilight-behavior-validation.json @@ -0,0 +1,109 @@ +{ + "schemaVersion": 1, + "task": "PKG-AMBILIGHT-02", + "status": "done", + "sourceCommit": "06032a6906aa2a5c59e421cd8076d70daf5f4801", + "checks": [ + { + "command": "node --test test/ambilight.test.js", + "result": "pass", + "tests": 30, + "scope": "Three frozen implementations; normal control/parameter semantics and historical failure assertions", + "log": "refactor/.cache/ambilight02-tests.log" + }, + { + "command": "playwright test test/browser/ambilight.spec.js", + "result": "pass", + "tests": 54, + "projects": [ + "chromium", + "firefox", + "webkit" + ], + "coreSources": [ + "npm 5.1.7", + "npm 5.4.0", + "candidate source" + ], + "skipped": 0, + "scope": "Native video/Canvas/RAF, local source switch, retained DOM and actual cross-origin taint; fresh native canvas reads recovered video while old plugin canvas still fails", + "report": "refactor/.cache/ambilight02-final-results/report.json", + "archivedResults": "refactor/.cache/ambilight02-final-results/results", + "originalResultsPrefix": "refactor/.cache/browser/results", + "stats": { + "startTime": "2026-09-12T03:03:56.604Z", + "duration": 68280.7, + "expected": 54, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + } + }, + { + "command": "eslint test/ambilight.test.js test/browser/ambilight.spec.js --no-fix", + "result": "pass" + }, + { + "command": "yarn ci:check", + "result": "pass", + "tests": { + "unit": 862, + "engineering": 14, + "baseline": 79, + "mainTotal": 955, + "repeatedContractExecutions": 44 + }, + "log": "refactor/.cache/ambilight02-ci.log" + } + ], + "inputs": [ + { + "file": "test/ambilight.test.js", + "sha256LF": "886a617d1ddb800c9fd639e3a125b9d1788e67a287900b7d6fbf8f6324774c47" + }, + { + "file": "test/browser/ambilight.spec.js", + "sha256LF": "5035a3a38460d56747b3786299672e4dbde6543bd384fb3c7112e522dd2add7f" + }, + { + "file": "test/helpers/ambilight.js", + "sha256LF": "7775f147355d2aa08615a82079760924ae151beb7d9bdc94cccfd8195f5b19d8" + }, + { + "file": "package.json", + "sha256LF": "0d2d5c253b72588e48fe6e68ad7069b70945d8a6bcd6866bbca4516e8299721a" + }, + { + "file": "test/browser/server.mjs", + "sha256LF": "af513e1e64f875668b6a7cbd060276d85d930e0cc1715577e97907cc26c3b58d" + }, + { + "file": "refactor/baselines/ambilight-release.json", + "sha256LF": "6acbea40a26397ffa36549fbecb1a12c057cfb3fc932f7cb83181ad5fa37bd2f" + } + ], + "logs": [ + { + "file": "refactor/.cache/ambilight02-tests.log", + "sha256": "f90963c42d07326e72767e81a9a446891b234ce8e6295192a7bef57ca3be03bf" + }, + { + "file": "refactor/.cache/ambilight02-browser-final.log", + "sha256": "4f1f4ea230fc667f48a1de5a0890abd2f4945342f2760952e1f50d3847e1083b" + }, + { + "file": "refactor/.cache/ambilight02-lint-final.log", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "file": "refactor/.cache/ambilight02-ci.log", + "sha256": "b265745fbf3347d3c31c94e4a48a2e6006027afeb2239758f68a6976c553d8bd" + } + ], + "browserReportSha256": "7bd351759c2003cdc6c4d52dee7bdb4ec24f647ae19a91fa4e6f974b5edcd1c7", + "limitations": [ + "These are historical observations; candidate fixes remain PKG-AMBILIGHT-03", + "Controlled zero dimensions prove missing guards, not a specific native exception", + "No physical devices, canvas proxy support claim, full consumer type or final packed plugin acceptance" + ] +} diff --git a/refactor/changes/2026-09-12-PKG-AMBILIGHT-02-tests.md b/refactor/changes/2026-09-12-PKG-AMBILIGHT-02-tests.md new file mode 100644 index 000000000..f7436df37 --- /dev/null +++ b/refactor/changes/2026-09-12-PKG-AMBILIGHT-02-tests.md @@ -0,0 +1,19 @@ +# PKG-AMBILIGHT-02 历史行为与失败复现 + +沿用01两个实际发布及冻结工作区,30项Node测试分别验证频率/暂停/重复start-stop和 +历史异常路径。对id=0、销毁后逃逸start、取色异常卡住旧handle、空context/零尺寸、 +取色中stop/destroy重入、设置样式失败和已销毁host晚注册建立可重跑断言。 + +浏览器使用原生video、Canvas和RAF;三个核心(实际npm5.1.7、npm5.4.0及候选)、 +三插件实现、三引擎验证9格取色、暂停/停止保留颜色、切源和destroy(false)残留视图。 +127.0.0.1页面加载localhost同端口视频构成真实跨origin;手动start捕获原生SecurityError, +切回同源且媒体播放后仍复现同一错误,证明历史Canvas污染持续存在。没有拦截网络或 +模拟像素/原生异常;主动catch用于避免将预期旧缺陷变成浏览器未处理错误。 + +Node模拟器的零尺寸被允许,只证明缺少尺寸保护,不声称浏览器一定抛某种异常;真实 +浏览器结果与源文件/样本/引擎/Core版本由附件保存。默认全局CI仍执行全部必要门槛。 +专项、三引擎和完整CI结果见 [验证记录](../baselines/ambilight-behavior-validation.json)。 + +这些断言固定的是历史问题,03需另加候选应当修复的断言,不能改旧快照让缺陷消失。 +下一步拆分视图、取色和RAF/销毁归属,保留正常stop/配置/样式/返回行为;类型和最终 +分发仍归04/06,实际设备与代理能力归05。无生产源码变更、推送或发布。 diff --git a/refactor/plan.md b/refactor/plan.md index 017ce354a..21a37bb50 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -4,7 +4,7 @@ 基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 220 项,范围 22 个包及工作区/示例。 -状态:todo 129 / doing 6 / blocked 0 / done 85 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 128 / doing 6 / blocked 0 / done 86 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -149,7 +149,7 @@ | ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 | | --- | --- | --- | --- | --- | --- | --- | | PKG-AMBILIGHT-01 | artplayer-plugin-ambilight
核对包契约与历史用法 | BASE-05 | blur/opacity/frequency/duration、start/stop 与挂载样式 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | M | done | -| PKG-AMBILIGHT-02 | artplayer-plugin-ambilight
建立特有行为与错误测试 | PKG-AMBILIGHT-01, ENG-03, ENG-05 | 跨域 canvas 失败、零尺寸、暂停、重复 start/stop 和 destroy | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | M | todo | +| PKG-AMBILIGHT-02 | artplayer-plugin-ambilight
建立特有行为与错误测试 | PKG-AMBILIGHT-01, ENG-03, ENG-05 | 跨域 canvas 失败、零尺寸、暂停、重复 start/stop 和 destroy | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | M | done | | PKG-AMBILIGHT-03 | artplayer-plugin-ambilight
整理内部职责与资源 | PKG-AMBILIGHT-02, CORE-03, CORE-12 | 取色计算与绘帧分离,统一 RAF 和 DOM 清理 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | M | todo | | PKG-AMBILIGHT-04 | artplayer-plugin-ambilight
迁移自有源码和公开类型 | PKG-AMBILIGHT-03, ENG-04, ENG-06, CORE-07 | canvas 上下文、参数及 start/stop 的明确类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | M | todo | | PKG-AMBILIGHT-05 | artplayer-plugin-ambilight
验证新旧核心和组合 | PKG-AMBILIGHT-04, CORE-22 | 原生 video、canvas proxy 的能力边界及销毁无帧循环 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | M | todo | @@ -477,6 +477,7 @@ - PKG-CHAPTER-04: [记录](changes/2026-09-10-PKG-CHAPTER-04-public-types.md) [记录](baselines/chapter-types-validation.json) - PKG-CHAPTER-05: [记录](changes/2026-09-12-PKG-CHAPTER-05-combinations.md) [记录](baselines/chapter-combinations-checkpoint.json) - PKG-AMBILIGHT-01: [记录](changes/2026-09-12-PKG-AMBILIGHT-01-contract.md) [记录](baselines/ambilight-contract.md) [记录](baselines/ambilight-release.json) [记录](baselines/ambilight-contract-validation.json) +- PKG-AMBILIGHT-02: [记录](changes/2026-09-12-PKG-AMBILIGHT-02-tests.md) [记录](baselines/ambilight-behavior-validation.json) [记录](ambilight-validation.md) - 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 c8cf35433..f9f68e894 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,14 @@ # 进度与证据 +## 最新完成:Ambilight-02 行为与失败回归 + +30项Node复现RAF id零、异常后卡住、销毁/重入/晚注册和初始化回滚问题。54项三引擎/ +三核心真实媒体验证通过;原生跨域Canvas污染在同源恢复后仍持续,新Canvas能取色的 +独立对照排除媒体未恢复误判。核心来源为实际npm5.1.7/5.4.0及候选;无skip。主CI955项 +通过,另44项重复观察;03将拆TS并修复,尚未改生产源码。当前220项:86 done、 +6 doing、128 todo。见 [实施记录](changes/2026-09-12-PKG-AMBILIGHT-02-tests.md) +和 [验证证据](baselines/ambilight-behavior-validation.json)。 + ## 最新完成:Ambilight-01 实际发布契约 冻结npm1.0.0/1.1.0共12成员及工作区源码/声明/demo;真实执行旧CJS.default、新CJS/ diff --git a/refactor/risk-table.md b/refactor/risk-table.md index bd262b399..ad5b1bdab 100644 --- a/refactor/risk-table.md +++ b/refactor/risk-table.md @@ -151,6 +151,6 @@ | VAST-TYPE-01 | open / 已复现 | VAST declares an async factory as synchronous and imports an unavailable SDK type dependency | PKG-VAST-04, PKG-VAST-06 | | VAST-LIFE-01 | open / 已复现 | VAST SDK and container lack core destruction ownership and pending work cancellation | PKG-VAST-02, PKG-VAST-03, PKG-VAST-05 | | VAST-DIST-01 | open / 已复现 | Published VAST CommonJS default namespace differs from the current callable distribution | PKG-VAST-04, PKG-VAST-06 | -| AMBILIGHT-LIFE-01 | open / 源码/产物事实 | Ambilight sampling and RAF/DOM ownership lack terminal/error handling | PKG-AMBILIGHT-02, PKG-AMBILIGHT-03, PKG-AMBILIGHT-05 | +| AMBILIGHT-LIFE-01 | open / 已复现 | Ambilight sampling and RAF/DOM ownership lack terminal/error handling | PKG-AMBILIGHT-02, PKG-AMBILIGHT-03, PKG-AMBILIGHT-05 | | AMBILIGHT-TYPE-01 | open / 源码/产物事实 | Ambilight declarations disagree with optional factory argument and differ across published generations | PKG-AMBILIGHT-04, PKG-AMBILIGHT-06 | | AMBILIGHT-DIST-01 | open / 已复现 | Ambilight 1.0.0 CommonJS default namespace differs from callable 1.1.0 | PKG-AMBILIGHT-04, PKG-AMBILIGHT-06 | diff --git a/refactor/risks.json b/refactor/risks.json index 062174d09..363d0aedf 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -3255,7 +3255,7 @@ { "id": "AMBILIGHT-LIFE-01", "title": "Ambilight sampling and RAF/DOM ownership lack terminal/error handling", - "confirmation": "source-observed", + "confirmation": "reproduced", "owners": [ "PKG-AMBILIGHT-02", "PKG-AMBILIGHT-03", @@ -3267,8 +3267,12 @@ "evidence": [ "refactor/baselines/ambilight-contract.md", "refactor/baselines/ambilight-release.json", - "refactor/changes/2026-09-12-PKG-AMBILIGHT-01-contract.md" - ] + "refactor/changes/2026-09-12-PKG-AMBILIGHT-01-contract.md", + "refactor/changes/2026-09-12-PKG-AMBILIGHT-02-tests.md", + "refactor/baselines/ambilight-behavior-validation.json", + "refactor/ambilight-validation.md" + ], + "workspaceState": "30 Node historical tests reproduce RAF id zero, terminal/escaped method, sampling/context, reentrant stop/destroy and setup rollback failures. Native browser sampling of a cross-origin video throws SecurityError and stays tainted after same-origin source recovery. No production fixes in this task." }, { "id": "AMBILIGHT-TYPE-01", diff --git a/refactor/tasks.json b/refactor/tasks.json index 7c31c358b..a93502c18 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -1481,11 +1481,15 @@ "ENG-03", "ENG-05" ], - "status": "todo", + "status": "done", "risk": "M", "deliverable": "跨域 canvas 失败、零尺寸、暂停、重复 start/stop 和 destroy", "acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言", - "evidence": [] + "evidence": [ + "changes/2026-09-12-PKG-AMBILIGHT-02-tests.md", + "baselines/ambilight-behavior-validation.json", + "ambilight-validation.md" + ] }, { "id": "PKG-AMBILIGHT-03", diff --git a/test/ambilight.test.js b/test/ambilight.test.js new file mode 100644 index 000000000..099a143a4 --- /dev/null +++ b/test/ambilight.test.js @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict' +// eslint-disable-next-line test/no-import-node-test -- Preserve historical RAF/canvas behavior separately from candidate fixes. +import test from 'node:test' +import { ambilightEnvironment, ambilightHistorical } from './helpers/ambilight.js' + +for (const implementation of await ambilightHistorical()) { + const label = implementation.name + + test(`Ambilight ${label}: frequency throttles samples and pause/stop preserve last colors`, async () => { + const env = await ambilightEnvironment(implementation) + const result = env.factory({ frequency: 2 })(env.art) + env.art.emit('ready') + env.art.playing = true + env.frame(499) + assert.equal(env.draws.length, 0) + env.frame(500) + assert.equal(env.draws.length, 9) + const colors = env.parent.children[0].children.map(child => child.style.backgroundColor) + env.art.playing = false + env.frame(1000) + assert.equal(env.draws.length, 9) + assert.equal(env.frames.size, 1) + result.stop() + assert.deepEqual(env.parent.children[0].children.map(child => child.style.backgroundColor), colors) + env.art.playing = true + result.start() + assert.equal(env.draws.length, 18) + assert.equal(env.frames.size, 1) + result.stop() + }) + + test(`Ambilight ${label}: ordinary start/stop is repeatable and ready can restart an earlier stop`, async () => { + const env = await ambilightEnvironment(implementation) + const result = env.factory()(env.art) + result.stop() + env.art.emit('ready') + result.start() + result.start() + assert.equal(env.frames.size, 1) + result.stop() + result.stop() + assert.equal(env.frames.size, 0) + result.start() + assert.equal(env.frames.size, 1) + result.stop() + }) + + test(`Ambilight ${label}: special frequency values preserve historical JavaScript division semantics`, async () => { + for (const [frequency, samples] of [[0, 0], [-1, 9], [Infinity, 9], [Number.NaN, 9], ['2', 0]]) { + const env = await ambilightEnvironment(implementation) + const result = env.factory({ frequency })(env.art) + env.art.playing = true + env.time(100) + result.start() + assert.equal(env.draws.length, samples, String(frequency)) + assert.equal(env.frames.size, 1) + result.stop() + } + }) + + test(`Ambilight ${label}: historical RAF id zero creates duplicate work and escapes stop`, async () => { + const env = await ambilightEnvironment(implementation, { firstFrame: 0 }) + const result = env.factory()(env.art) + result.start() + result.start() + assert.equal(env.frames.size, 2) + result.stop() + assert.deepEqual([...env.frames.keys()], [0]) + }) + + test(`Ambilight ${label}: historical destroy leaves its view and escaped start can restart frames`, async () => { + const env = await ambilightEnvironment(implementation) + const result = env.factory()(env.art) + env.art.emit('ready') + env.art.destroy() + assert.equal(env.frames.size, 0) + assert.equal(env.parent.children.length, 2) + result.start() + assert.equal(env.frames.size, 1) + result.stop() + }) + + test(`Ambilight ${label}: historical frame exceptions wedge start until an explicit stop`, async () => { + for (const errorType of ['draw', 'read']) { + const env = await ambilightEnvironment(implementation) + const result = env.factory()(env.art) + const error = Object.assign(new Error(errorType), { name: errorType === 'read' ? 'SecurityError' : 'InvalidStateError' }) + result.start() + env.art.playing = true + if (errorType === 'read') + env.setReadError(error) + else env.setDrawError(error) + assert.throws(() => env.frame(100), value => value === error) + assert.equal(env.frames.size, 0) + env.setReadError(null) + env.setDrawError(null) + result.start() + assert.equal(env.frames.size, 0, 'Stale nonzero handle still blocks restart') + result.stop() + env.time(200) + result.start() + assert.equal(env.frames.size, 1) + result.stop() + } + }) + + test(`Ambilight ${label}: historical null contexts and zero dimensions reach unguarded sampling`, async () => { + const missing = await ambilightEnvironment(implementation, { noContext: true }) + const result = missing.factory()(missing.art) + missing.art.playing = true + missing.time(100) + assert.throws(result.start, error => error.name === 'TypeError') + assert.equal(missing.parent.children.length, 2) + const zero = await ambilightEnvironment(implementation) + zero.video.videoWidth = zero.video.videoHeight = 0 + zero.art.playing = true + zero.time(100) + const zeroResult = zero.factory()(zero.art) + zeroResult.start() + assert.equal(zero.draws.length, 9, 'Recorder accepts zero area; this proves missing guard, not browser acceptance') + assert.equal(zero.draws[0][3], 0) + assert.equal(zero.draws[0][4], 0) + zeroResult.stop() + }) + + test(`Ambilight ${label}: historical stop/destroy during sampling queues a new frame after cleanup`, async () => { + for (const operation of ['stop', 'destroy']) { + let result + let first = true + const env = await ambilightEnvironment(implementation, { onDraw() { + if (!first) + return + first = false + if (operation === 'stop') + result.stop() + else env.art.destroy() + } }) + result = env.factory()(env.art) + result.start() + env.art.playing = true + env.frame(100) + assert.equal(env.draws.length, 9) + assert.equal(env.frames.size, 1) + result.stop() + } + }) + + test(`Ambilight ${label}: historical setup failure retains an inserted view without a cleanup handle`, async () => { + const env = await ambilightEnvironment(implementation) + const failure = new Error('style setup failed') + env.art.constructor.utils.setStyles = () => { + throw failure + } + assert.throws(() => env.factory()(env.art), error => error === failure) + assert.equal(env.parent.children.length, 2) + assert.equal(env.listeners.size, 0) + }) + + test(`Ambilight ${label}: historical late attachment to a destroyed host still allocates and starts`, async () => { + const env = await ambilightEnvironment(implementation) + env.art.destroy() + const result = env.factory()(env.art) + assert.equal(env.parent.children.length, 2) + result.start() + assert.equal(env.frames.size, 1) + result.stop() + }) +} diff --git a/test/browser/ambilight.spec.js b/test/browser/ambilight.spec.js new file mode 100644 index 000000000..7ece89fac --- /dev/null +++ b/test/browser/ambilight.spec.js @@ -0,0 +1,97 @@ +import { transform } from 'esbuild' +import { hash } from '../../refactor/scripts/releases.mjs' +import { ambilightHistorical } from '../helpers/ambilight.js' +import { expect, test } from './fixtures.js' + +const implementations = await Promise.all((await ambilightHistorical()).map(async item => ({ ...item, code: item.format === 'source' ? (await transform(item.source, { format: 'cjs', target: 'es2020' })).code : item.source }))) + +async function setup(page, core, implementation, testInfo, crossOrigin = false) { + await page.goto(`/test/player.html?core=${core}`) + await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.ambilightFactory = module.exports.default || module.exports; })();` }) + await page.evaluate((crossOrigin) => { + const url = new URL('/test/pattern.mp4', location.href) + if (crossOrigin) + url.hostname = 'localhost' + window.createPlayer(url.href) + }, crossOrigin) + await expect.poll(() => page.evaluate(() => window.art.template.$video.videoWidth)).toBeGreaterThan(0) + await page.click('#play') + await expect.poll(() => page.evaluate(() => window.art.playing && window.art.currentTime > 0)).toBe(true) + await page.evaluate(() => { + window.art.plugins.add(window.ambilightFactory({ frequency: Infinity })) + window.ambilight = window.art.plugins.artplayerPluginAmbilight + }) + await testInfo.attach('ambilight-inputs', { contentType: 'application/json', body: JSON.stringify({ core, implementation: implementation.name, sha256: hash(implementation.code), crossOrigin, media: '/test/pattern.mp4', canvas: 'native, no pixel or RAF stubs' }) }) +} + +test.afterEach(async ({ page }, testInfo) => { + await testInfo.attach('ambilight-state', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ version: window.Artplayer?.version, source: window.art?.url, width: window.art?.template.$video.videoWidth, currentTime: window.art?.currentTime, errors: window.ambilightErrors, grids: document.querySelectorAll('.artplayer-plugin-ambilight').length }))) }) + await page.evaluate(() => { + // Historical escaped methods intentionally restart work; cleanup follows assertions. + window.ambilight?.stop() + if (window.art && !window.art.isDestroy) + window.art.destroy() + }) +}) + +for (const core of ['published-5.1.7', 'published', 'candidate']) { + for (const implementation of implementations) { + const label = `${core} / ${implementation.name}` + test(`${label}: native canvas colors, pause, source switch and historical retained destroy view`, async ({ page }, testInfo) => { + await setup(page, core, implementation, testInfo) + await page.evaluate(() => window.ambilight.start()) + await expect.poll(() => page.evaluate(() => [...document.querySelectorAll('.artplayer-plugin-ambilight > div')].filter(node => /^rgb\(/.test(node.style.backgroundColor)).length)).toBe(9) + const layout = await page.evaluate(() => { + const grid = document.querySelector('.artplayer-plugin-ambilight') + return { parentMatches: grid.parentNode === window.art.template.$video.parentNode, nextMatches: grid.nextSibling === window.art.template.$video, columns: getComputedStyle(grid).gridTemplateColumns.split(' ').length, zIndex: getComputedStyle(grid).zIndex } + }) + expect(layout).toEqual({ parentMatches: true, nextMatches: true, columns: 3, zIndex: '9' }) + await page.click('#pause') + await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(false) + const before = await page.evaluate(() => [...document.querySelectorAll('.artplayer-plugin-ambilight > div')].map(node => node.style.backgroundColor)) + const stopped = await page.evaluate(() => { + const returned = window.ambilight.stop() + return { returned: returned === undefined, colors: [...document.querySelectorAll('.artplayer-plugin-ambilight > div')].map(node => node.style.backgroundColor) } + }) + expect(stopped).toEqual({ returned: true, colors: before }) + await page.evaluate(() => window.art.switchUrl('/assets/sample/video.mp4')) + await page.click('#play') + await expect.poll(() => page.evaluate(() => window.art.playing && window.art.template.$video.videoWidth > 0)).toBe(true) + await page.evaluate(() => window.ambilight.start()) + expect(await page.evaluate(() => window.art.template.$video.currentSrc.endsWith('/assets/sample/video.mp4'))).toBe(true) + await page.evaluate(() => window.art.destroy(false)) + expect(await page.locator('.artplayer-plugin-ambilight').count()).toBe(1) + }) + + test(`${label}: native cross-origin SecurityError persists after same-origin recovery in historical canvas`, async ({ page }, testInfo) => { + await setup(page, core, implementation, testInfo, true) + const capture = () => page.evaluate(() => { + window.ambilight.stop() + try { + window.ambilight.start() + return null + } + catch (error) { + window.ambilightErrors = [...(window.ambilightErrors || []), error.name] + return error.name + } + }) + expect(await capture()).toBe('SecurityError') + await page.evaluate(() => window.art.switchUrl('/test/pattern.mp4')) + await page.click('#play') + await expect.poll(() => page.evaluate(() => window.art.playing && window.art.template.$video.currentSrc.startsWith(location.origin))).toBe(true) + await expect.poll(() => page.evaluate(() => { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + try { + context.drawImage(window.art.template.$video, 0, 0, 1, 1) + context.getImageData(0, 0, 1, 1) + return true + } + catch { return false } + })).toBe(true) + expect(await capture()).toBe('SecurityError') + expect(await page.evaluate(() => window.art.currentTime)).toBeGreaterThanOrEqual(0) + }) + } +}