diff --git a/package.json b/package.json index ac8ff1b64..190efc268 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/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js", + "test:unit": "node --test test/dpip.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js", "test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs", "test": "yarn test:node && yarn test:baseline", "test:browser": "playwright test", @@ -73,7 +73,8 @@ "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:canvas": "node --test test/canvas.test.js test/canvas-lifecycle.test.js", - "test:canvas-types-package": "node refactor/scripts/canvas-package-types.mjs" + "test:canvas-types-package": "node refactor/scripts/canvas-package-types.mjs", + "test:dpip": "node --test test/dpip.test.js" }, "browserslist": "last 1 Chrome version", "devDependencies": { diff --git a/refactor/baselines/dpip-behavior-validation.json b/refactor/baselines/dpip-behavior-validation.json new file mode 100644 index 000000000..5bcc4afb3 --- /dev/null +++ b/refactor/baselines/dpip-behavior-validation.json @@ -0,0 +1,136 @@ +{ + "schemaVersion": 1, + "task": "PKG-DPIP-02", + "status": "done", + "checks": [ + { + "command": "node --test test/dpip.test.js refactor/scripts/dpip-contract.test.mjs", + "result": "pass", + "tests": 64, + "historicalLifecycle": 48, + "contract": 16 + }, + { + "command": "playwright test test/browser/dpip.spec.js", + "result": "pass", + "tests": 48, + "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": "four frozen implementations x two actual cores x three engines x two real DOM iframe scenarios; explicit historical failures, not fixes or native Document PiP" + }, + { + "command": "focused eslint --no-fix", + "result": "pass" + }, + { + "command": "yarn ci:check", + "result": "pass", + "tests": { + "unit": 986, + "engineering": 14, + "baseline": 116, + "mainTotal": 1116, + "repeatedContractExecutions": 44 + }, + "strictProductionTypeScriptFiles": 284 + } + ], + "limitations": [ + "Historical failure assertions are reproductions, not candidate fixes.", + "Controlled Node host destroy does not model all core cleanup.", + "Real browser iframe DOM and events are used; requestWindow and close are controlled, not native Document PiP.", + "WebKit reports width640 for the 320-wide fixture in the styled player; preserved as a historical observation, not intrinsic-size acceptance.", + "Native window/user activation, media continuity, focus, proxies and devices remain task05." + ], + "parentCommit": "efbbdb4782be75d80c2f51e62e0eb8c5e98b83eb", + "inputs": [ + { + "file": "test/dpip.test.js", + "sha256LF": "0f73444b07821642b6291bd24c10087708867b1fcac5e23b9c4a1d901904163c" + }, + { + "file": "test/helpers/dpip.js", + "sha256LF": "93941787e7fd34a332eb46f47becb8a306f25871a511d9c83b792c04f8035131" + }, + { + "file": "test/browser/dpip.spec.js", + "sha256LF": "0626ddd9ef96aab5d68f47496266708c5f4be8cd9c9f3984db371b1612b6921e" + }, + { + "file": "refactor/baselines/dpip-release.json", + "sha256LF": "afcf54f22fb3f57186e19bdf384a130c6388e972164774ee56eeca23fc814c23" + }, + { + "file": "packages/artplayer-plugin-document-pip/src/index.js", + "sha256LF": "655818d30d82e43443e9a7ea185f91fc951eb72ad11edaaed0264182e62f32ba" + }, + { + "file": "package.json", + "sha256LF": "d7ee39161397f3e9121975b79592c84c65200ccf043d4449ac5b9a9fbb1a93f0" + } + ], + "logs": [ + { + "file": "refactor/.cache/dpip02-node-first.log", + "sha256": "866a8985af2db6953dcb35cc8dc0405d99c3944075c264424ccd2703b09194d9" + }, + { + "file": "refactor/.cache/dpip02-node-final.log", + "sha256": "946cce2d8663e4d06b969d6ba3f5463d074c6cb593d4fca0de1d0eca3402e5a9" + }, + { + "file": "refactor/.cache/dpip02-lint-final.log", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "file": "refactor/.cache/dpip02-browser-first.log", + "sha256": "dd4af0c08a2a33250f7b712074e3e13ae4775ab613972dc7f35ab35632bde20f" + }, + { + "file": "refactor/.cache/dpip02-browser-final.log", + "sha256": "10556a704038d1e0052c59769443b14348a9e8d81cd8e72f0009ad807050df9b" + }, + { + "file": "refactor/.cache/dpip02-browser-third.log", + "sha256": "cb5e301223d6b7ddb2b5dd4222e1e1cd45ee0f8403d66814f174de963a118e9b" + }, + { + "file": "refactor/.cache/dpip02-ci.log", + "sha256": "0fe1f724a072ed13b802346e213a2ed638330f4a8bd55154d44b82b90008da26" + }, + { + "file": "refactor/.cache/dpip02-first-results/report.json", + "sha256": "e1dca0e28bbdf0b537e15a71b846da64303d75b14084082f58db971b3c652f2a" + }, + { + "file": "refactor/.cache/dpip02-second-results/report.json", + "sha256": "029b99b64c0463376c6dfd3a622d92e31c2de6608b3689d9b185864dca6e91ef" + }, + { + "file": "refactor/.cache/dpip02-final-results/report.json", + "sha256": "7175394fe821285107950c846aad33acf01062d40036708d7d181742cdf20e00" + } + ], + "browserArtifacts": [ + { + "file": "refactor/.cache/dpip02-final-results/results/.last-run.json", + "sha256": "91d1c43004802cd49950d78eb11c8fa7d05da8ffffe219a8b13b2f561bc00903" + } + ] +} diff --git a/refactor/changes/2026-09-12-PKG-DPIP-02-tests.md b/refactor/changes/2026-09-12-PKG-DPIP-02-tests.md new file mode 100644 index 000000000..c7a5206e7 --- /dev/null +++ b/refactor/changes/2026-09-12-PKG-DPIP-02-tests.md @@ -0,0 +1,44 @@ +# PKG-DPIP-02 窗口竞争与还原失败 + +本步只建立历史可重跑测试,不修改插件生产源码。三份实际可执行发布(1.0.1、 +1.0.2、1.1.0)和冻结工作区各12项,共48项;1.0.0缺运行时仍不加入行为矩阵。 +正常/错误对照包含请求拒绝后重试、pagehide/unload只关闭一次、不支持两种fallback、 +source替换保持节点身份及resize调用。明确复现的缺陷: + +- close或destroy发生在requestWindow完成前,迟到窗口仍接管播放器,逃逸open可重开。 +- 并发open覆盖还原目标,前一窗口/监听器/占位节点遗留,close把播放器还原到前一窗口。 +- open/close的sleep完成后仍在销毁实例发resize;激活事件里销毁同样未撤销后续效果。 +- adoption失败未回滚窗口与占位;占位节点被外部移动后,close先adopt再insert失败, + 播放器成为无父节点,窗口仍active且关闭监听器已经移除。 +- 从其他document迁入的播放器可还原,但复制的样式来自全局document,遗漏原文档样式。 +- 1.1.0/冻结源码在DOMContentLoaded前重复执行会重复注入相同id;1.0.1/1.0.2的 + Parcel模块缓存使相同bundle重复执行只有一份,不能把新版本缺陷写成全部版本都有。 + +受控宿主的destroy只触发插件可见通知,不模拟核心全量回收,保留控件/监听器的断言 +只证明插件本身没有释放,不能据此声称实际核心destroy一定保留整个UI。 + +浏览器矩阵为四实现×两实际核心(npm5.4.0/候选)×三引擎×两场景,共48项。 +使用原生iframe的document/adoptNode/insertBefore/Event,控制requestWindow和close; +验证同一个player/video节点迁入与还原、媒体已加载、pagehide/unload、实际notice DOM, +并明确断言历史pending-close和移动占位导致的失败状态。未伪装为原生文档PiP窗口, +不证明用户激活、焦点/键盘、原生窗口关闭或跨窗连续播放。能力检测在替换API前记录。 + +首次Node运行45/48通过:两份Parcel bundle重复执行缓存与预计不同,冻结源码重复 +eval遇到词法重声明。loader现在只加函数作用域隔离,不改执行代码;保留旧Parcel缓存 +的实际1节点断言。首次浏览器16/48通过:24项错误使用notice.show getter读文字, +改为$noticeInner.textContent;另8项WebKit对320宽媒体返回640(当前CSS宽度), +现以精确历史引擎差异记录,不能把其通过称为正确原生像素/内在尺寸通过。首次完整报告、 +trace和截图保留,尺寸原因及原生媒体连续性留05进一步对照。 + +第二轮37/48通过:跨文档迁移后的原生媒体/resize事件会把notice改成Aspect Ratio, +错误断言需要在close同步失败路径刚写入DOM时取证,不能稍后假定它永远不被覆盖。 +另外iframe默认300×150触发布局变化,WebKit读数也出现266;最终固定测试iframe为 +640×360并等待明确的640宽读数,保留其他引擎320宽对照,没有把任意正数作为通过。 +第二轮报告同样完整归档。该改动稳定DOM测试环境,不解决或解释原生媒体内在尺寸。 + +结果见[验证证据](../baselines/dpip-behavior-validation.json)。03将分离窗口状态机、 +DOM迁移/样式、控件与事件资源,新增候选正向断言并保留本文件对应旧失败对照; +04处理类型/导出,05继续原生窗口和代理/设备组合。本步测试失败可独立回退, +不会修改已发布归档。不推送、发布或升级版本。 + +最终64项Node定向检查(48生命周期+16正常契约)、48项浏览器和lint通过;完整CI1116项(986单元+14工程+116基线)通过,另44项重复契约观察。284生产TS文件严格检查。本步done并独立提交,已复现风险保持open,由03/05完成修复和原生验收。 diff --git a/refactor/dpip-validation.md b/refactor/dpip-validation.md index 2b0d099d7..ea1f21d4b 100644 --- a/refactor/dpip-validation.md +++ b/refactor/dpip-validation.md @@ -21,3 +21,13 @@ test/helpers/dpip.js是受控DOM/window边界:验证ownerDocument、节点身 证据入口:[01记录](changes/2026-09-12-PKG-DPIP-01-contract.md)、 [01验证](baselines/dpip-contract-validation.json)。正常契约测试通过不关闭源码观察风险。 + +## 生命周期历史复现(02) + +`yarn test:dpip`:48项(四实现×12组),覆盖拒绝/fallback、重复/迟到打开、close/destroy、 +延迟resize、激活重入、adoption失败、占位移动、跨文档样式、source节点身份及重复样式。 +`yarn test:browser test/browser/dpip.spec.js`:48项,四实现×两实际核心×三引擎×两场景。 +真实DOM用于迁移、还原与错误验证;窗口API受控,不能作为原生Document PiP验收。 +当前WebKit的640宽读数与320宽fixture不同,精确保留为历史观察,待05查清尺寸和媒体 +连续性;测试不会宽泛允许任意尺寸。初次错误与修正原因见[02记录](changes/2026-09-12-PKG-DPIP-02-tests.md), +验收状态见[02证据](baselines/dpip-behavior-validation.json)。03必须新增候选修复断言。 diff --git a/refactor/plan.md b/refactor/plan.md index b42e77174..57f9443b9 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -4,7 +4,7 @@ 基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 221 项,范围 22 个包及工作区/示例。 -状态:todo 121 / doing 6 / blocked 0 / done 94 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 120 / doing 6 / blocked 0 / done 95 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -294,7 +294,7 @@ | ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 | | --- | --- | --- | --- | --- | --- | --- | | PKG-DPIP-01 | artplayer-plugin-document-pip
核对包契约与历史用法 | BASE-05 | width/height/placeholder/fallbackToVideoPiP、open/close/toggle 返回 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done | -| PKG-DPIP-02 | artplayer-plugin-document-pip
建立特有行为与错误测试 | PKG-DPIP-01, ENG-03, ENG-05 | 不支持/拒绝、重复打开、pagehide、核心销毁和视频 PiP fallback | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | todo | +| PKG-DPIP-02 | artplayer-plugin-document-pip
建立特有行为与错误测试 | PKG-DPIP-01, ENG-03, ENG-05 | 不支持/拒绝、重复打开、pagehide、核心销毁和视频 PiP fallback | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done | | PKG-DPIP-03 | artplayer-plugin-document-pip
整理内部职责与资源 | PKG-DPIP-02, CORE-16, CORE-17 | 窗口生命周期、DOM 迁移、样式与事件 document 重绑分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo | | PKG-DPIP-04 | artplayer-plugin-document-pip
迁移自有源码和公开类型 | PKG-DPIP-03, ENG-04, ENG-06, CORE-07 | Document PiP 可选能力和真实状态/返回类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo | | PKG-DPIP-05 | artplayer-plugin-document-pip
验证新旧核心和组合 | PKG-DPIP-04, CORE-22, PKG-CANVAS-04, PKG-MB-04 | 原生视频及两个 proxy、键盘/焦点/全屏与关闭还原 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo | @@ -507,6 +507,7 @@ - PKG-VAST-02: [记录](changes/2026-09-12-PKG-VAST-02-tests.md) [记录](baselines/vast-behavior-validation.json) [记录](baselines/vast-core.json) [记录](vast-validation.md) - PKG-VAST-03: [记录](changes/2026-09-12-PKG-VAST-03-lifecycle.md) [记录](baselines/vast-lifecycle-validation.json) [记录](vast-compatibility-decision.md) - PKG-DPIP-01: [记录](changes/2026-09-12-PKG-DPIP-01-contract.md) [记录](baselines/dpip-release.json) [记录](baselines/dpip-contract.md) [记录](baselines/dpip-contract-validation.json) [记录](dpip-validation.md) +- PKG-DPIP-02: [记录](changes/2026-09-12-PKG-DPIP-02-tests.md) [记录](baselines/dpip-behavior-validation.json) [记录](dpip-validation.md) - PKG-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) - PKG-CANVAS-03: [记录](changes/2026-09-12-PKG-CANVAS-03-lifecycle.md) [记录](baselines/canvas-lifecycle-validation.json) [记录](canvas-validation.md) diff --git a/refactor/progress.md b/refactor/progress.md index abd218791..84792dae8 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,15 @@ # 进度与证据 +## 最新完成:Document PiP-02 生命周期历史复现 + +四实现×12组共48项复现迟到/重复窗口、销毁后效果、DOM回滚失败和样式差异, +另16项正常契约通过。三浏览器×两核心×四实现×两场景48项真实DOM iframe检查通过, +其中历史缺陷断言通过不代表修复;原生窗口/用户激活/连续播放仍待05。 +完整CI1116项通过,另44项重复契约观察,284生产TS文件严格检查。 +当前221项:95 done、6 doing、120 todo。见[本步记录](changes/2026-09-12-PKG-DPIP-02-tests.md) +和[验证证据](baselines/dpip-behavior-validation.json)。下一步03拆分TS状态与资源并修复, +保留历史负例;每任务独立本地提交,不推送/发布。 + ## 最新完成:Document PiP-01 实际发布契约 冻结四个npm归档/21成员及7个Git工作区输入。实际1.0.0缺少三个运行时入口, diff --git a/refactor/risk-table.md b/refactor/risk-table.md index 448c75e86..d80f99d69 100644 --- a/refactor/risk-table.md +++ b/refactor/risk-table.md @@ -162,5 +162,7 @@ | DPIP-PACK-01 | open / 已复现 | Published Document PiP 1.0.0 tarball omits every declared runtime entry | PKG-DPIP-06 | | DPIP-DIST-01 | open / 已复现 | Document PiP namespace.default in 1.0.1/1.0.2 differs from callable 1.1.0 export | PKG-DPIP-04, PKG-DPIP-06 | | DPIP-TYPE-01 | open / 已复现 | Document PiP requires options and declares void actions and writable flags that differ from runtime | PKG-DPIP-04 | -| DPIP-LIFE-01 | open / 源码/产物事实 | Document PiP has no separate ownership for pending window requests and delayed post-transition callbacks | PKG-DPIP-02, PKG-DPIP-03, PKG-DPIP-05 | -| DPIP-DOM-01 | open / 源码/产物事实 | Document PiP copies global-document styles and retains original parent without transactional rollback | PKG-DPIP-02, PKG-DPIP-03, PKG-DPIP-05 | +| DPIP-LIFE-01 | open / 已复现 | Document PiP has no separate ownership for pending window requests and delayed post-transition callbacks | PKG-DPIP-02, PKG-DPIP-03, PKG-DPIP-05 | +| DPIP-DOM-01 | open / 已复现 | Document PiP copies global-document styles and retains original parent without transactional rollback | PKG-DPIP-02, PKG-DPIP-03, PKG-DPIP-05 | +| DPIP-STYLE-01 | open / 已复现 | Document PiP 1.1.0 and frozen source duplicate style IDs when evaluated before DOMContentLoaded | PKG-DPIP-03 | +| DPIP-MEDIA-01 | open / 已复现 | WebKit reports styled width640 for width320 media in the controlled Document PiP iframe matrix | PKG-DPIP-05 | diff --git a/refactor/risks.json b/refactor/risks.json index 3d3cb15c5..642a6419a 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -3486,7 +3486,7 @@ { "id": "DPIP-LIFE-01", "title": "Document PiP has no separate ownership for pending window requests and delayed post-transition callbacks", - "confirmation": "source-observed", + "confirmation": "reproduced", "owners": [ "PKG-DPIP-02", "PKG-DPIP-03", @@ -3498,13 +3498,15 @@ "evidence": [ "refactor/baselines/dpip-release.json", "refactor/baselines/dpip-contract.md", - "refactor/changes/2026-09-12-PKG-DPIP-01-contract.md" + "refactor/changes/2026-09-12-PKG-DPIP-01-contract.md", + "refactor/changes/2026-09-12-PKG-DPIP-02-tests.md", + "refactor/baselines/dpip-behavior-validation.json" ] }, { "id": "DPIP-DOM-01", "title": "Document PiP copies global-document styles and retains original parent without transactional rollback", - "confirmation": "source-observed", + "confirmation": "reproduced", "owners": [ "PKG-DPIP-02", "PKG-DPIP-03", @@ -3516,7 +3518,39 @@ "evidence": [ "refactor/baselines/dpip-release.json", "refactor/baselines/dpip-contract.md", - "refactor/changes/2026-09-12-PKG-DPIP-01-contract.md" + "refactor/changes/2026-09-12-PKG-DPIP-01-contract.md", + "refactor/changes/2026-09-12-PKG-DPIP-02-tests.md", + "refactor/baselines/dpip-behavior-validation.json" + ] + }, + { + "id": "DPIP-STYLE-01", + "title": "Document PiP 1.1.0 and frozen source duplicate style IDs when evaluated before DOMContentLoaded", + "owners": [ + "PKG-DPIP-03" + ], + "compatibleResolution": "Preserve the style id and injection timing while deduplicating at insertion and releasing loading listeners.", + "closureCriteria": "Historical two-node versus Parcel cached one-node difference stays explicit; candidate has one final style node under duplicate evaluation.", + "confirmation": "reproduced", + "status": "open", + "evidence": [ + "refactor/changes/2026-09-12-PKG-DPIP-02-tests.md", + "refactor/baselines/dpip-behavior-validation.json" + ] + }, + { + "id": "DPIP-MEDIA-01", + "title": "WebKit reports styled width640 for width320 media in the controlled Document PiP iframe matrix", + "owners": [ + "PKG-DPIP-05" + ], + "compatibleResolution": "Use independent native media controls to determine the dimension behavior and verify real Document PiP playback and proxy dimensions.", + "closureCriteria": "Exact browser/version evidence distinguishes layout-driven reports from intrinsic pixels and validates native media continuity; do not treat controlled iframe success as native PiP acceptance.", + "confirmation": "reproduced", + "status": "open", + "evidence": [ + "refactor/changes/2026-09-12-PKG-DPIP-02-tests.md", + "refactor/baselines/dpip-behavior-validation.json" ] } ] diff --git a/refactor/tasks.json b/refactor/tasks.json index 4c18c61f9..0bec1c66f 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -3009,11 +3009,15 @@ "ENG-03", "ENG-05" ], - "status": "todo", + "status": "done", "risk": "H", "deliverable": "不支持/拒绝、重复打开、pagehide、核心销毁和视频 PiP fallback", "acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言", - "evidence": [] + "evidence": [ + "changes/2026-09-12-PKG-DPIP-02-tests.md", + "baselines/dpip-behavior-validation.json", + "dpip-validation.md" + ] }, { "id": "PKG-DPIP-03", diff --git a/test/README.md b/test/README.md index 4b5042550..e564222d1 100644 --- a/test/README.md +++ b/test/README.md @@ -54,3 +54,5 @@ These tests are separate from the fast Node suite and run in the Browser playbac formats with a controlled Audio object: timing intent, offsets, source updates, rejection, volume/rate, independent instances and cleanup. Published-only tests retain defect evidence. See [audio validation](../refactor/audio-validation.md) for actual media tests and limitations. + +Document PiP: `yarn test:dpip` runs 48 historical window/DOM/lifecycle cases. Native DOM iframe checks use `yarn test:browser test/browser/dpip.spec.js`; controlled window APIs do not establish native Document PiP support. See [validation notes](../refactor/dpip-validation.md). diff --git a/test/browser/dpip.spec.js b/test/browser/dpip.spec.js new file mode 100644 index 000000000..e5f30fbc6 --- /dev/null +++ b/test/browser/dpip.spec.js @@ -0,0 +1,90 @@ +import { hash } from '../../refactor/scripts/releases.mjs' +import { dpipHistorical } from '../helpers/dpip.js' +import { expect, test } from './fixtures.js' + +const implementations = await dpipHistorical() + +async function setup(page, core, implementation, testInfo) { + await page.goto(`/test/player.html?core=${core}`) + await page.evaluate(() => { + window.dpipEvidence = { nativeCapability: typeof window.documentPictureInPicture?.requestWindow === 'function', scope: 'real DOM iframe with controlled requestWindow; not native Document PiP', windows: [], requests: [], events: [] } + Object.defineProperty(window, 'documentPictureInPicture', { configurable: true, value: { + requestWindow(option) { + return new Promise((resolve) => { + window.dpipEvidence.requests.push({ option, resolve }) + }) + }, + } }) + window.resolveDpip = (index) => { + const frame = document.createElement('iframe') + frame.width = '640' + frame.height = '360' + frame.style.cssText = 'display:block;border:0;width:640px;height:360px' + document.body.appendChild(frame) + const popup = frame.contentWindow + const record = { frame, popup, closed: false } + Object.defineProperty(popup, 'close', { configurable: true, value: () => { + record.closed = true + } }) + window.dpipEvidence.windows.push(record) + window.dpipEvidence.requests[index].resolve(popup) + } + }) + await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.dpipFactory = module.exports.default || module.exports; })();` }) + await page.evaluate(() => { + window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, plugins: [window.dpipFactory()] }) + window.dpip = window.art.plugins.artplayerPluginDocumentPip + window.dpipPlayer = window.art.template.$player + window.dpipVideo = window.art.video + window.dpipParent = window.dpipPlayer.parentNode + window.art.on('document-pip', active => window.dpipEvidence.events.push(active)) + }) + await expect.poll(() => page.evaluate(() => window.art.video.readyState)).toBeGreaterThanOrEqual(2) + await testInfo.attach('dpip-input', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), core, ...(await page.evaluate(() => ({ capability: window.dpipEvidence.nativeCapability, scope: window.dpipEvidence.scope }))) }) }) +} + +for (const implementation of implementations) { + for (const core of ['published', 'candidate']) { + test(`Document PiP ${implementation.name}/${core}: real DOM migration and pagehide restore preserve media and controls`, async ({ page }, testInfo) => { + await setup(page, core, implementation, testInfo) + await page.evaluate(() => { + window.dpipOpening = window.dpip.open() + window.resolveDpip(0) + }) + await page.evaluate(() => window.dpipOpening) + await expect.poll(() => page.evaluate(() => window.dpipVideo.videoWidth)).toBe(testInfo.project.name === 'webkit' ? 640 : 320) + expect(await page.evaluate(() => ({ active: window.dpip.isActive, adopted: window.dpipPlayer.ownerDocument === window.dpipEvidence.windows[0].popup.document, videoIdentity: window.dpipVideo === window.art.video, placeholder: window.dpipParent.querySelectorAll('.artplayer-document-pip-placeholder').length, width: window.dpipVideo.videoWidth }))).toEqual({ active: true, adopted: true, videoIdentity: true, placeholder: 1, width: testInfo.project.name === 'webkit' ? 640 : 320 }) + await page.evaluate(() => { + const popup = window.dpipEvidence.windows[0].popup + popup.dispatchEvent(new popup.Event('pagehide')) + popup.dispatchEvent(new popup.Event('unload')) + }) + await expect.poll(() => page.evaluate(() => window.dpip.isActive)).toBe(false) + expect(await page.evaluate(() => ({ restored: window.dpipPlayer.parentNode === window.dpipParent && window.dpipPlayer.ownerDocument === document, videoIdentity: window.art.video === window.dpipVideo, placeholders: document.querySelectorAll('.artplayer-document-pip-placeholder').length, closed: window.dpipEvidence.windows[0].closed, events: window.dpipEvidence.events }))).toEqual({ restored: true, videoIdentity: true, placeholders: 0, closed: true, events: [true, false] }) + await page.evaluate(() => window.art.destroy()) + }) + + test(`Document PiP ${implementation.name}/${core}: historical pending close and moved placeholder fail in real DOM`, async ({ page }, testInfo) => { + await setup(page, core, implementation, testInfo) + await page.evaluate(async () => { + window.dpipOpening = window.dpip.open() + await window.dpip.close() + window.resolveDpip(0) + await window.dpipOpening + }) + expect(await page.evaluate(() => window.dpip.isActive)).toBe(true) + await page.evaluate(async () => { + document.body.appendChild(document.querySelector('.artplayer-document-pip-placeholder')) + const closing = window.dpip.close() + window.dpipFailureSnapshot = { active: window.dpip.isActive, detached: window.dpipPlayer.parentNode === null, closed: window.dpipEvidence.windows[0].closed, notice: window.art.template.$noticeInner.textContent } + await closing + }) + expect(await page.evaluate(() => window.dpipFailureSnapshot)).toEqual({ active: true, detached: true, closed: false, notice: 'Document Picture-in-Picture close failed' }) + await page.evaluate(async () => { + window.dpipParent.appendChild(document.querySelector('.artplayer-document-pip-placeholder')) + await window.dpip.close() + window.art.destroy() + }) + }) + } +} diff --git a/test/dpip.test.js b/test/dpip.test.js new file mode 100644 index 000000000..5b6b061f2 --- /dev/null +++ b/test/dpip.test.js @@ -0,0 +1,227 @@ +import assert from 'node:assert/strict' +// eslint-disable-next-line test/no-import-node-test -- Frozen historical failures remain separate from candidate regression tests. +import test from 'node:test' +import { dpipEnvironment, dpipHistorical } from './helpers/dpip.js' + +function deferred() { + let resolve + let reject + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +for (const implementation of await dpipHistorical()) { + const label = implementation.name + + test(`Document PiP ${label}: rejection retains notice and node identity; retry and pagehide restore once`, async () => { + let reject = true + const error = new Error('request denied') + const env = dpipEnvironment(implementation, { requestWindow: (_, create) => reject ? Promise.reject(error) : Promise.resolve(create()) }) + const result = env.factory()(env.art) + assert.equal(await result.open(), undefined) + assert.equal(result.isActive, false) + assert.equal(env.player.parentNode, env.parent) + assert.equal(env.warnings[0][1], error) + assert.match(env.art.notice.show, /open failed/) + reject = false + await result.open() + const win = env.windows[0] + win.dispatchEvent({ type: 'pagehide' }) + win.dispatchEvent({ type: 'unload' }) + await env.flush() + assert.equal(result.isActive, false) + assert.equal(win.closed, true) + assert.deepEqual(env.parent.children, [env.player, env.sibling]) + assert.deepEqual(env.emitted.filter(event => event.name === 'document-pip').map(event => event.args[0]), [true, false]) + assert.equal([...win.listeners.values()].reduce((sum, set) => sum + set.size, 0), 0) + }) + + test(`Document PiP ${label}: disabled fallback reports an open failure and throwing video fallback rejects unchanged`, async () => { + const env = dpipEnvironment(implementation, { supported: false }) + const result = env.factory({ fallbackToVideoPiP: false })(env.art) + await result.open() + assert.equal(env.art.pip, false) + assert.match(env.art.notice.show, /open failed/) + assert.equal(env.requests.length, 0) + const fallback = dpipEnvironment(implementation, { supported: false }) + const error = new Error('video PiP denied') + Object.defineProperty(fallback.art, 'pip', { get() { + return false + }, set() { + throw error + } }) + await assert.rejects(fallback.factory()(fallback.art).open(), value => value === error) + assert.equal(fallback.warnings.length, 0) + }) + + test(`Document PiP ${label}: close cannot cancel a pending request and late resolution reopens`, async () => { + const pending = deferred() + const env = dpipEnvironment(implementation, { requestWindow: () => pending.promise }) + const result = env.factory()(env.art) + const opening = result.open() + await result.close() + assert.equal(result.isActive, false) + pending.resolve(env.createWindow()) + await opening + assert.equal(result.isActive, true, 'Historical defect: close lost to pending open') + assert.notEqual(env.player.ownerDocument, env.document) + await result.close() + }) + + test(`Document PiP ${label}: destroy does not revoke pending requests or escaped open methods`, async () => { + const pending = deferred() + const env = dpipEnvironment(implementation, { requestWindow: () => pending.promise }) + const result = env.factory()(env.art) + const opening = result.open() + env.art.destroy() + pending.resolve(env.createWindow()) + await opening + assert.equal(env.art.isDestroy, true) + assert.equal(result.isActive, true, 'Historical defect: late window takes a destroyed player') + await result.close() + await result.open() + assert.equal(env.requests.length, 2, 'Historical escaped method starts another request') + assert.equal(result.isActive, true) + await result.close() + }) + + test(`Document PiP ${label}: concurrent opens leak the first window and restore into that window`, async () => { + const first = deferred() + const second = deferred() + let request = 0 + const env = dpipEnvironment(implementation, { requestWindow: () => request++ === 0 ? first.promise : second.promise }) + const result = env.factory()(env.art) + const a = result.open() + const b = result.open() + const firstWindow = env.createWindow() + const secondWindow = env.createWindow() + first.resolve(firstWindow) + await a + second.resolve(secondWindow) + await b + assert.equal(env.requests.length, 2) + await result.close() + assert.equal(result.isActive, false) + assert.equal(firstWindow.closed, false) + assert.equal(secondWindow.closed, true) + assert.equal(env.player.ownerDocument, firstWindow.document, 'Historical restore target was overwritten by the second open') + assert.equal(firstWindow.listeners.get('pagehide').size, 1) + assert.equal(env.parent.children[0].className, 'artplayer-document-pip-placeholder') + }) + + test(`Document PiP ${label}: delayed open and close resize effects survive terminal destroy`, async () => { + const delays = [] + const env = dpipEnvironment(implementation, { sleep: () => { + const pending = deferred() + delays.push(pending) + return pending.promise + } }) + const result = env.factory()(env.art) + const opening = result.open() + await env.flush() + assert.equal(delays.length, 1) + env.art.destroy() + assert.equal(delays.length, 2) + const before = env.emitted.length + for (const delay of delays) delay.resolve() + await opening + await env.flush() + assert.deepEqual(env.emitted.slice(before).map(event => event.name), ['resize', 'resize']) + assert.equal(result.isActive, false) + }) + + test(`Document PiP ${label}: destroy during activation leaves a later resize and retained plugin subscriptions`, async () => { + const env = dpipEnvironment(implementation) + const result = env.factory()(env.art) + env.art.on('document-pip', (active) => { + if (active) + env.art.destroy() + }) + await result.open() + await env.flush() + assert.equal(result.isActive, false) + assert.equal(env.controls.has('document-pip'), true) + assert.equal(env.subscriptions.get('destroy').size, 1) + assert.equal(env.controls.get('document-pip').node.listeners.get('click').size, 1) + assert.equal(env.emitted.filter(event => event.name === 'resize').length, 2) + }) + + test(`Document PiP ${label}: failed adoption retains active window and placeholder until explicit close`, async () => { + const env = dpipEnvironment(implementation, { requestWindow: (_, create) => { + const win = create() + win.document.adoptNode = () => { + throw new Error('adoption refused') + } + return Promise.resolve(win) + } }) + const result = env.factory()(env.art) + await result.open() + assert.equal(result.isActive, true) + assert.equal(env.windows[0].closed, false) + assert.equal(env.parent.children.length, 3) + assert.match(env.art.notice.show, /open failed/) + await result.close() + assert.deepEqual(env.parent.children, [env.player, env.sibling]) + }) + + test(`Document PiP ${label}: moving the placeholder makes restoration detach the player and retain the window`, async () => { + const env = dpipEnvironment(implementation) + const result = env.factory()(env.art) + await result.open() + const placeholder = env.parent.children[0] + const alternate = env.document.createElement('section') + env.document.body.appendChild(alternate) + alternate.appendChild(placeholder) + await result.close() + assert.match(env.art.notice.show, /close failed/) + assert.equal(result.isActive, true) + assert.equal(env.windows[0].closed, false) + assert.equal(env.player.parentNode, null) + assert.equal(env.windows[0].listeners.get('pagehide').size, 0) + }) + + test(`Document PiP ${label}: foreign ownerDocument styles are omitted while node restoration still works`, async () => { + const env = dpipEnvironment(implementation) + const foreign = env.createWindow().document + const foreignStyle = foreign.createElement('style') + foreignStyle.textContent = '.foreign { color: red }' + foreign.head.appendChild(foreignStyle) + foreign.body.appendChild(env.parent) + const result = env.factory()(env.art) + await result.open() + const win = env.windows[1] + assert.equal(win.document.querySelectorAll('style').some(style => style.textContent === foreignStyle.textContent), false) + await result.close() + assert.equal(env.player.ownerDocument, foreign) + assert.deepEqual(env.parent.children, [env.player, env.sibling]) + }) + + test(`Document PiP ${label}: source replacement keeps player identity and popup resize calls the current method`, async () => { + const env = dpipEnvironment(implementation) + const video = env.document.createElement('video') + env.player.appendChild(video) + const result = env.factory()(env.art) + await result.open() + let resized = 0 + env.art.resize = () => resized++ + video.src = 'second.mp4' + env.art.emit('restart', video.src) + env.windows[0].dispatchEvent({ type: 'resize' }) + assert.equal(resized, 1) + await result.close() + assert.equal(env.player.children[0], video) + assert.equal(video.src, 'second.mp4') + assert.equal(video.ownerDocument, env.document) + }) + + test(`Document PiP ${label}: duplicate evaluation preserves Parcel caching versus newer duplicate style injection`, () => { + const env = dpipEnvironment(implementation, { readyState: 'loading' }) + env.evaluate() + env.document.dispatchEvent({ type: 'DOMContentLoaded' }) + const parcelCached = ['published-1.0.1', 'published-1.0.2'].includes(label) + assert.equal(env.document.querySelectorAll('style').filter(style => style.id === 'artplayer-plugin-document-pip').length, parcelCached ? 1 : 2) + }) +} diff --git a/test/helpers/dpip.js b/test/helpers/dpip.js index a11bdb563..9f570e73d 100644 --- a/test/helpers/dpip.js +++ b/test/helpers/dpip.js @@ -188,7 +188,7 @@ export function dpipEnvironment(implementation, settings = {}) { } const context = vm.createContext({ window, document, console: { warn: (...args) => warnings.push(args) }, module: { exports: {} } }) context.exports = context.module.exports - const evaluate = () => vm.runInContext(implementation.code, context, { timeout: 5000 }) + const evaluate = () => vm.runInContext(`(() => { ${implementation.code}\n })()`, context, { timeout: 5000 }) evaluate() return { factory: context.module.exports.default || context.module.exports,