test(canvas): [PKG-CANVAS-02] reproduce frame leaks and native pixel boundaries

This commit is contained in:
Harvey Zhao committed 2026-09-12 12:43:39 +08:00
1 parent 5e26d2298d
commit e931520e66
11 files changed
+642 -12

No files matched your search

+3 -2
View File
@@ -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/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:unit": "node --test test/canvas.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -71,7 +71,8 @@
"check:contracts": "node refactor/scripts/contracts.mjs",
"test:contracts": "node refactor/scripts/run-contracts.mjs",
"test:ambilight": "node --test test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js",
"test:ambilight-types-package": "node refactor/scripts/ambilight-package-types.mjs"
"test:ambilight-types-package": "node refactor/scripts/ambilight-package-types.mjs",
"test:canvas": "node --test test/canvas.test.js"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -0,0 +1,206 @@
{
"schemaVersion": 1,
"task": "PKG-CANVAS-02",
"status": "done",
"checks": [
{
"command": "node --test test/canvas.test.js",
"result": "pass",
"tests": 33,
"scope": "11 historical normal/error/ownership categories across three frozen implementations"
},
{
"command": "playwright test test/browser/canvas.spec.js",
"result": "pass",
"tests": 42,
"skipped": 0,
"browsers": [
{
"project": "chromium",
"version": "153.0.8010.12",
"platform": "win32"
},
{
"project": "firefox",
"version": "155.0",
"platform": "win32"
},
{
"project": "webkit",
"version": "26.6",
"platform": "win32"
}
],
"scope": "36 archived/frozen proxy checks plus 6 independent native attached/detached controls; expected historical WebKit transparency and post-destroy RAF remain defects"
},
{
"command": "focused eslint --no-fix",
"result": "pass"
},
{
"command": "yarn ci:check",
"result": "pass",
"tests": {
"unit": 914,
"engineering": 14,
"baseline": 96,
"mainTotal": 1024,
"repeatedContractExecutions": 44
},
"strictProductionTypeScriptFiles": 278
}
],
"limitations": [
"Historical failure assertions are not candidate fixes or rendering acceptance.",
"WebKit detached-video transparency is scoped to recorded Windows Playwright browser and media inputs, not all Safari devices.",
"Controlled Node DOM and bitmap promises do not prove native pixel, codec or device behavior.",
"No VAST VPN exception or browser skip used."
],
"sourceCommit": "5e26d2298d4fc7568b208962d2aa069b788a8a78",
"preservedBrowserAttempts": [
{
"file": "refactor/.cache/canvas02-first-results/report.json",
"sha256": "de5c0195c7de8a1551b064fd4213c089cfe20d6aecc5bdba4558938ac7b2758e",
"stats": {
"startTime": "2026-09-12T04:24:44.219Z",
"duration": 29128.137000000002,
"expected": 30,
"skipped": 0,
"unexpected": 6,
"flaky": 0
},
"resultsDirectory": "refactor/.cache/canvas02-first-results/results"
},
{
"file": "refactor/.cache/canvas02-webkit-wait-results/report.json",
"sha256": "43a7b4497d89213244201ecfd052b0fa0dedabea71d88239756df12998e3039e",
"stats": {
"startTime": "2026-09-12T04:26:11.586Z",
"duration": 22140.993,
"expected": 0,
"skipped": 0,
"unexpected": 6,
"flaky": 0
},
"resultsDirectory": "refactor/.cache/canvas02-webkit-wait-results/results"
},
{
"file": "refactor/.cache/canvas02-pixel-probe-results/report.json",
"sha256": "dd441cc8e223fdaeecdb39dac5297c5f5cb1008a2c396d3b9caac5fa3a43ed76",
"stats": {
"startTime": "2026-09-12T04:27:46.306Z",
"duration": 11620.739,
"expected": 0,
"skipped": 0,
"unexpected": 1,
"flaky": 0
},
"resultsDirectory": "refactor/.cache/canvas02-pixel-probe-results/results"
},
{
"file": "refactor/.cache/canvas02-attach-probe-results/report.json",
"sha256": "5f12808970c4cb65ffed6cb265c3d3b595bdea0eb1ea79fa3e524a77a22070b7",
"stats": {
"startTime": "2026-09-12T04:29:43.968Z",
"duration": 11748.322,
"expected": 0,
"skipped": 0,
"unexpected": 1,
"flaky": 0
},
"resultsDirectory": "refactor/.cache/canvas02-attach-probe-results/results"
},
{
"file": "refactor/.cache/canvas02-native-control-results/report.json",
"sha256": "655cd06111a6f9389bee5b7bd56b1f50b81482635bb69ad58462051a721235a5",
"stats": {
"startTime": "2026-09-12T04:34:13.618Z",
"duration": 6111.419,
"expected": 3,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"resultsDirectory": "refactor/.cache/canvas02-native-control-results/results"
},
{
"file": "refactor/.cache/canvas02-native-detached-results/report.json",
"sha256": "1db5932b7979980177dc0d158090cec3d967cce33c2e4f9a4d2f1c7d6ca94f95",
"stats": {
"startTime": "2026-09-12T04:35:12.367Z",
"duration": 11861.622,
"expected": 0,
"skipped": 0,
"unexpected": 1,
"flaky": 0
},
"resultsDirectory": "refactor/.cache/canvas02-native-detached-results/results"
},
{
"file": "refactor/.cache/canvas02-pixel-boundary-results/report.json",
"sha256": "ac1768227986cb716d40231a856bab8ae9dcb35ee0161516a42cb47e5df1e687",
"stats": {
"startTime": "2026-09-12T04:36:16.598Z",
"duration": 30244.336,
"expected": 42,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"resultsDirectory": "refactor/.cache/canvas02-pixel-boundary-results/results"
},
{
"file": "refactor/.cache/canvas02-final-results/report.json",
"sha256": "28886ac131df9f5f871fb9c66b0985a73cd9e84dd5542d028db68e28f374b649",
"stats": {
"startTime": "2026-09-12T04:37:05.676Z",
"duration": 28495.888,
"expected": 42,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"resultsDirectory": "refactor/.cache/canvas02-final-results/results"
}
],
"inputs": [
{
"file": "test/canvas.test.js",
"sha256LF": "df1b1aaee15f18b6b9808f48e759ef2ef22912e7a20a0b831e51bee4d84ee436"
},
{
"file": "test/browser/canvas.spec.js",
"sha256LF": "e34a4463574f09057af7f307e8564cbcd8f03415a270f362b6c83de69359f652"
},
{
"file": "test/helpers/canvas.js",
"sha256LF": "cc617b6d1053a9ed9caa5dad03088abaaff4344f078e23d007ee1a935f2a6c3c"
},
{
"file": "refactor/baselines/canvas-release.json",
"sha256LF": "7f7b229a5c0350a5d02be101d0095548c7ef1e76ce95c3b7152b415b0117398c"
}
],
"logs": [
{
"file": "refactor/.cache/canvas02-node.log",
"sha256": "7558fb6922ba2e2490c586051081375f98069048d234f4cd7fda22b85b746bfd"
},
{
"file": "refactor/.cache/canvas02-lint-final.log",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"file": "refactor/.cache/canvas02-browser-readiness.log",
"sha256": "ba10158100c211a7f5ee5e05e917f7ffc20c8968e8372b7d99f668e16e4b7970"
},
{
"file": "refactor/.cache/canvas02-ci-before-risk-index.log",
"sha256": "0a0452a062448c3a2092c4d62ed84b5a255f9016cf4ef86c108825952d45a45e"
},
{
"file": "refactor/.cache/canvas02-ci.log",
"sha256": "e4137711c5902d943ddfdae260f895959223e9ef1040e464197f8f59209d403f"
}
]
}
+20 -2
View File
@@ -15,9 +15,27 @@ play/pause、destroy前后订阅、缺context和尺寸异常;保留历史失
当前已有的Ambilight代理颜色测试会直接加载proxy的src/index.js;代理迁移文件入口
或模块拆分时必须同步改为实际候选入口加载,不能用旧dist掩盖改名或遗漏模块。
Canvas本身的正式历史浏览器矩阵尚未建立;没有在01使用新浏览器结果作包整体验收。
Canvas正式历史浏览器矩阵在02建立;01没有使用新浏览器结果作包整体验收。
01最终13项Canvas专项、7项契约索引检查与991项完整CI通过;证据见
[发布契约验证](baselines/canvas-contract-validation.json) 和
[实施记录](changes/2026-09-12-PKG-CANVAS-01-contract.md)。三个新增风险继续open,
生命周期仅source-observed,02需先复现再推进03源码修复。
01时生命周期仅source-observed,02复现以后推进03源码修复。
## 02 历史失败与真实浏览器
`yarn test:canvas`运行33项历史Node检查,已接入test:unit。运行
`yarn test:browser test/browser/canvas.spec.js`核验42项:三实现×两种实际支持proxy
的核心×三引擎×两场景=36,加三个引擎分别挂载/未挂载原生video的6项独立对照。
就绪事件顺序、暂停/seek/resize/切源及回调内destroy后继续RAF均有断言。历史npm
5.1.7不支持proxy,不假装把它计入此矩阵。
WebKit历史代理输出透明是明确的失败基线;测试成功表示重现了该失败。原生video
播放前挂载可读到像素,未挂载对照为透明;播放后才挂载旧代理的video未恢复。
没有替换像素、bitmap或媒体来绕过它,03需以候选真实像素关闭CANVAS-PIXEL-01。
既有Ambilight组合测试由回调填充九格调色板,只证明输出buffer几何,不证明代理
已从视频画出有效帧。Windows Playwright WebKit证据不能直接等同真实Safari设备。
失败尝试与最终报告均保留report.json和完整results,哈希与范围见
[02验证证据](baselines/canvas-behavior-validation.json);问题处理细节见
[02实施记录](changes/2026-09-12-PKG-CANVAS-02-tests.md)。本任务不修改生产源码。
@@ -0,0 +1,32 @@
# PKG-CANVAS-02 历史行为与错误回归
对实际npm1.0.0、1.1.0和01冻结工作区运行33项Node测试,覆盖挂起bitmap在pause/
destroy之后完成、重复play产生并发帧、旧帧覆盖新帧、drawImage异常未close、bitmap
拒绝、用户回调异常、回调中同步销毁、延迟订阅在销毁后安装、注册失败未回滚、已销毁
宿主、零尺寸和缺失2D context。正常回调错误语义与资源缺陷分别记录;假Canvas的
NaN仅描述输入赋值,不声称真实Canvas能存储NaN尺寸。
真实浏览器直接执行两个归档及冻结JS,组合实际npm核心5.4.0/候选核心和Chromium/
Firefox/WebKit,36项代理检查加6项独立原生对照。代理检查包含loadedmetadata→
loadeddata→canplay顺序、播放/暂停/seek、resize、切源,以及回调中destroy以后
原生RAF仍被调用。RAF观察器只包装代理的词法引用,仍委托浏览器调度;媒体、Canvas、
bitmap、回调和事件均为实际执行。
首次矩阵30通过/6失败:WebKit的六个正常代理场景透明,视频时间仍推进。等待更多帧、
对同一视频直接drawImage、将其在播放后挂载到页面均未恢复。独立原生video播放前
挂载则三个引擎都读到alpha255;未挂载的WebKit对照为alpha0,另两个引擎仍255。
这将问题收窄到当前WebKit环境的未挂载媒体路径,没有据此宣称所有Safari设备相同。
最终测试明确断言历史透明结果;它们证明旧失败可复现,不是WebKit渲染成功或skip。
03必须以候选实际像素验证修复,不能把原生对照当作代理修复证据。
新增test:canvas并接入test:unit,源码、类型、依赖和构建产物保持本任务开始时状态。
验证结果及保留的失败/最终报告见[证据](../baselines/canvas-behavior-validation.json)。
生命周期风险升级为reproduced,新增未挂载视频像素风险,均继续open交给03~05。
VAST脚本VPN例外继续只适用于VAST;本任务没有使用例外,也没有跳过浏览器场景。
完成检查后本任务独立本地提交,不推送/发布。
首次完整CI在风险索引一致性检查失败:新增风险的JSON台账已更新,生成的risk-table.md
尚未同步。使用risk-register.mjs --write重新生成并重跑CI,保留首次失败日志。
最终专项lint、33项历史Node、42项浏览器及完整CI1024项通过(914单元+14工程+96基线),另44项重复契约观察不计为新增。278个生产TS文件严格检查;状态done,准备独立提交。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 221 项,范围 22 个包及工作区/示例。
状态:todo 125 / doing 6 / blocked 0 / done 90 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 124 / doing 6 / blocked 0 / done 91 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -319,7 +319,7 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| PKG-CANVAS-01 | artplayer-proxy-canvas<br>核对包契约与历史用法 | BASE-05 | callback、canvas 原方法、转发 media 属性/方法/事件 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done |
| PKG-CANVAS-02 | artplayer-proxy-canvas<br>建立特有行为与错误测试 | PKG-CANVAS-01, ENG-03, ENG-05 | ready/loadeddata/canplay、play/seek、回调、resize 与销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | todo |
| PKG-CANVAS-02 | artplayer-proxy-canvas<br>建立特有行为与错误测试 | PKG-CANVAS-01, ENG-03, ENG-05 | ready/loadeddata/canplay、play/seek、回调、resize 与销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-CANVAS-03 | artplayer-proxy-canvas<br>整理内部职责与资源 | PKG-CANVAS-02, CORE-06, CORE-11, CORE-16 | video adapter/原 canvas 方法/RAF 绘制/事件订阅分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo |
| PKG-CANVAS-04 | artplayer-proxy-canvas<br>迁移自有源码和公开类型 | PKG-CANVAS-03, ENG-04, ENG-06, CORE-07 | canvas 与媒体能力的精确组合类型,保持 Result | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo |
| PKG-CANVAS-05 | artplayer-proxy-canvas<br>验证新旧核心和组合 | PKG-CANVAS-04, CORE-22 | 真实 video/canvas 绘制、字幕和 document PiP 恢复 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
@@ -507,3 +507,4 @@
- PKG-VAST-02: [记录](changes/2026-09-12-PKG-VAST-02-tests.md) [记录](baselines/vast-behavior-validation.json) [记录](baselines/vast-core.json) [记录](vast-validation.md)
- PKG-VAST-03: [记录](changes/2026-09-12-PKG-VAST-03-lifecycle.md) [记录](baselines/vast-lifecycle-validation.json) [记录](vast-compatibility-decision.md)
- PKG-CANVAS-01: [记录](changes/2026-09-12-PKG-CANVAS-01-contract.md) [记录](baselines/canvas-contract.md) [记录](baselines/canvas-release.json) [记录](canvas-validation.md) [记录](baselines/canvas-contract-validation.json)
- PKG-CANVAS-02: [记录](changes/2026-09-12-PKG-CANVAS-02-tests.md) [记录](baselines/canvas-behavior-validation.json) [记录](canvas-validation.md)
+11
View File
@@ -1,5 +1,16 @@
# 进度与证据
## 最新完成:Canvas-02 历史错误与原生浏览器对照
33项Node回归与42项真实浏览器检查通过,就绪事件顺序、播放/暂停/seek/resize/切源
及销毁后RAF均有断言。WebKit六个旧代理场景首次透明失败;独立原生对照表明播放前
挂载可读到像素、未挂载透明。最终矩阵明确重现历史失败,不把它当作渲染成功。
完整CI1024项通过,另44项重复观察;未修改生产源码。CANVAS-LIFE-01升级reproduced,
新增CANVAS-PIXEL-01待03修复。当前221项:91 done、6 doing、124 todo。下一步03
拆分严格TS职责并以候选回归关闭生命周期/像素问题。见
[本步记录](changes/2026-09-12-PKG-CANVAS-02-tests.md) 和
[验证证据](baselines/canvas-behavior-validation.json)。独立本地提交,无推送/发布。
## 最新完成:Canvas-01 发布契约与正常基线
冻结npm1.0.0/1.1.0共12成员及工作区6个Git输入,运行三实现的同步返回、媒体属性/
+2 -1
View File
@@ -155,6 +155,7 @@
| AMBILIGHT-TYPE-01 | resolved / 已复现 | Ambilight declarations disagree with optional factory argument and differ across published generations | PKG-AMBILIGHT-04, PKG-AMBILIGHT-06 |
| AMBILIGHT-DIST-01 | resolved / 已复现 | Ambilight 1.0.0 CommonJS default namespace differs from callable 1.1.0 | PKG-AMBILIGHT-04, PKG-AMBILIGHT-06 |
| AMBILIGHT-PROXY-01 | resolved / 已复现 | Canvas proxy output pixels differ from forwarded video dimensions | PKG-AMBILIGHT-PROXY-01, PKG-AMBILIGHT-05 |
| CANVAS-LIFE-01 | open / 源码/产物事实 | Canvas proxy pending draw, bitmap and delayed subscriptions lack terminal ownership | PKG-CANVAS-02, PKG-CANVAS-03, PKG-CANVAS-05 |
| CANVAS-LIFE-01 | open / 已复现 | Canvas proxy pending draw, bitmap and delayed subscriptions lack terminal ownership | PKG-CANVAS-02, PKG-CANVAS-03, PKG-CANVAS-05 |
| CANVAS-TYPE-01 | open / 源码/产物事实 | Canvas proxy declarations change required callback across generations and omit forwarded media capabilities | PKG-CANVAS-04, PKG-CANVAS-06 |
| CANVAS-DIST-01 | open / 已复现 | Canvas proxy published CommonJS namespace differs from current callable export | PKG-CANVAS-04, PKG-CANVAS-06 |
| CANVAS-PIXEL-01 | open / 已复现 | Historical Canvas proxy produces transparent frames with detached native video in tested WebKit | PKG-CANVAS-03, PKG-CANVAS-05 |
+23 -3
View File
@@ -3343,7 +3343,7 @@
{
"id": "CANVAS-LIFE-01",
"title": "Canvas proxy pending draw, bitmap and delayed subscriptions lack terminal ownership",
"confirmation": "source-observed",
"confirmation": "reproduced",
"owners": [
"PKG-CANVAS-02",
"PKG-CANVAS-03",
@@ -3355,8 +3355,11 @@
"evidence": [
"refactor/baselines/canvas-contract.md",
"refactor/baselines/canvas-release.json",
"refactor/changes/2026-09-12-PKG-CANVAS-01-contract.md"
]
"refactor/changes/2026-09-12-PKG-CANVAS-01-contract.md",
"refactor/changes/2026-09-12-PKG-CANVAS-02-tests.md",
"refactor/baselines/canvas-behavior-validation.json"
],
"workspaceState": "33 historical Node checks reproduce pending bitmap, concurrent RAF, close omission, reentrant destroy and delayed/partial registration leaks; 18 real browser core/proxy combinations observe RAF continuing after destroy inside callback. Candidate fixes pending task 03."
},
{
"id": "CANVAS-TYPE-01",
@@ -3391,6 +3394,23 @@
"refactor/baselines/canvas-release.json",
"refactor/changes/2026-09-12-PKG-CANVAS-01-contract.md"
]
},
{
"id": "CANVAS-PIXEL-01",
"title": "Historical Canvas proxy produces transparent frames with detached native video in tested WebKit",
"status": "open",
"confirmation": "reproduced",
"owners": [
"PKG-CANVAS-03",
"PKG-CANVAS-05"
],
"compatibleResolution": "Verify actual pixel recovery with explicit backing media ownership and stable public canvas surface; do not infer that attaching after playback fixes it.",
"closureCriteria": "Candidate native proxy pixels, pause/seek/source switch and terminal cleanup pass all three engines; device limitations remain explicit.",
"evidence": [
"refactor/changes/2026-09-12-PKG-CANVAS-02-tests.md",
"refactor/baselines/canvas-behavior-validation.json"
],
"workspaceState": "All six historical WebKit proxy combinations produce alpha0 while time advances. Separate native video mounted before play yields alpha255; detached native control alpha0. Chromium/Firefox both paths yield255. These historical observations do not prove candidate fix or all Safari device behavior."
}
]
}
+6 -2
View File
@@ -3268,11 +3268,15 @@
"ENG-03",
"ENG-05"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "ready/loadeddata/canplay、play/seek、回调、resize 与销毁",
"acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言",
"evidence": []
"evidence": [
"changes/2026-09-12-PKG-CANVAS-02-tests.md",
"baselines/canvas-behavior-validation.json",
"canvas-validation.md"
]
},
{
"id": "PKG-CANVAS-03",
+163
View File
@@ -0,0 +1,163 @@
import { transform } from 'esbuild'
import { hash } from '../../refactor/scripts/releases.mjs'
import { canvasHistorical } from '../helpers/canvas.js'
import { expect, test } from './fixtures.js'
const implementations = await Promise.all((await canvasHistorical()).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) {
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: `(() => {
const pending = new Set();
const state = { requested: 0, fired: 0, cancelled: 0 };
const requestAnimationFrame = callback => {
const id = window.requestAnimationFrame(time => { pending.delete(id); state.fired++; callback(time); });
pending.add(id); state.requested++; return id;
};
const cancelAnimationFrame = id => { pending.delete(id); state.cancelled++; window.cancelAnimationFrame(id); };
const module = { exports: {} }; const exports = module.exports;
${implementation.code};
window.canvasFactory = module.exports.default || module.exports;
window.canvasFrames = { state, pending, stopAll() { for (const id of pending) window.cancelAnimationFrame(id); pending.clear(); } };
})();` })
await page.evaluate(() => {
window.proxyDraws = 0
window.proxyEvents = []
window.proxyErrors = []
window.art = new window.Artplayer({
container: '.player',
url: '/test/pattern.mp4',
muted: true,
loop: true,
proxy: window.canvasFactory((context, video) => {
window.proxyContext = context
window.proxyVideo = video
window.proxyDraws++
if (window.destroyInNextCallback) {
window.destroyInNextCallback = false
window.art.destroy(false)
window.destroyedInCallback = true
}
}),
})
for (const name of ['video:loadedmetadata', 'video:loadeddata', 'video:canplay', 'video:play', 'video:pause', 'video:seeked', 'artplayerProxyCanvas:draw', 'destroy'])
window.art.on(name, () => window.proxyEvents.push(name))
window.art.on('artplayerProxyCanvas:error', error => window.proxyErrors.push({ name: error.name, message: error.message }))
document.querySelector('#play').onclick = () => window.art.play()
document.querySelector('#pause').onclick = () => window.art.pause()
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.proxyDraws)).toBeGreaterThan(1)
await expect.poll(() => page.evaluate(() => window.proxyVideo.currentTime)).toBeGreaterThan(0)
await testInfo.attach('canvas-historical-inputs', { contentType: 'application/json', body: JSON.stringify({ core, implementation: implementation.name, sha256: hash(implementation.code), nativeBitmapAPI: await page.evaluate(() => typeof createImageBitmap), scope: 'actual archived proxy or frozen workspace, native video/Canvas/bitmap and lexical native RAF observer' }) })
}
test.afterEach(async ({ page }, testInfo) => {
await testInfo.attach('canvas-native-pixel-control', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(async () => {
const video = window.proxyVideo
const output = window.proxyContext
if (!video || !output)
return null
const pixel = (context, x, y) => [...context.getImageData(x, y, 1, 1).data]
const probe = document.createElement('canvas')
probe.width = video.videoWidth || 320
probe.height = video.videoHeight || 180
const context = probe.getContext('2d')
const state = { output: { width: output.canvas.width, height: output.canvas.height, corner: pixel(output, 0, 0), center: pixel(output, Math.floor(output.canvas.width / 2), Math.floor(output.canvas.height / 2)) } }
try {
context.drawImage(video, 0, 0, probe.width, probe.height)
state.direct = { corner: pixel(context, 0, 0), center: pixel(context, Math.floor(probe.width / 2), Math.floor(probe.height / 2)) }
if (typeof createImageBitmap === 'function') {
const bitmap = await createImageBitmap(video)
try {
context.clearRect(0, 0, probe.width, probe.height)
context.drawImage(bitmap, 0, 0, probe.width, probe.height)
state.bitmap = { width: bitmap.width, height: bitmap.height, corner: pixel(context, 0, 0), center: pixel(context, Math.floor(probe.width / 2), Math.floor(probe.height / 2)) }
}
finally { bitmap.close() }
}
}
catch (error) { state.error = { name: error.name, message: error.message } }
return state
})) })
await testInfo.attach('canvas-historical-state', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ version: window.Artplayer?.version, host: window.art?.template.$video.nodeName, source: window.proxyVideo?.currentSrc, width: window.proxyVideo?.videoWidth, currentTime: window.proxyVideo?.currentTime, destroyed: window.art?.isDestroy, frames: window.canvasFrames?.state, pending: window.canvasFrames?.pending.size, draws: window.proxyDraws, errors: window.proxyErrors, events: window.proxyEvents }))) })
await page.evaluate(() => {
window.proxyVideo?.pause()
if (window.art && !window.art.isDestroy)
window.art.destroy()
window.canvasFrames?.stopAll()
})
})
for (const core of ['published', 'candidate']) {
for (const implementation of implementations) {
const label = `${core} / ${implementation.name}`
test(`${label}: native proxy control flow and detached-video pixel boundary`, async ({ page, browserName }, testInfo) => {
await setup(page, core, implementation, testInfo)
expect(await page.evaluate(() => window.art.template.$video.nodeName)).toBe('CANVAS')
await expect.poll(() => page.evaluate(() => window.proxyEvents.includes('video:canplay'))).toBe(true)
const readiness = await page.evaluate(() => ['video:loadedmetadata', 'video:loadeddata', 'video:canplay'].map(name => window.proxyEvents.indexOf(name)))
expect(readiness[0]).toBeGreaterThanOrEqual(0)
expect(readiness[1]).toBeGreaterThan(readiness[0])
expect(readiness[2]).toBeGreaterThan(readiness[1])
if (browserName === 'webkit') {
const detached = await page.evaluate(() => ({ connected: window.proxyVideo.isConnected, alpha: window.proxyContext.getImageData(0, 0, 1, 1).data[3], currentTime: window.proxyVideo.currentTime }))
expect(detached.connected).toBe(false)
expect(detached.alpha).toBe(0)
await testInfo.attach('webkit-historical-pixel-failure', { contentType: 'application/json', body: JSON.stringify({ state: detached, scope: 'expected historical transparent output, not successful rendering; independent attached and detached native controls run separately' }) })
}
else {
await expect.poll(() => page.evaluate(() => window.proxyContext.getImageData(0, 0, 1, 1).data[3])).toBe(255)
}
await page.click('#pause')
await expect.poll(() => page.evaluate(() => window.proxyVideo.paused)).toBe(true)
await page.evaluate(() => window.art.currentTime = 0.5)
await expect.poll(() => page.evaluate(() => window.proxyEvents.filter(name => name === 'video:seeked').length)).toBeGreaterThan(0)
await page.evaluate(() => {
const player = window.art.template.$player
player.style.width = '320px'
player.style.height = '480px'
window.art.emit('resize')
})
await expect.poll(() => page.evaluate(() => window.art.template.$video.width)).toBe(320)
expect(await page.evaluate(() => window.art.template.$video.style.padding)).toBe('150px 0px')
await page.evaluate(() => window.art.switchUrl('/assets/sample/video.mp4'))
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.proxyVideo.currentSrc.endsWith('/assets/sample/video.mp4') && !window.proxyVideo.paused && window.proxyVideo.currentTime > 0)).toBe(true)
expect(await page.evaluate(() => window.proxyEvents.filter(name => name === 'video:loadedmetadata').length)).toBeGreaterThan(1)
})
test(`${label}: historical destroy inside the real draw callback leaves native RAF work running`, async ({ page }, testInfo) => {
await setup(page, core, implementation, testInfo)
await page.evaluate(() => window.destroyInNextCallback = true)
await expect.poll(() => page.evaluate(() => window.destroyedInCallback && window.art.isDestroy)).toBe(true)
await expect.poll(() => page.evaluate(() => window.canvasFrames.pending.size)).toBeGreaterThan(0)
const fired = await page.evaluate(() => window.canvasFrames.state.fired)
await expect.poll(() => page.evaluate(() => window.canvasFrames.state.fired)).toBeGreaterThan(fired)
})
}
}
for (const attached of [true, false]) {
test(`native video pixel control without proxy: attached=${attached}`, async ({ page, browserName }, testInfo) => {
await page.goto('/test/player.html')
await page.evaluate(async (attached) => {
const video = document.createElement('video')
video.src = '/test/pattern.mp4'
video.muted = true
video.loop = true
if (attached)
document.body.appendChild(video)
window.proxyVideo = video
window.proxyContext = document.createElement('canvas').getContext('2d')
await video.play()
}, attached)
await expect.poll(() => page.evaluate(() => window.proxyVideo.currentTime)).toBeGreaterThan(0)
await expect.poll(() => page.evaluate(() => {
window.proxyContext.drawImage(window.proxyVideo, 0, 0, 300, 150)
return window.proxyContext.getImageData(0, 0, 1, 1).data[3]
})).toBe(browserName === 'webkit' && !attached ? 0 : 255)
await testInfo.attach('native-control', { contentType: 'application/json', body: JSON.stringify({ attached, scope: 'native video, direct drawImage, no proxy or bitmap before assertion; detached WebKit transparency is a historical environment observation, not rendering acceptance' }) })
})
}
+173
View File
@@ -0,0 +1,173 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Node historical regression runner.
import test from 'node:test'
import { canvasEnvironment, canvasHistorical } from './helpers/canvas.js'
function deferred() {
let resolve, reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
for (const implementation of await canvasHistorical()) {
test(`Canvas ${implementation.name}: historical pending bitmap draws and reschedules after pause or destroy`, async () => {
for (const operation of ['video:pause', 'destroy']) {
const pending = deferred()
let closed = 0
const bitmap = { close() {
closed++
} }
const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending.promise })
env.factory()(env.art)
env.art.emit('video:play')
env.art.emit(operation)
assert.equal(env.frames.size, 0)
pending.resolve(bitmap)
await env.flush()
assert.equal(env.draws.length, 1)
assert.equal(closed, 1)
assert.equal(env.frames.size, 1)
assert.equal(env.emitted.at(-1).name, 'artplayerProxyCanvas:draw')
}
})
test(`Canvas ${implementation.name}: historical repeated play creates concurrent stale bitmap loops`, async () => {
const pending = [deferred(), deferred()]
let index = 0
const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending[index++].promise })
env.factory()(env.art)
env.art.emit('video:play')
env.art.emit('video:play')
const newer = { close() {} }
const older = { close() {} }
pending[1].resolve(newer)
await env.flush()
pending[0].resolve(older)
await env.flush()
assert.deepEqual(env.draws.map(args => args[0]), [newer, older])
assert.equal(env.frames.size, 2)
env.art.destroy()
assert.equal(env.frames.size, 1, 'Only the latest stored RAF handle is cancelled')
})
test(`Canvas ${implementation.name}: historical draw failure emits its error but leaks the acquired bitmap`, async () => {
const failure = new Error('draw failed')
let closed = 0
const bitmap = { close() {
closed++
} }
const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.resolve(bitmap), onDraw() {
throw failure
} })
env.factory()(env.art)
env.art.emit('video:play')
await env.flush()
assert.equal(closed, 0)
assert.equal(env.emitted.at(-1).name, 'artplayerProxyCanvas:error')
assert.equal(env.emitted.at(-1).args[0], failure)
assert.equal(env.frames.size, 1)
})
test(`Canvas ${implementation.name}: historical bitmap rejection emits the original error without direct-video fallback`, async () => {
const failure = new Error('bitmap rejected')
const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.reject(failure) })
env.factory()(env.art)
env.art.emit('video:play')
await env.flush()
assert.equal(env.draws.length, 0)
assert.equal(env.emitted.at(-1).name, 'artplayerProxyCanvas:error')
assert.equal(env.emitted.at(-1).args[0], failure)
assert.equal(env.frames.size, 1)
})
test(`Canvas ${implementation.name}: callback errors occur after bitmap close and replace the draw event with an error`, async () => {
const failure = new Error('callback failed')
let closed = 0
const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.resolve({ close() {
closed++
} }) })
env.factory(() => {
throw failure
})(env.art)
env.art.emit('resize')
await env.flush()
assert.equal(closed, 1)
assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:draw').length, 0)
assert.equal(env.emitted.at(-1).args[0], failure)
})
test(`Canvas ${implementation.name}: historical callback destruction still emits draw and queues a new frame`, async () => {
const env = await canvasEnvironment(implementation)
env.factory(() => env.art.destroy())(env.art)
env.art.emit('video:play')
await env.flush()
assert.deepEqual(env.emitted.map(event => event.name), ['video:play', 'destroy', 'artplayerProxyCanvas:draw'])
assert.equal(env.frames.size, 1)
})
test(`Canvas ${implementation.name}: historical destroy before setup timeout retains host handlers and attaches media listeners later`, async () => {
const env = await canvasEnvironment(implementation)
env.factory()(env.art)
env.art.destroy()
assert.equal(env.timers.size, 1)
assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 5)
env.flushTimers()
assert.equal(env.proxies.length, 5)
env.emitVideo({ type: 'play' })
await env.flush()
assert.equal(env.frames.size, 1)
})
test(`Canvas ${implementation.name}: historical late attachment to a destroyed host still registers and draws`, async () => {
const env = await canvasEnvironment(implementation)
env.art.destroy()
const result = env.factory()(env.art)
assert.equal(result, env.canvas)
env.flushTimers()
env.emitVideo({ type: 'play' })
await env.flush()
assert.equal(env.proxies.length, 5)
assert.equal(env.draws.length, 1)
assert.equal(env.frames.size, 1)
})
test(`Canvas ${implementation.name}: historical setup subscription failure leaves deferred work and the partial host registration`, async () => {
const env = await canvasEnvironment(implementation)
const on = env.art.on
const failure = new Error('subscription failed')
env.art.on = (...args) => {
on(...args)
throw failure
}
assert.throws(() => env.factory()(env.art), error => error === failure)
assert.equal(env.timers.size, 1)
assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 1)
env.flushTimers()
assert.equal(env.proxies.length, 5)
})
test(`Canvas ${implementation.name}: historical zero media dimensions reach unguarded resize and drawing`, async () => {
const env = await canvasEnvironment(implementation)
env.video.videoWidth = 0
env.video.videoHeight = 0
env.factory()(env.art)
env.art.emit('resize')
await env.flush()
assert(Number.isNaN(env.canvas.height), 'This controlled element does not apply native unsigned dimension coercion')
assert.equal(env.canvas.style.padding, 'NaNpx 0px')
assert.equal(env.draws.length, 1)
})
test(`Canvas ${implementation.name}: historical missing 2D context repeats a TypeError path with no capability guard`, async () => {
const env = await canvasEnvironment(implementation, { noContext: true })
env.factory()(env.art)
env.art.emit('video:play')
await env.flush()
assert.equal(env.emitted.at(-1).name, 'artplayerProxyCanvas:error')
assert.equal(env.emitted.at(-1).args[0].name, 'TypeError')
assert.equal(env.frames.size, 1)
})
}