test(ads): [PKG-ADS-02] cover legacy timing and real media contracts

This commit is contained in:
Harvey Zhao committed 2026-09-12 07:26:58 +08:00
1 parent c6b82c3392
commit 069ad4aae1
15 files changed
+1519 -17

No files matched your search

+2 -1
View File
@@ -34,6 +34,7 @@
"build:i18n": "node ./scripts/build-i18n.js", "build:i18n": "node ./scripts/build-i18n.js",
"build:test": "node ./scripts/build-test.js", "build:test": "node ./scripts/build-test.js",
"test:playback": "node --test test/playback.test.js", "test:playback": "node --test test/playback.test.js",
"test:ads": "node --test test/ads.test.js",
"trans:docs": "node ./scripts/trans-docs.js", "trans:docs": "node ./scripts/trans-docs.js",
"create:plugin": "node ./scripts/plugin/create.js", "create:plugin": "node ./scripts/plugin/create.js",
"test:dash-control": "node --test test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js", "test:dash-control": "node --test test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js",
@@ -50,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports", "ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js", "test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs", "typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test 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/ads.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:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline", "test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test", "test:browser": "playwright test",
+58
View File
@@ -0,0 +1,58 @@
# Ads 回归测试与维护入口
Ads 的实际发布行为以 [1.0.6 归档](baselines/ads-release.json) 为基线;工作区 2.1.0
未发布,其旧 bundle 单独从冻结 Git 提交加载。不要将当前声明里的 source/type 当成
发布 JS 支持的输入;正常广告仍使用 html/video/url,图片通过 HTML img 提供。
## 可执行入口
使用仓库固定 Node/Yarn 工具链:
```sh
yarn test:ads
yarn test:unit
yarn test:browser test/browser/ads.spec.js
```
Node 测试已接入 test:unit,因此 ci:check 会执行。浏览器测试独立运行;共享服务器端口
8084 和报告目录,每轮运行后先归档 report.json 与完整 results,再启动下一轮。
没有新增依赖、锁文件或生产打包规则变更。
`ARTPLAYER_TEST_ADS` 可以指定额外正式 JS/legacy/mjs 产物,多个文件使用本机路径分隔符。
`ARTPLAYER_ADS_ARTIFACT` 给浏览器指定单个 script 产物,默认按仓库 Vite 配置从源码
内存构建。路径无效直接失败,不自动回退。实际发布 bundle 总是先校验归档及成员哈希。
## 边界与文件职责
- `test/helpers/ads.js`:来源加载、冻结 bundle 的 VM、可控时钟和最小 ArtPlayer 宿主。
使用真实 option-validator,记录计时/播放/DOM 操作意图;节点不是 HTML 解析器、布局
引擎或媒体解码器。多实例共用一个测试时钟,独立维护监听、节点和归一化选项。
- `test/ads.test.js`:正常工厂/配置/优先级、倒计时、按钮阈值、公开方法返回、事件参数
身份、独立实例、restart、静音、visibility 与错误顺序;历史缺陷只断言冻结实现。
- `test/browser/ads.spec.js`:实际 npm 1.0.6/候选源码与核心 5.4.1/候选四组合,使用
本地图片与 MP4,验证解码像素、时间推进、点击、主内容恢复、404 和真实切源。
每例附核心来源、插件 SHA、状态、浏览器版本;失败保留截图和 trace。
播放拒绝用例精确注入 NotAllowedError,只拦截指定广告或广告后的主视频 play。
仅对匹配的预期 unhandledrejection 记录并 preventDefault,其他页面异常仍使测试失败。
这证明旧插件的 Promise 处理路径,不能当作真实设备自动播放策略验收。
零阈值按钮测试初始化后暂停倒计时,以稳定观察首个 tick 前行为;正常视频测试不模拟时间。
Windows WebKit 的 videoWidth 可能是布局宽度,解码验证使用本地样本的实际颜色像素。
## 已复现的历史缺陷与接续
Node 同时复现 npm 1.0.6 和冻结工作区实现的八类问题:重复 play 建立多条计时链;
skip 后残留回调/重复完成;destroy 后仍请求主片播放;零阈值点击首秒无效;初始化前
skip 请求播放后抛错;复用工厂使首实例事件参数指向第二实例;重复 metadata 重复启动;
初始化前 play 的回调访问不存在的 DOM。
浏览器另外观察发布版广告 play 被拒后倒计时继续,以及主片恢复被拒后广告隐藏并
发出 skip,两条路径均泄漏未处理拒绝。历史观察不是候选验收要求;03 的修复需要
对应候选回归,保持正常事件顺序、同步结果与 play/pause 只管理倒计时的既有语义。
ADS-LIFE-01、ADS-MEDIA-01、ADS-UI-01 继续 open。全屏按钮/外部全屏状态、快速
metadata、真实隐藏页媒体、销毁资源回收、旧核心 4.5.5、实际设备策略、隔离打包与
完整 demo 验收由 03~06 接续。不能用本轮 5.4.1 组合扩大历史核心支持范围。
本步输入、原始失败和最终结果见 [执行记录](baselines/ads-validation.json);生产模块
地图在 03 实际拆分时写入包内文档,本文件只解释测试维护边界。
+7
View File
@@ -63,3 +63,10 @@ bundle-text 导入;私有打包器标识不属于稳定 API,不能把这段
上述除实际 export 形状外,运行路径问题先记 source-observed,由 02 复现再在 03 修正。 上述除实际 export 形状外,运行路径问题先记 source-observed,由 02 复现再在 03 修正。
03 按配置/状态与计时/视图/内容恢复拆分;04 严格 TS 与公开类型;05 真实媒体/旧核心/ 03 按配置/状态与计时/视图/内容恢复拆分;04 严格 TS 与公开类型;05 真实媒体/旧核心/
多实例;06 demo、文档和完整分发。保持本地广告素材,不触发真实投放、跳转或计费服务。 多实例;06 demo、文档和完整分发。保持本地广告素材,不触发真实投放、跳转或计费服务。
## 02 执行补充
上述为 01 捕获时的状态。02 已以 58 项 Node 和 66 项三引擎浏览器测试固定正常行为,
复现计时/工厂复用/过早调用/销毁、零阈值及两类播放拒绝问题;三个相关风险保持 open。
实际结果、原始失败和仍待验证边界见 [Ads 验证说明](../ads-validation.md) 及
[02 记录](../changes/2026-09-12-PKG-ADS-02-tests.md)。本步不修改生产接口。
+527
View File
@@ -0,0 +1,527 @@
{
"schemaVersion": 1,
"task": "PKG-ADS-02",
"sourceCommit": "c6b82c33",
"inputs": [
{
"file": "test/helpers/ads.js",
"sha256LF": "2301a057612cd752863f1fea9b65860dbb65ba4b029303a784f8759fdadacf6c"
},
{
"file": "test/ads.test.js",
"sha256LF": "1aa325c9982aea2de61b68c9688a3640c628a1a47a672080655de729ccd37c64"
},
{
"file": "test/browser/ads.spec.js",
"sha256LF": "ab7541748ed89185429e6307bf5caf96221d91fbf29c067d62cd54f5b207f5bd"
},
{
"file": "refactor/baselines/ads-release.json",
"sha256LF": "e5b47f7d04fe043aaeb40390da4b4c19d80ff6f8889e8e0f5c806ffa44306b69"
},
{
"file": "package.json",
"sha256LF": "ef7248885ff7e6b7d798150ec22ace1c8dc3a4a10fed79fba653cdf09b4baebb"
}
],
"productionChanged": false,
"node": {
"tests": 58,
"passed": 58,
"failed": 0,
"log": {
"file": "refactor/.cache/ads02-node.log",
"sha256": "1f8ab521d7c9856239f3bdc55e59ae089f8b4efa50e3319c858fb749d70ea81f",
"bytes": 6034
},
"scope": "14 shared contracts across source/npm1.0.6/frozen-workspace2.1.0 and 8 historical defects across two frozen implementations."
},
"browser": [
{
"name": "first",
"note": "Fixture error: accidental window prefix in event-name strings; original report retained.",
"stats": {
"startTime": "2026-09-11T23:17:11.938Z",
"duration": 43755.335,
"expected": 2,
"skipped": 0,
"unexpected": 16,
"flaky": 0
},
"report": {
"file": "refactor/.cache/ads02-browser-first-report.json",
"sha256": "1f75f147f6d9547b024bd4dd567b1117f84e283ea07c8d5aad02680db12be9d5",
"bytes": 714132
},
"log": {
"file": "refactor/.cache/ads02-browser-first.log",
"sha256": "4a93233ec7bd568a34b4acee199afea67679e049ba5bc4f26c390f9c455b5fc0",
"bytes": 51425
},
"results": [
{
"file": "refactor/.cache/ads02-browser-first-results/.last-run.json",
"sha256": "277f7af5229ae3845c8f2eebca17747ecab246343884ac63d5e4f8a2224dde85",
"bytes": 831
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-candida-4af82-completion-restores-content-chromium/error-context.md",
"sha256": "05447494c0ba235ab5126353f802175c8dd1b706d73fa782660f5e99f9443088",
"bytes": 11517
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-candida-4af82-completion-restores-content-chromium/test-failed-1.png",
"sha256": "6d583742166e610c20655efe045bd91f370069c21570e92d6fd1a1a5309944e7",
"bytes": 85742
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-candida-4af82-completion-restores-content-chromium/trace.zip",
"sha256": "2e7c69b1142fdf5657231880d3225f291dfb1c6fd0bf4048452ee7b570602364",
"bytes": 962815
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-candida-a6f9a-reshold-and-resumed-content-chromium/error-context.md",
"sha256": "4242932aef9d82d2e49ae4b0282b67e7446f851e6f919e1f0efb45482f8c363e",
"bytes": 11494
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-candida-a6f9a-reshold-and-resumed-content-chromium/test-failed-1.png",
"sha256": "67f6d29acf3b6d8a2ce4ff9960bd98438fa5111ef28b6594625e0710e1dd9baa",
"bytes": 85451
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-candida-a6f9a-reshold-and-resumed-content-chromium/trace.zip",
"sha256": "99def7123d37fdd97922db1fae23c1274428322b39232636f3b11506bf20fa9e",
"bytes": 774428
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-candida-bc3ff-ls-open-to-playable-content-chromium/error-context.md",
"sha256": "e66b8bee07a7bb6ca3ae0d40ba47e6196e8c040a81eb046e59e97cbd87686262",
"bytes": 11359
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-candida-bc3ff-ls-open-to-playable-content-chromium/test-failed-1.png",
"sha256": "c7aeb49e2d94bdeca9e7cf96c36c930d8e82013124eac1b63825cbde79de9d51",
"bytes": 84845
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-candida-bc3ff-ls-open-to-playable-content-chromium/trace.zip",
"sha256": "cfbf508a33445bad979ed0ce731de99ab7282cd5273860e9bc5a293e2297e843",
"bytes": 2500977
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-candida-e65a0-acement-does-not-replay-ads-chromium/error-context.md",
"sha256": "d63140df98c0bd5a5c1382d6546dafebb2ddf425b99ff2a87f878c15d867663a",
"bytes": 11904
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-candida-e65a0-acement-does-not-replay-ads-chromium/test-failed-1.png",
"sha256": "9670500dec0dbca163cc3abcf1a55933da5d05553b6495381467f4446c861c28",
"bytes": 83113
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-candida-e65a0-acement-does-not-replay-ads-chromium/trace.zip",
"sha256": "8218520fe7bd3addeec3483aa111014a7cea77e311444d8c8a952fdefbf9be67",
"bytes": 327973
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-publish-1efa0-completion-restores-content-chromium/error-context.md",
"sha256": "a6e76cc8c411fa74f68b08354b3898ac6ea76a29dd9438891de59e62d05ee430",
"bytes": 11517
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-publish-1efa0-completion-restores-content-chromium/test-failed-1.png",
"sha256": "8b2b93410bae5ccb75858fe9e7cee0b13b1c1c5f72ee24ee71887699ffe0911b",
"bytes": 85667
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-publish-1efa0-completion-restores-content-chromium/trace.zip",
"sha256": "643bd9845f0115ed261e3806c7a1eb724d1baad2beb878f555cce5ab69509774",
"bytes": 981934
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-publish-7ce1f-acement-does-not-replay-ads-chromium/error-context.md",
"sha256": "b06bd805a8c8592a5b0c10cdf1a20a21333926fad1edccd22c7fe0491d98cb06",
"bytes": 10063
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-publish-7ce1f-acement-does-not-replay-ads-chromium/test-failed-1.png",
"sha256": "1bc111cc89a2f5182abca3991faac494e7bb08eb17f7e197cc99c360eb9bea2d",
"bytes": 85757
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-publish-7ce1f-acement-does-not-replay-ads-chromium/trace.zip",
"sha256": "2b9a7b66770c1f8e0be41867c6395920a263fef345f39ab152c44be656b900bb",
"bytes": 827835
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-publish-bd6d9-ls-open-to-playable-content-chromium/error-context.md",
"sha256": "116b870a3c9a632ea24cafa63c340a9433d32ebb52629cebb95a70d832c09c9c",
"bytes": 11359
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-publish-bd6d9-ls-open-to-playable-content-chromium/test-failed-1.png",
"sha256": "c7aeb49e2d94bdeca9e7cf96c36c930d8e82013124eac1b63825cbde79de9d51",
"bytes": 84845
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-publish-bd6d9-ls-open-to-playable-content-chromium/trace.zip",
"sha256": "51cc4158ec08dc7ffaf772650c0504128e7c5988d25590fccd6dca7c0a769cec",
"bytes": 2502705
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-publish-d3f59-reshold-and-resumed-content-chromium/error-context.md",
"sha256": "849c351ca5e52c1f2d318ea0b52f66a0d545d3a5a83e82eb0227e1341ee11a62",
"bytes": 11494
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-publish-d3f59-reshold-and-resumed-content-chromium/test-failed-1.png",
"sha256": "67f6d29acf3b6d8a2ce4ff9960bd98438fa5111ef28b6594625e0710e1dd9baa",
"bytes": 85451
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-candidate-core-publish-d3f59-reshold-and-resumed-content-chromium/trace.zip",
"sha256": "74fdffde8420424a92301d0f561e9257615c83d2d48c66810f8d835934220681",
"bytes": 753295
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-candida-038a7-ls-open-to-playable-content-chromium/error-context.md",
"sha256": "0d8e511d5322d29807ac64113c1d722951d6651da5455d5602ae5aed8b4bf077",
"bytes": 11359
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-candida-038a7-ls-open-to-playable-content-chromium/test-failed-1.png",
"sha256": "ce8bb9e88c0963c81b7b57527545e84d9ef28ade0443e521ccddf658439a7bc3",
"bytes": 86066
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-candida-038a7-ls-open-to-playable-content-chromium/trace.zip",
"sha256": "256cd01936ac9c52f283d5261595c7da272acfc796f5b602cc80a5d599d0fd94",
"bytes": 2497213
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-candida-408f9-reshold-and-resumed-content-chromium/error-context.md",
"sha256": "1a7d860ee698ae3025f15ce5af11766c1804b0b6bf32bb8e31ac99d2aae7b10d",
"bytes": 11494
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-candida-408f9-reshold-and-resumed-content-chromium/test-failed-1.png",
"sha256": "190ef0f38dec3e15535cb24669a255da2be7972b2ae2d282255579952d598f1f",
"bytes": 85592
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-candida-408f9-reshold-and-resumed-content-chromium/trace.zip",
"sha256": "87cfab2ead83790042514d679e81f31e31911f64bc359b1f25dee1f0da2eb26e",
"bytes": 786246
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-candida-5a82d-acement-does-not-replay-ads-chromium/error-context.md",
"sha256": "346452cd39e825afdee8d912887d8c94c5cd31b6ae4eb474eb7406603c0ebb26",
"bytes": 11844
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-candida-5a82d-acement-does-not-replay-ads-chromium/test-failed-1.png",
"sha256": "e89961b465d1cab2e2dadd889c1cd7ec2a4445fadc6e5b64a7a2be4d6d349c52",
"bytes": 82544
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-candida-5a82d-acement-does-not-replay-ads-chromium/trace.zip",
"sha256": "62990cfdd6d6df611258e9f4f81436e08e5deb94715fd931ca97a87845f4eba9",
"bytes": 305094
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-candida-e88cb-completion-restores-content-chromium/error-context.md",
"sha256": "53649990ecd022be23b799c762d919e2a2557d59082bd99fd3b3b2b4de54a2bc",
"bytes": 11517
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-candida-e88cb-completion-restores-content-chromium/test-failed-1.png",
"sha256": "a8c4b0f780656c5c22fde95c33844dca73ce234e326c95b2d626f089d42fbab3",
"bytes": 85827
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-candida-e88cb-completion-restores-content-chromium/trace.zip",
"sha256": "93e4131d8b76e7434c2adff11fc6079d06094d60b6ecd40437ee1b2480d7cf19",
"bytes": 994224
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-publish-4059c-ls-open-to-playable-content-chromium/error-context.md",
"sha256": "8bc40421d083bd4da4a732e42183d4362e71a3ddc9b9f0445a4e646241019a02",
"bytes": 11359
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-publish-4059c-ls-open-to-playable-content-chromium/test-failed-1.png",
"sha256": "e84abc16fb03fadc2ff6c0b9014257309d693708983472cba24d56698cdb644d",
"bytes": 85019
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-publish-4059c-ls-open-to-playable-content-chromium/trace.zip",
"sha256": "2455462a8e7ef451b512870487f31189d5743053701bed59ec108cfb39663002",
"bytes": 2517192
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-publish-70f1d-reshold-and-resumed-content-chromium/error-context.md",
"sha256": "a3a3e05f78e8aa9387fffcd195e814b8b07d08633dab20ad17763718e796af22",
"bytes": 11494
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-publish-70f1d-reshold-and-resumed-content-chromium/test-failed-1.png",
"sha256": "190ef0f38dec3e15535cb24669a255da2be7972b2ae2d282255579952d598f1f",
"bytes": 85592
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-publish-70f1d-reshold-and-resumed-content-chromium/trace.zip",
"sha256": "d18960a99fa8126a57b28c96792c58b750340fbf66b4dba0d00f934103128dcf",
"bytes": 801155
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-publish-b0ec8-completion-restores-content-chromium/error-context.md",
"sha256": "466a7503fc51d9d91452deea73a6cd260c4063f089ac29e1f0e5345117f68eed",
"bytes": 11517
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-publish-b0ec8-completion-restores-content-chromium/test-failed-1.png",
"sha256": "8ca355ad820a8fe9eef893f8e902aa31c81cf154ed2493bbe042f6f8148e5122",
"bytes": 85885
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-publish-b0ec8-completion-restores-content-chromium/trace.zip",
"sha256": "16898ce55027ffb1fe41803cad2e08d618ba46d7b39702032f045b06873867ed",
"bytes": 996878
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-publish-cafa4-acement-does-not-replay-ads-chromium/error-context.md",
"sha256": "6aa961de1b9a539ac4ed0d198357a4c663dcc41b9642d718fa7355485e8f1036",
"bytes": 10063
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-publish-cafa4-acement-does-not-replay-ads-chromium/test-failed-1.png",
"sha256": "a813c19361195cf8a21f10ed75c551d91f1b7ac6f2b28e85aaa591956db25103",
"bytes": 85938
},
{
"file": "refactor/.cache/ads02-browser-first-results/ads-published-core-publish-cafa4-acement-does-not-replay-ads-chromium/trace.zip",
"sha256": "1040ec019bfb2a0596fbbd97d37ac104f5cc955496804925bd87b30e40e566bb",
"bytes": 1069405
}
]
},
{
"name": "main",
"note": "Fixture errors: pause before initialization and Windows WebKit layout-sized videoWidth; no production change.",
"stats": {
"startTime": "2026-09-11T23:18:17.690Z",
"duration": 87437.579,
"expected": 45,
"skipped": 0,
"unexpected": 9,
"flaky": 0
},
"report": {
"file": "refactor/.cache/ads02-browser-main-report.json",
"sha256": "c8367b7fe7816a43f240e2deeeab5678b2e621faa2d47f328658e81c5fa849fc",
"bytes": 4379446
},
"log": {
"file": "refactor/.cache/ads02-browser-main.log",
"sha256": "777c8c15c0de7276795b15a0f805645e82381c15b35d72cd7e2fc103072a0fc3",
"bytes": 42791
},
"results": [
{
"file": "refactor/.cache/ads02-browser-main-results/.last-run.json",
"sha256": "ddff586209b5e0d613af233bcdd6916c98eb6d1cd7d843677a03f501a265d863",
"bytes": 488
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-candida-4af82-completion-restores-content-webkit/error-context.md",
"sha256": "1ffa2343602c5ec45c07dd286ab36ddbd7c05d046e7023d0f74cb83203f295f6",
"bytes": 11609
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-candida-4af82-completion-restores-content-webkit/test-failed-1.png",
"sha256": "9661ab1955197d0d14d43e5886b1d915368b6108ebae7822e24f2ce5cc23126a",
"bytes": 284210
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-candida-4af82-completion-restores-content-webkit/trace.zip",
"sha256": "6fbbb702ec5dd582b083371722cdb36c4bb57ce019b83a133b52ee6ebb2c9678",
"bytes": 474156
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-candida-e65a0-acement-does-not-replay-ads-chromium/error-context.md",
"sha256": "b397553768ba42ddbcdc11dfb395f9efdd0d8f521d3f0235344befe19b8bbaac",
"bytes": 11890
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-candida-e65a0-acement-does-not-replay-ads-chromium/test-failed-1.png",
"sha256": "850afa86feea866de96bb79010972202cd50963a1a00d63993c2407f277fc55e",
"bytes": 82366
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-candida-e65a0-acement-does-not-replay-ads-chromium/trace.zip",
"sha256": "81a6d2908790b8e2b953fdd8fc20d7a444a9ae6fe80b467c9d7aa5f6105d6147",
"bytes": 309426
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-publish-1efa0-completion-restores-content-webkit/error-context.md",
"sha256": "8f283bdd5ed4524d757f9a6b341d9981c768fe99d3c958cd6124f17819b145d9",
"bytes": 11609
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-publish-1efa0-completion-restores-content-webkit/test-failed-1.png",
"sha256": "9661ab1955197d0d14d43e5886b1d915368b6108ebae7822e24f2ce5cc23126a",
"bytes": 284210
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-publish-1efa0-completion-restores-content-webkit/trace.zip",
"sha256": "8a763218a56f680a4153ec45e7a5fba1f09c0f69a504e5adea05eea2d7e4cfdb",
"bytes": 482775
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-publish-7ce1f-acement-does-not-replay-ads-chromium/error-context.md",
"sha256": "f7978d6f06677fc98a4cd3f2a65c398ea6a71ee6c516f1be5d54bf19144c1ac0",
"bytes": 11890
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-publish-7ce1f-acement-does-not-replay-ads-chromium/test-failed-1.png",
"sha256": "9670500dec0dbca163cc3abcf1a55933da5d05553b6495381467f4446c861c28",
"bytes": 83113
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-publish-7ce1f-acement-does-not-replay-ads-chromium/trace.zip",
"sha256": "e88f95d4949c8a742ead3abaeb12daad0907613fe132714aff2c98ee22ec6558",
"bytes": 292129
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-publish-7ce1f-acement-does-not-replay-ads-webkit/error-context.md",
"sha256": "7dd0f72e6174308fe6ff874a8d7c69c010bee358d4366cc6a999cc60d2109e64",
"bytes": 11879
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-publish-7ce1f-acement-does-not-replay-ads-webkit/test-failed-1.png",
"sha256": "7aea52a6492913db997af24e7e0549fbb687c6ff0f8eebc62dfe7663ed966686",
"bytes": 295773
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-candidate-core-publish-7ce1f-acement-does-not-replay-ads-webkit/trace.zip",
"sha256": "37df9e1bddd1332dd0256e84fccda9c48260ef2b70f7d13b01b8f8273c87fde9",
"bytes": 439288
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-published-core-candida-e88cb-completion-restores-content-webkit/error-context.md",
"sha256": "8ab533519aa8d4d6c34683e0d6ab5c18c462f6cff2c81fb0ccc6cda6bed3b0e8",
"bytes": 11609
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-published-core-candida-e88cb-completion-restores-content-webkit/test-failed-1.png",
"sha256": "deb11cedcc6e99a189368adb7884fbbf1fada92f20e546954dd93bfbe63d3f4a",
"bytes": 281691
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-published-core-candida-e88cb-completion-restores-content-webkit/trace.zip",
"sha256": "7e37ab7f87069b038e104b8d4da14801aaae66f44b24cf6195b257917d4f1061",
"bytes": 477790
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-published-core-publish-b0ec8-completion-restores-content-webkit/error-context.md",
"sha256": "a873017516be2c0ce81e3dc0464b557900c9ecbfa33b158b4df5d645d663deed",
"bytes": 11609
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-published-core-publish-b0ec8-completion-restores-content-webkit/test-failed-1.png",
"sha256": "deb11cedcc6e99a189368adb7884fbbf1fada92f20e546954dd93bfbe63d3f4a",
"bytes": 281691
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-published-core-publish-b0ec8-completion-restores-content-webkit/trace.zip",
"sha256": "6a3bc7bee58db58a56d6819b1773980c9ccd4d704f4461936400577c07ddb69d",
"bytes": 458980
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-published-core-publish-cafa4-acement-does-not-replay-ads-chromium/error-context.md",
"sha256": "349c384f17f8be457530f496b7dee004ac73fef1dd3f1f8f0389b26a4fc19479",
"bytes": 11830
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-published-core-publish-cafa4-acement-does-not-replay-ads-chromium/test-failed-1.png",
"sha256": "cc675a6a18ac7f88bc57c8b5465d3e0e50da713ec3bd93aab1af3808f391bfb0",
"bytes": 83287
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-published-core-publish-cafa4-acement-does-not-replay-ads-chromium/trace.zip",
"sha256": "0ed30c8369d6c321a881661a6d0cf5a5ed132bc60ece69522bb570fbdfa44e85",
"bytes": 306689
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-published-core-publish-cafa4-acement-does-not-replay-ads-webkit/error-context.md",
"sha256": "35119c774f59077c08d4fa8560fc659eede6d9385be920915072f00bda08801d",
"bytes": 11638
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-published-core-publish-cafa4-acement-does-not-replay-ads-webkit/test-failed-1.png",
"sha256": "c05abe618a1941b6f4ef394a23488d30b966e9ece26ffab1ab2fa0488e80e3e6",
"bytes": 292476
},
{
"file": "refactor/.cache/ads02-browser-main-results/ads-published-core-publish-cafa4-acement-does-not-replay-ads-webkit/trace.zip",
"sha256": "bae6016c038d23fc1161b8d6544148c2eb34ef52329328086264a7a1e8606314",
"bytes": 430948
}
]
},
{
"name": "complete",
"note": "Four core/plugin combinations plus exact published rejection and zero-threshold observations; no skips or retries.",
"stats": {
"startTime": "2026-09-11T23:21:01.439Z",
"duration": 83617.205,
"expected": 66,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"report": {
"file": "refactor/.cache/ads02-browser-complete-report.json",
"sha256": "0136934ce8fe090edad7f5aed7111f0895e61ad12d072b5f6e71f5ae5584e76b",
"bytes": 5925060
},
"log": {
"file": "refactor/.cache/ads02-browser-complete.log",
"sha256": "1d5918f237c457e44227c24047ee2bed941a94a155bca922667290d18ee50413",
"bytes": 12396
},
"results": [
{
"file": "refactor/.cache/ads02-browser-complete-results/.last-run.json",
"sha256": "91d1c43004802cd49950d78eb11c8fa7d05da8ffffe219a8b13b2f561bc00903",
"bytes": 45
}
]
}
],
"ci": {
"file": "refactor/.cache/ads02-ci.log",
"sha256": "3a27de7214c4ecd8344b036f56290eaf89a09b75c3861268fab65e17a4a3c602",
"bytes": 84503,
"status": "passed",
"tests": 806,
"unit": 747,
"engineering": 14,
"baseline": 45,
"productionTypeScript": 262,
"coreDeclarationsUnchanged": 37,
"warning": "Existing generated docs/assets/ts/artplayer.d.ts unused eslint-disable; no errors."
},
"limits": [
"Injected Promise rejection is not device autoplay-policy acceptance.",
"Core 4.5.5 still pending.",
"Source-built candidate only; complete distribution validation belongs to 04-06.",
"Full screen, real hidden-page policy, rapid metadata and teardown media-resource acceptance remain open."
],
"visualReview": {
"file": "refactor/.cache/ads02-video-review.png",
"source": "Complete report candidate core / candidate Ads Chromium ads-video-playing attachment",
"observed": "Decoded local pattern, countdown, muted icon and fullscreen button visible in ad overlay."
}
}
@@ -0,0 +1,56 @@
# PKG-ADS-02:Ads 行为、错误和真实媒体基线
起点 c6b82c33;本步建立测试基线,不修改 Ads 生产源码、公开声明、样式或产物。
使用实际 npm 1.0.6、Git 冻结的未发布工作区 2.1.0,以及当前源码构建;不使用虚构的
npm 2.1.0。完整来源见 [01 契约](../baselines/ads-contract.md)。
## 交付与验收
新增窄范围时钟/宿主 helper 与 58 项 Node 测试:14 项共享行为在三实现运行,8 类
历史缺陷仅在两个冻结实现运行。实际 option-validator 校验配置,包含浅 i18n、参数
身份、互不干扰的实例、首次播放、倒计时暂停、跳过、restart、媒体错误与 destroy。
默认 test:unit 接入,并提供 yarn test:ads;没有新依赖或 lockfile 变动。
新增 66 项三引擎浏览器测试,全部通过、无重试/跳过。每个引擎 16 项为核心
5.4.1/候选 × Ads 1.0.6/候选的四种正常行为组合,另 6 项为两个核心上的三种历史
问题观察。正常组合包含 HTML 图片实际加载、视频解码颜色/时间、静音点击、跳过阈值、
自动完成、实际 404 恢复、计时暂停和切换主视频。截图中广告画面、倒计时与静音/全屏
控件可见;已查看候选核心/候选 Ads 的实际视频截图。
播放拒绝通过精确的媒体 play 注入复现:广告启动拒绝后计时继续;主片恢复拒绝后广告
隐藏并 emit skip,两者均产生未处理拒绝。仅捕获指定错误,其他未处理错误仍导致失败。
这不是设备自动播放认证。零阈值用例暂停倒计时观察真实按钮首 tick 前状态。
完整本地 CI 806 项通过(747 单元、14 工程、45 基线),262 个生产 TS 文件严格检查,
核心 37 份声明无漂移。保留一条既有生成编辑器声明的 unused eslint-disable warning,
没有新增 lint 错误。执行日志与最终结论记录在 [执行证据](../baselines/ads-validation.json)。
生产源码和类型未改,严格检查复用现有项目;完整包分发和原生设备验收仍由后续任务负责。
## 原始失败与修正
- 首轮 Chromium 2 通过、16 失败:给浏览器全局加 window 时误改了事件名字符串,
监听到错误的名字,修复夹具字符串。原始 trace/报告完整保留。
- 第二轮三引擎 45 通过、9 失败:5 个暂停测试在广告初始化前调用方法;补真实 DOM
出现条件。4 个 WebKit 视频测试错误要求 videoWidth=320,而 Windows WebKit 报
布局宽度 640;改为读取实际解码帧的黄/蓝/青色像素,所有引擎使用相同断言。
- 第三轮新增主片拒绝/零阈值观察后 66 项全部通过。没有增加超时、重试或跳过。
- 初始 Node 文件的 lint 换行、多语句和正则规范问题已按仓库规则修正。
## 兼容边界和后续
API-01/04/05/06/08 的可控行为与真实页面用例已经固定。这里没有改变用户调用、
事件顺序、DOM/CSS 或时长规则,也没有把历史缺陷定义为新实现必须保留的行为。
ADS-LIFE-01、ADS-MEDIA-01、ADS-UI-01 从源码观察提升到复现,保持 open;其中
metadata 竞态、真实隐藏页行为和全屏状态仍待验证,不能因部分复现视为整个风险已解决。
03 按配置、计时状态、视图、媒体和生命周期所有权拆分,并为历史缺陷增加候选修复断言;
04 严格 TS/准确声明/旧类型入口;05 保留旧核心 4.5.5 和实际设备、销毁媒体资源等验证;
06 完整产物消费和 demo。正常 play/pause 只管理倒计时的已有语义要保留。
测试文件地图、命令与限制见 [Ads 验证说明](../ads-validation.md),包内架构说明随 03
实际拆分交付,避免提前记录尚不存在的模块。
## 提交与回退
独立本地提交主题:test(ads): [PKG-ADS-02] cover legacy timing and real media contracts。
回退该提交移除本步测试、脚本接入、文档和状态变化,生产行为不受影响。
没有 push、tag、publish 或 merge;全项目重构和发布复盘目标继续。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 217 项,范围 22 个包及工作区/示例。 基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 217 项,范围 22 个包及工作区/示例。
状态:todo 138 / doing 4 / blocked 0 / done 75 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 状态:todo 137 / doing 4 / blocked 0 / done 76 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -257,7 +257,7 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 | | ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| PKG-ADS-01 | artplayer-plugin-ads<br>核对包契约与历史用法 | BASE-05 | 核对 npm 1.0.0~1.0.6 与未发布工作区 2.1.0 的真实参数、类型、结果、倒计时、分发和旧核心来源 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done | | PKG-ADS-01 | artplayer-plugin-ads<br>核对包契约与历史用法 | BASE-05 | 核对 npm 1.0.0~1.0.6 与未发布工作区 2.1.0 的真实参数、类型、结果、倒计时、分发和旧核心来源 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done |
| PKG-ADS-02 | artplayer-plugin-ads<br>建立特有行为与错误测试 | PKG-ADS-01, ENG-03, ENG-05 | 固定 npm 1.0.6 与工作区契约;HTML/视频/图片 HTML、跳过阈值、计时链、工厂复用、多实例、过早/重复方法、播放拒绝与销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | todo | | PKG-ADS-02 | artplayer-plugin-ads<br>建立特有行为与错误测试 | PKG-ADS-01, ENG-03, ENG-05 | 固定 npm 1.0.6 与工作区契约;HTML/视频/图片 HTML、跳过阈值、计时链、工厂复用、多实例、过早/重复方法、播放拒绝与销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-ADS-03 | artplayer-plugin-ads<br>整理内部职责与资源 | PKG-ADS-02, CORE-10, CORE-13 | 配置/广告状态/计时/视图/主视频恢复分离,保留产品规则;资源与版本检查改用经验证的旧核心能力边界 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo | | PKG-ADS-03 | artplayer-plugin-ads<br>整理内部职责与资源 | PKG-ADS-02, CORE-10, CORE-13 | 配置/广告状态/计时/视图/主视频恢复分离,保留产品规则;资源与版本检查改用经验证的旧核心能力边界 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo |
| PKG-ADS-04 | artplayer-plugin-ads<br>迁移自有源码和公开类型 | PKG-ADS-03, ENG-04, ENG-06, CORE-07 | 兼容真实 html/video/url/i18n、旧 export= 与工作区 source/type 类型接受面;时长 string/number 漂移与未实现别名单独处置 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo | | PKG-ADS-04 | artplayer-plugin-ads<br>迁移自有源码和公开类型 | PKG-ADS-03, ENG-04, ENG-06, CORE-07 | 兼容真实 html/video/url/i18n、旧 export= 与工作区 source/type 类型接受面;时长 string/number 漂移与未实现别名单独处置 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo |
| PKG-ADS-05 | artplayer-plugin-ads<br>验证新旧核心和组合 | PKG-ADS-04, CORE-22 | 本地广告 fixture、多实例、正常结束/跳过/销毁;核验历史关联核心 4.5.5、发布 5.4.1 与候选核心的实际归档/能力/媒体组合 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo | | PKG-ADS-05 | artplayer-plugin-ads<br>验证新旧核心和组合 | PKG-ADS-04, CORE-22 | 本地广告 fixture、多实例、正常结束/跳过/销毁;核验历史关联核心 4.5.5、发布 5.4.1 与候选核心的实际归档/能力/媒体组合 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
@@ -485,3 +485,4 @@
- PKG-DASH-04: [记录](changes/2026-09-12-PKG-DASH-04-types.md) [记录](baselines/dash-types-validation.json) [记录](dash-validation.md) - PKG-DASH-04: [记录](changes/2026-09-12-PKG-DASH-04-types.md) [记录](baselines/dash-types-validation.json) [记录](dash-validation.md)
- PKG-DASH-05: [记录](changes/2026-09-12-PKG-DASH-05-sdk-checkpoint.md) [记录](baselines/dash-sdk.json) [记录](baselines/dash-sdk-checkpoint.json) [记录](dash-validation.md) [记录](changes/2026-09-12-PKG-DASH-05-sdk-types.md) [记录](baselines/dash-sdk-types-validation.json) [记录](changes/2026-09-12-PKG-DASH-05-sdk-events.md) [记录](baselines/dash-sdk-events-validation.json) [记录](changes/2026-09-12-PKG-DASH-05-seek-diagnosis.md) [记录](baselines/dash-seek-diagnosis.json) - PKG-DASH-05: [记录](changes/2026-09-12-PKG-DASH-05-sdk-checkpoint.md) [记录](baselines/dash-sdk.json) [记录](baselines/dash-sdk-checkpoint.json) [记录](dash-validation.md) [记录](changes/2026-09-12-PKG-DASH-05-sdk-types.md) [记录](baselines/dash-sdk-types-validation.json) [记录](changes/2026-09-12-PKG-DASH-05-sdk-events.md) [记录](baselines/dash-sdk-events-validation.json) [记录](changes/2026-09-12-PKG-DASH-05-seek-diagnosis.md) [记录](baselines/dash-seek-diagnosis.json)
- PKG-ADS-01: [记录](baselines/ads-release.json) [记录](baselines/ads-contract.md) [记录](baselines/ads-contract-validation.json) [记录](changes/2026-09-12-PKG-ADS-01-contract.md) [记录](scripts/ads-contract.test.mjs) - PKG-ADS-01: [记录](baselines/ads-release.json) [记录](baselines/ads-contract.md) [记录](baselines/ads-contract-validation.json) [记录](changes/2026-09-12-PKG-ADS-01-contract.md) [记录](scripts/ads-contract.test.mjs)
- PKG-ADS-02: [记录](changes/2026-09-12-PKG-ADS-02-tests.md) [记录](ads-validation.md) [记录](baselines/ads-validation.json)
+20
View File
@@ -1,5 +1,25 @@
# 进度与证据 # 进度与证据
## 最新完成:Ads-02 行为与真实媒体回归
新增 58 项 Node 测试,覆盖源码、实际 npm 1.0.6 和冻结的未发布工作区 2.1.0;
倒计时、阈值、方法返回/事件顺序、独立实例、restart、媒体错误及八类历史缺陷有可重跑证据。
Ads 测试接入 test:unit,并提供 yarn test:ads,没有新增依赖或修改生产源码/类型/产物。
三引擎实际浏览器 66 项全部通过、无跳过/重试:5.4.1/候选核心 × 旧/候选插件,
本地图片、视频解码像素、静音/跳过、自动恢复、404、暂停倒计时与切源。额外精确注入
两类 play 拒绝,并复现零阈值首 tick 前点击问题。注入拒绝不等于设备自动播放策略验收。
首轮事件名夹具错误及第二轮初始化等待/Windows WebKit 尺寸假设失败完整保留。
完整本地 CI 806 项通过(747 单元、14 工程、45 基线),262 个生产 TS 严格检查,
核心声明无漂移。Ads 生命周期/媒体/UI 风险仍 open,旧核心 4.5.5、全屏、隐藏页策略、
真实资源销毁、完整分发及设备验收由 03~06 接续;DASH 暂停 seek 等既有风险不变。
当前 217 项:76 done、4 doing、137 todo。下一项 Ads-03 开始配置/计时/视图/媒体与
生命周期拆分及历史缺陷修复。见 [测试说明](ads-validation.md)、
[本步记录](changes/2026-09-12-PKG-ADS-02-tests.md) 和 [执行证据](baselines/ads-validation.json)。
以下为历史检查点当时状态。
## 最新完成:Ads-01 实际发布契约核对 ## 最新完成:Ads-01 实际发布契约核对
工作区 Ads 2.1.0 未发布;实际 npm 五个 1.0.x 版本、30 个成员已校验冻结,主要行为基线 工作区 Ads 2.1.0 未发布;实际 npm 五个 1.0.x 版本、30 个成员已校验冻结,主要行为基线
+3 -3
View File
@@ -144,6 +144,6 @@
| ADS-TYPE-01 | open / 源码/产物事实 | Ads 声明丢失真实 html/video/url,旧 totalDuration 为 string,当前 source/type 未实现 | PKG-ADS-02, PKG-ADS-04 | | ADS-TYPE-01 | open / 源码/产物事实 | Ads 声明丢失真实 html/video/url,旧 totalDuration 为 string,当前 source/type 未实现 | PKG-ADS-02, PKG-ADS-04 |
| ADS-DIST-01 | open / 已复现 | Ads npm 1.0.6 default namespace 与未发布 2.1.0 callable/exports 分发不同 | PKG-ADS-04, PKG-ADS-06, REL-01 | | ADS-DIST-01 | open / 已复现 | Ads npm 1.0.6 default namespace 与未发布 2.1.0 callable/exports 分发不同 | PKG-ADS-04, PKG-ADS-06, REL-01 |
| ADS-CORE-01 | open / 源码/产物事实 | Ads 旧发布关联核心 4.5.5,当前源码新增 >=5 门槛和 document 事件桥依赖 | PKG-ADS-02, PKG-ADS-03, PKG-ADS-05 | | ADS-CORE-01 | open / 源码/产物事实 | Ads 旧发布关联核心 4.5.5,当前源码新增 >=5 门槛和 document 事件桥依赖 | PKG-ADS-02, PKG-ADS-03, PKG-ADS-05 |
| ADS-LIFE-01 | open / 源码/产物事实 | Ads 计时链、重复/过早调用、工厂复用与销毁缺乏资源边界 | PKG-ADS-02, PKG-ADS-03, PKG-ADS-05 | | ADS-LIFE-01 | open / 已复现 | Ads 计时链、重复/过早调用、工厂复用与销毁缺乏资源边界 | PKG-ADS-02, PKG-ADS-03, PKG-ADS-05 |
| ADS-MEDIA-01 | open / 源码/产物事实 | Ads metadata 与 play 拒绝、隐藏页媒体状态和内容恢复仍缺真实验证 | PKG-ADS-02, PKG-ADS-03, PKG-ADS-05 | | ADS-MEDIA-01 | open / 已复现 | Ads metadata 与 play 拒绝、隐藏页媒体状态和内容恢复仍缺真实验证 | PKG-ADS-02, PKG-ADS-03, PKG-ADS-05 |
| ADS-UI-01 | open / 源码/产物事实 | Ads 零等待跳过与初始状态不符,全屏图标/外部状态需核验 | PKG-ADS-02, PKG-ADS-03, PKG-ADS-05 | | ADS-UI-01 | open / 已复现 | Ads 零等待跳过与初始状态不符,全屏图标/外部状态需核验 | PKG-ADS-02, PKG-ADS-03, PKG-ADS-05 |
+44 -9
View File
@@ -3015,30 +3015,65 @@
{ {
"id": "ADS-LIFE-01", "id": "ADS-LIFE-01",
"title": "Ads 计时链、重复/过早调用、工厂复用与销毁缺乏资源边界", "title": "Ads 计时链、重复/过早调用、工厂复用与销毁缺乏资源边界",
"confirmation": "source-observed", "confirmation": "reproduced",
"status": "open", "status": "open",
"owners": ["PKG-ADS-02", "PKG-ADS-03", "PKG-ADS-05"], "owners": [
"evidence": ["refactor/baselines/ads-contract.md", "packages/artplayer-plugin-ads/src/index.js"], "PKG-ADS-02",
"PKG-ADS-03",
"PKG-ADS-05"
],
"evidence": [
"refactor/baselines/ads-contract.md",
"packages/artplayer-plugin-ads/src/index.js",
"test/ads.test.js",
"test/browser/ads.spec.js",
"refactor/ads-validation.md",
"refactor/baselines/ads-validation.json"
],
"compatibleResolution": "先复现重复 play 的计时链、skip 后计时、destroy 后恢复与复用工厂 options;以实例所有权和幂等清理修正,不更改广告时长/跳过规则。", "compatibleResolution": "先复现重复 play 的计时链、skip 后计时、destroy 后恢复与复用工厂 options;以实例所有权和幂等清理修正,不更改广告时长/跳过规则。",
"closureCriteria": "时钟/订阅/DOM 与主片恢复的成功、异常、重入、销毁、复用和多实例回归通过。" "closureCriteria": "时钟/订阅/DOM 与主片恢复的成功、异常、重入、销毁、复用和多实例回归通过。"
}, },
{ {
"id": "ADS-MEDIA-01", "id": "ADS-MEDIA-01",
"title": "Ads metadata 与 play 拒绝、隐藏页媒体状态和内容恢复仍缺真实验证", "title": "Ads metadata 与 play 拒绝、隐藏页媒体状态和内容恢复仍缺真实验证",
"confirmation": "source-observed", "confirmation": "reproduced",
"status": "open", "status": "open",
"owners": ["PKG-ADS-02", "PKG-ADS-03", "PKG-ADS-05"], "owners": [
"evidence": ["refactor/baselines/ads-contract.md", "packages/artplayer-plugin-ads/src/index.js", "docs/assets/example/ads.js"], "PKG-ADS-02",
"PKG-ADS-03",
"PKG-ADS-05"
],
"evidence": [
"refactor/baselines/ads-contract.md",
"packages/artplayer-plugin-ads/src/index.js",
"docs/assets/example/ads.js",
"test/ads.test.js",
"test/browser/ads.spec.js",
"refactor/ads-validation.md",
"refactor/baselines/ads-validation.json"
],
"compatibleResolution": "用本地媒体验证事件安装顺序、加载失败与拒绝、倒计时和广告播放的独立语义,恢复主片时不吞公共拒绝或在销毁后播放。", "compatibleResolution": "用本地媒体验证事件安装顺序、加载失败与拒绝、倒计时和广告播放的独立语义,恢复主片时不吞公共拒绝或在销毁后播放。",
"closureCriteria": "实际广告/主片的加载、暂停/恢复、失败/换源/销毁和隐藏状态有新旧核心组合证据。" "closureCriteria": "实际广告/主片的加载、暂停/恢复、失败/换源/销毁和隐藏状态有新旧核心组合证据。"
}, },
{ {
"id": "ADS-UI-01", "id": "ADS-UI-01",
"title": "Ads 零等待跳过与初始状态不符,全屏图标/外部状态需核验", "title": "Ads 零等待跳过与初始状态不符,全屏图标/外部状态需核验",
"confirmation": "source-observed", "confirmation": "reproduced",
"status": "open", "status": "open",
"owners": ["PKG-ADS-02", "PKG-ADS-03", "PKG-ADS-05"], "owners": [
"evidence": ["refactor/baselines/ads-contract.md", "packages/artplayer-plugin-ads/src/index.js", "docs/assets/example/ads.js"], "PKG-ADS-02",
"PKG-ADS-03",
"PKG-ADS-05"
],
"evidence": [
"refactor/baselines/ads-contract.md",
"packages/artplayer-plugin-ads/src/index.js",
"docs/assets/example/ads.js",
"test/ads.test.js",
"test/browser/ads.spec.js",
"refactor/ads-validation.md",
"refactor/baselines/ads-validation.json"
],
"compatibleResolution": "复现 playDuration<=0 文案可关闭但 isCanClose 尚为 false;按既有 demo 规则修复,验证全屏按钮和外部退出状态,保留 CSS/DOM 钩子。", "compatibleResolution": "复现 playDuration<=0 文案可关闭但 isCanClose 尚为 false;按既有 demo 规则修复,验证全屏按钮和外部退出状态,保留 CSS/DOM 钩子。",
"closureCriteria": "零/正/不可跳阈值、真实点击、倒计时/详情/静音/全屏与外部状态组合均有回归。" "closureCriteria": "零/正/不可跳阈值、真实点击、倒计时/详情/静音/全屏与外部状态组合均有回归。"
} }
+6 -2
View File
@@ -2545,11 +2545,15 @@
"ENG-03", "ENG-03",
"ENG-05" "ENG-05"
], ],
"status": "todo", "status": "done",
"risk": "H", "risk": "H",
"deliverable": "固定 npm 1.0.6 与工作区契约;HTML/视频/图片 HTML、跳过阈值、计时链、工厂复用、多实例、过早/重复方法、播放拒绝与销毁", "deliverable": "固定 npm 1.0.6 与工作区契约;HTML/视频/图片 HTML、跳过阈值、计时链、工厂复用、多实例、过早/重复方法、播放拒绝与销毁",
"acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言", "acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言",
"evidence": [] "evidence": [
"changes/2026-09-12-PKG-ADS-02-tests.md",
"ads-validation.md",
"baselines/ads-validation.json"
]
}, },
{ {
"id": "PKG-ADS-03", "id": "PKG-ADS-03",
+6
View File
@@ -2,6 +2,12 @@
Use the pinned Node/Yarn toolchain from `../refactor/toolchain-setup.md`. Use the pinned Node/Yarn toolchain from `../refactor/toolchain-setup.md`.
`yarn test:ads` runs source, verified npm 1.0.6 and the frozen unpublished 2.1.0
Ads bundle against controlled clock/host contracts. It is included in `test:unit`.
The helper uses the real option validator but does not simulate media decoding or layout.
Historical defect assertions apply only to frozen implementations. See
[Ads validation](../refactor/ads-validation.md) for artifact overrides and browser scope.
| Command | Scope | | Command | Scope |
| ------------------- | -------------------------------------------------------------------------------------------------------- | | ------------------- | -------------------------------------------------------------------------------------------------------- |
| `yarn test:unit` | Playback and DASH regressions, public contracts against released/current code, and JS/TS fixture loading | | `yarn test:unit` | Playback and DASH regressions, public contracts against released/current code, and JS/TS fixture loading |
+351
View File
@@ -0,0 +1,351 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Controlled clock/host contracts use Node's runner.
import test from 'node:test'
import { adsEnvironment, adsImplementations } from './helpers/ads.js'
const implementations = await adsImplementations()
const skips = host => host.calls.filter(call => call[0] === 'emit' && call[1] === 'artplayerPluginAds:skip')
const clicks = host => host.calls.filter(call => call[0] === 'emit' && call[1] === 'artplayerPluginAds:click')
const contentPlays = host => host.calls.filter(call => call[0] === 'content.play')
for (const { name, factory } of implementations) {
test(`${name}: lazy synchronous factory starts once after ready and either playback event`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const result = factory({ html: '<b>ad</b>' })(host.art)
assert.deepEqual(Object.keys(result), ['name', 'skip', 'pause', 'play'])
assert.equal(result.name, 'artplayerPluginAds')
assert.equal(host.node(), undefined)
assert.equal(env.timers.size, 0)
host.art.emit('play')
assert.equal(host.node(), undefined)
host.art.emit('ready').emit('video:playing')
const node = host.node()
assert(node)
assert.equal(host.art.template.$ads, node)
assert.equal(host.node('html').innerHTML, '<b>ad</b>')
assert.equal(host.art.playing, false)
host.art.emit('play').emit('video:playing')
assert.equal(host.node(), node)
assert.equal(env.timers.size, 1)
assert.equal(host.calls.filter(call => call[0] === 'content.pause').length, 1)
})
test(`${name}: actual validator keeps numeric durations and complete shallow i18n`, (t) => {
const env = adsEnvironment(t)
assert.throws(() => factory({ totalDuration: '10' })(env.host().art), /totalDuration/)
assert.throws(() => factory({ playDuration: '5' })(env.host().art), /playDuration/)
assert.throws(() => factory({ html: 1 })(env.host().art), /html/)
assert.throws(() => factory({ i18n: { close: 'close' } })(env.host().art), /i18n/)
const host = env.host()
factory()(host.art)
assert.equal(host.normalized.totalDuration, 10)
assert.equal(host.normalized.playDuration, 5)
assert.equal(host.normalized.muted, false)
assert.equal(host.normalized.html, '')
assert.equal(host.normalized.video, '')
})
test(`${name}: video wins over HTML and metadata starts media and countdown`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
factory({ html: '<b>fallback</b>', video: 'ad.mp4', muted: true })(host.art)
host.start()
assert.equal(host.node('html'), undefined)
assert.equal(host.node('video').tagName, 'VIDEO')
assert.equal(host.node('video').muted, true)
assert.equal(env.timers.size, 0)
host.fire('video', 'loadedmetadata')
assert.equal(env.timers.size, 1)
assert.equal(host.node('video').paused, false)
assert.equal(host.node('timer').style.display, 'flex')
assert.equal(host.node('control').style.display, 'flex')
assert.equal(host.node('loading').style.display, 'none')
assert.equal(host.calls.filter(call => call[0] === 'ad.play').length, 1)
})
test(`${name}: each active second counts down and total duration restores content`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
factory({ html: 'ad', playDuration: 2, totalDuration: 3 })(host.art)
host.start()
assert.equal(host.node('close').innerHTML, '2秒后可关闭广告')
assert.equal(host.node('countdown').innerHTML, '3秒')
env.tick(999)
assert.equal(host.node('countdown').innerHTML, '3秒')
env.tick(1)
assert.equal(host.node('close').innerHTML, '1秒后可关闭广告')
assert.equal(host.node('countdown').innerHTML, '2秒')
env.tick(1000)
assert.equal(host.node('close').innerHTML, '关闭广告')
assert.equal(contentPlays(host).length, 0)
env.tick(1000)
assert.equal(host.node('countdown').innerHTML, '0秒')
assert.equal(host.node().style.display, 'none')
assert.equal(skips(host).length, 1)
assert.equal(skips(host)[0][2], host.normalized)
assert.equal(contentPlays(host).length, 1)
assert.equal(env.timers.size, 0)
})
test(`${name}: pause/play control the countdown without changing ad video state`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const plugin = factory({ video: 'ad.mp4', totalDuration: 4 })(host.art)
host.start()
host.fire('video', 'loadedmetadata')
env.tick(1000)
assert.equal(plugin.pause(), undefined)
env.tick(5000)
assert.equal(host.node('countdown').innerHTML, '3秒')
assert.equal(host.node('video').paused, false)
assert.equal(plugin.play(), undefined)
env.tick(1000)
assert.equal(host.node('countdown').innerHTML, '2秒')
assert.equal(host.calls.filter(call => call[0] === 'ad.play').length, 1)
})
test(`${name}: button obeys positive threshold while public skip bypasses it`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const plugin = factory({ html: 'ad', playDuration: 2, totalDuration: 5 })(host.art)
host.start()
host.fire('close', 'click')
env.tick(1000)
host.fire('close', 'click')
assert.equal(skips(host).length, 0)
assert.equal(plugin.skip(), undefined)
assert.equal(skips(host).length, 1)
const second = env.host()
factory({ html: 'second', playDuration: 2, totalDuration: 5 })(second.art)
second.start()
env.tick(2000)
second.fire('close', 'click')
assert.equal(skips(second).length, 1)
})
test(`${name}: non-skippable button stays hidden and automatic completion still occurs`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
factory({ html: 'ad', playDuration: 2, totalDuration: 2 })(host.art)
host.start()
assert.equal(host.node('close').style.display, 'none')
env.tick(2000)
assert.equal(skips(host).length, 1)
})
test(`${name}: translations preserve first-placeholder substitution and normalized payload`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const i18n = { close: 'skip', countdown: '%s seconds %s', detail: 'visit', canBeClosed: 'wait %s' }
factory({ html: 'ad', playDuration: 1, totalDuration: 3, i18n })(host.art)
host.start()
assert.equal(host.node('countdown').innerHTML, '3 seconds %s')
assert.equal(host.node('close').innerHTML, 'wait 1')
env.tick(1000)
assert.equal(host.node('close').innerHTML, 'skip')
host.fire('close', 'click')
assert.equal(skips(host)[0][2].i18n, i18n)
})
test(`${name}: ad/detail clicks preserve URL and option payload; unlinked ad still emits`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
factory({ html: '<img src="poster.png">', url: 'https://example.invalid/ad' })(host.art)
host.start()
host.fire('html', 'click')
host.fire('detail', 'click')
assert.deepEqual(env.opened, ['https://example.invalid/ad', 'https://example.invalid/ad'])
assert.equal(clicks(host).length, 2)
assert(clicks(host).every(call => call[2] === host.normalized))
const second = env.host()
factory({ html: 'unlinked' })(second.art)
second.start()
assert.equal(second.node('detail').style.display, 'none')
second.fire('html', 'click')
assert.equal(clicks(second).length, 1)
assert.equal(env.opened.length, 2)
})
test(`${name}: mute applies only to the ad and toggles existing icons`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
factory({ video: 'ad.mp4', muted: true })(host.art)
host.start()
assert.equal(host.nodes.get('volume').style.display, 'none')
assert.equal(host.nodes.get('volumeClose').style.display, 'inline-flex')
host.fire('muted', 'click')
assert.equal(host.node('video').muted, false)
assert.equal(host.nodes.get('volume').style.display, 'inline-flex')
assert.equal(host.nodes.get('volumeClose').style.display, 'none')
host.fire('muted', 'click')
assert.equal(host.node('video').muted, true)
})
test(`${name}: document visibility pauses and resumes only the countdown`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
factory({ html: 'ad', totalDuration: 4 })(host.art)
host.start()
env.tick(1000)
host.visibility(true)
env.tick(5000)
assert.equal(host.node('countdown').innerHTML, '3秒')
host.visibility(false)
env.tick(1000)
assert.equal(host.node('countdown').innerHTML, '2秒')
})
test(`${name}: media error before metadata restores content and retains skip order`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
factory({ video: 'missing.mp4' })(host.art)
host.start()
host.calls.length = 0
host.fire('video', 'error')
assert.deepEqual(host.calls.map(call => call.slice(0, 2)), [
['content.play', false],
['emit', 'play'],
['ad.pause'],
['style', 'artplayer-plugin-ads'],
['emit', 'artplayerPluginAds:skip'],
])
assert.equal(host.node().style.display, 'none')
assert.equal(env.timers.size, 0)
})
test(`${name}: separate factories keep instance timers and event options independent`, (t) => {
const env = adsEnvironment(t)
const first = env.host()
const second = env.host()
const firstPlugin = factory({ html: 'first', totalDuration: 2 })(first.art)
factory({ html: 'second', totalDuration: 3 })(second.art)
first.start()
second.start()
firstPlugin.pause()
env.tick(3000)
assert.equal(skips(first).length, 0)
assert.equal(first.node('countdown').innerHTML, '2秒')
assert.equal(skips(second).length, 1)
assert.equal(skips(second)[0][2], second.normalized)
firstPlugin.play()
env.tick(2000)
assert.equal(skips(first).length, 1)
assert.equal(skips(first)[0][2], first.normalized)
})
test(`${name}: initial input is read at attach and completed preroll stays hidden after restart`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const option = { html: 'before', totalDuration: 1 }
const attach = factory(option)
option.html = 'at attach'
attach(host.art)
option.html = 'after attach'
host.start()
assert.equal(host.node('html').innerHTML, 'at attach')
env.tick(1000)
const root = host.node()
host.art.emit('restart', 'second.mp4').emit('ready').emit('play').emit('video:playing')
assert.equal(host.node(), root)
assert.equal(root.style.display, 'none')
assert.equal(skips(host).length, 1)
assert.equal(env.timers.size, 0)
})
}
// Freeze legacy defects separately; migrated candidates must not inherit these assertions.
for (const { name, factory } of implementations.filter(item => item.historical)) {
test(`${name} historical: duplicate play starts parallel countdown chains`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const plugin = factory({ html: 'ad', totalDuration: 10 })(host.art)
host.start()
plugin.play()
assert.equal(env.timers.size, 2)
env.tick(1000)
assert.equal(host.node('countdown').innerHTML, '8秒')
plugin.pause()
assert.equal(env.timers.size, 1)
})
test(`${name} historical: skip leaves a queued callback and emits completion again`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const plugin = factory({ html: 'ad', totalDuration: 1 })(host.art)
host.start()
plugin.skip()
assert.equal(env.timers.size, 1)
env.tick(1000)
assert.equal(skips(host).length, 2)
plugin.skip()
assert.equal(skips(host).length, 3)
})
test(`${name} historical: destroy leaves countdown intent that requests content play`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
factory({ html: 'ad', totalDuration: 1 })(host.art)
host.start()
host.art.destroy()
assert.equal(env.timers.size, 1)
env.tick(1000)
assert.deepEqual(contentPlays(host), [['content.play', true]])
})
test(`${name} historical: zero wait displays close immediately but rejects the first click`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
factory({ html: 'ad', playDuration: 0, totalDuration: 3 })(host.art)
host.start()
assert.equal(host.node('close').innerHTML, '关闭广告')
host.fire('close', 'click')
assert.equal(skips(host).length, 0)
env.tick(1000)
host.fire('close', 'click')
assert.equal(skips(host).length, 1)
})
test(`${name} historical: skip before initialization plays content then throws`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const plugin = factory({ html: 'ad' })(host.art)
assert.throws(() => plugin.skip(), /undefined/)
assert.equal(contentPlays(host).length, 1)
})
test(`${name} historical: reusing one factory changes the first instance event payload identity`, (t) => {
const env = adsEnvironment(t)
const attach = factory({ html: 'shared' })
const first = env.host()
const firstPlugin = attach(first.art)
first.start()
const firstOption = first.normalized
const second = env.host()
attach(second.art)
second.start()
firstPlugin.skip()
assert.notEqual(firstOption, second.normalized)
assert.equal(skips(first)[0][2], second.normalized)
})
test(`${name} historical: repeated metadata schedules and starts the ad twice`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
factory({ video: 'ad.mp4' })(host.art)
host.start()
host.fire('video', 'loadedmetadata')
host.fire('video', 'loadedmetadata')
assert.equal(env.timers.size, 2)
assert.equal(host.calls.filter(call => call[0] === 'ad.play').length, 2)
})
test(`${name} historical: play before initialization schedules a callback against missing DOM`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const plugin = factory({ html: 'ad' })(host.art)
plugin.play()
assert.equal(env.timers.size, 1)
assert.throws(() => env.tick(1000), /null|undefined/)
})
}
+6
View File
@@ -1,5 +1,11 @@
# Browser regression entry # Browser regression entry
`yarn test:browser test/browser/ads.spec.js` tests actual Ads 1.0.6 and candidate code
with published 5.4.1 and candidate cores using local image/video media. It covers actual
decoded pixels, countdown, skipping, content restoration, 404 and source replacement.
Published-only rejection cases inject an exact play failure; they do not certify device
autoplay policies. See [Ads validation](../../refactor/ads-validation.md).
Audio Track tests use the source and frozen 1.1.0 plugin on both cores. The local AAC fixture Audio Track tests use the source and frozen 1.1.0 plugin on both cores. The local AAC fixture
has a pinned hash and generation command in media/audio-tone.json and uses the existing Range has a pinned hash and generation command in media/audio-tone.json and uses the existing Range
server. The suite retains published cleanup/native-pause defects as explicit observations. server. The suite retains published cleanup/native-pause defects as explicit observations.
+214
View File
@@ -0,0 +1,214 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
let published
let candidate
let release
test.beforeAll(async () => {
release = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/ads-release.json', import.meta.url))).release
const member = `package/${release.manifest.main}`
const bytes = readMember(await ensureArchive(release), member)
assert.equal(hash(bytes), release.files[member])
published = bytes.toString()
candidate = process.env.ARTPLAYER_ADS_ARTIFACT
? fs.readFileSync(process.env.ARTPLAYER_ADS_ARTIFACT, 'utf8')
: await compilePackage('artplayer-plugin-ads', 'umd')
})
test.afterEach(async ({ page }, testInfo) => {
const state = await page.evaluate(() => {
const ad = document.querySelector('.artplayer-plugin-ads-video')
return {
events: window.adsEvents,
rejected: window.adsRejected,
ad: ad && { time: ad.currentTime, paused: ad.paused, width: ad.videoWidth, height: ad.videoHeight, error: ad.error?.code },
content: window.art && { time: window.art.currentTime, paused: window.art.video.paused, destroyed: window.art.isDestroy },
}
})
await testInfo.attach('ads-state', { contentType: 'application/json', body: JSON.stringify(state) })
await page.evaluate(() => {
// Historical skip/destroy do not cancel their countdown. Explicit pause prevents
// fixture teardown from masking the behavior asserted by a different test.
window.art?.plugins.artplayerPluginAds?.pause()
if (window.art && !window.art.isDestroy)
window.art.destroy()
})
})
async function openAds(page, core, plugin, option, testInfo) {
await page.goto(`/test/player.html?core=${core}`)
const code = plugin === 'published' ? published : candidate
await page.addScriptTag({ content: code })
await testInfo.attach('ads-inputs', { contentType: 'application/json', body: JSON.stringify({ core, plugin, release: plugin === 'published' ? release : undefined, sha256: hash(code), artifact: plugin === 'candidate' ? process.env.ARTPLAYER_ADS_ARTIFACT || 'workspace source build' : undefined }) })
await page.evaluate((option) => {
window.adsEvents = []
window.art = new window.Artplayer({
container: '.player',
url: '/test/pattern.mp4',
muted: true,
plugins: [window.artplayerPluginAds(option)],
})
window.art.on('artplayerPluginAds:skip', value => window.adsEvents.push({ name: 'skip', option: value }))
window.art.on('artplayerPluginAds:click', value => window.adsEvents.push({ name: 'click', option: value }))
document.querySelector('#play').onclick = () => window.art.play()
}, option)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await expect(page.locator('.artplayer-plugin-ads')).toHaveCount(0)
await page.locator('#play').click()
}
for (const core of ['published', 'candidate']) {
for (const plugin of ['published', 'candidate']) {
const label = `${core} core / ${plugin} Ads`
test(`${label}: real image HTML, skip threshold and resumed content`, async ({ page }, testInfo) => {
await openAds(page, core, plugin, { html: '<img alt="Ad artwork" src="/test/thumbnail-grid.svg">', playDuration: 2, totalDuration: 8 }, testInfo)
const root = page.locator('.artplayer-plugin-ads')
await expect(root).toBeVisible()
await expect.poll(() => page.locator('img[alt="Ad artwork"]').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true)
expect(await page.evaluate(() => window.art.video.paused)).toBe(true)
await page.locator('.artplayer-plugin-ads-close').click()
expect(await page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(0)
await expect(page.locator('.artplayer-plugin-ads-close')).toHaveText('关闭广告')
await page.locator('.artplayer-plugin-ads-close').click()
await expect(root).toBeHidden()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
expect(await page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(1)
await testInfo.attach('ads-image-completed', { contentType: 'image/png', body: await page.screenshot() })
})
test(`${label}: real video decodes, mute toggles and countdown completion restores content`, async ({ page }, testInfo) => {
await openAds(page, core, plugin, { video: '/test/pattern.mp4', html: 'unused', muted: true, playDuration: 5, totalDuration: 3 }, testInfo)
const ad = page.locator('.artplayer-plugin-ads-video')
await expect.poll(() => ad.evaluate(video => video.currentTime)).toBeGreaterThan(0.2)
expect(await ad.evaluate(video => ({ paused: video.paused, muted: video.muted }))).toEqual({ paused: false, muted: true })
// Windows WebKit reports layout-sized videoWidth; sample actual decoded colors.
const pixels = await ad.evaluate((video) => {
const canvas = document.createElement('canvas')
canvas.width = 80
canvas.height = 45
const context = canvas.getContext('2d')
context.drawImage(video, 0, 0, 80, 45)
return [30, 45, 75].map(x => [...context.getImageData(x, 2, 1, 1).data])
})
expect(pixels[0][0]).toBeGreaterThan(180)
expect(pixels[0][1]).toBeGreaterThan(180)
expect(pixels[0][2]).toBeLessThan(80)
expect(pixels[1][0]).toBeLessThan(80)
expect(pixels[1][2]).toBeGreaterThan(180)
expect(pixels[2][1]).toBeGreaterThan(180)
expect(pixels[2][2]).toBeGreaterThan(180)
await testInfo.attach('ads-decoded-pixels', { contentType: 'application/json', body: JSON.stringify(pixels) })
await expect(page.locator('.artplayer-plugin-ads-html')).toHaveCount(0)
await expect(page.locator('.artplayer-plugin-ads-close')).toBeHidden()
await page.locator('.artplayer-plugin-ads-muted').click()
expect(await ad.evaluate(video => video.muted)).toBe(false)
await page.locator('.artplayer-plugin-ads-muted').click()
await testInfo.attach('ads-video-playing', { contentType: 'image/png', body: await page.screenshot() })
await expect(page.locator('.artplayer-plugin-ads')).toBeHidden()
expect(await ad.evaluate(video => video.paused)).toBe(true)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
expect(await page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(1)
})
test(`${label}: actual missing ad media fails open to playable content`, async ({ page }, testInfo) => {
await page.route('**/missing-ad.mp4', route => route.fulfill({ status: 404, body: '' }))
await openAds(page, core, plugin, { video: '/missing-ad.mp4', muted: true }, testInfo)
await expect.poll(() => page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(1)
expect(await page.locator('.artplayer-plugin-ads-video').evaluate(video => video.error?.code)).toBe(4)
await expect(page.locator('.artplayer-plugin-ads')).toBeHidden()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
})
test(`${label}: countdown pause resumes once and source replacement does not replay ads`, async ({ page }, testInfo) => {
await openAds(page, core, plugin, { html: 'one preroll', playDuration: 1, totalDuration: 8 }, testInfo)
await expect(page.locator('.artplayer-plugin-ads')).toBeVisible()
await page.evaluate(() => window.art.plugins.artplayerPluginAds.pause())
const count = await page.locator('.artplayer-plugin-ads-countdown').textContent()
// A real clock observation specifically checks that pause stops countdown.
await page.waitForTimeout(1200)
await expect(page.locator('.artplayer-plugin-ads-countdown')).toHaveText(count)
await page.evaluate(() => window.art.plugins.artplayerPluginAds.play())
await expect(page.locator('.artplayer-plugin-ads-countdown')).not.toHaveText(count)
await page.evaluate(() => {
window.art.plugins.artplayerPluginAds.pause()
window.art.plugins.artplayerPluginAds.skip()
})
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
await page.evaluate(() => window.art.switchUrl('/test/pattern.mp4?replacement=1'))
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
await expect(page.locator('.artplayer-plugin-ads')).toHaveCount(1)
await expect(page.locator('.artplayer-plugin-ads')).toBeHidden()
expect(await page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(1)
})
}
test(`${core} core / published Ads historical: rejected ad play leaks a rejection while timer continues`, async ({ page }, testInfo) => {
await page.addInitScript(() => {
window.adsRejected = []
window.addEventListener('unhandledrejection', (event) => {
if (event.reason?.message === 'Ads test controlled NotAllowedError') {
window.adsRejected.push({ name: event.reason.name, message: event.reason.message })
event.preventDefault()
}
})
const play = HTMLMediaElement.prototype.play
HTMLMediaElement.prototype.play = function () {
if (this.classList.contains('artplayer-plugin-ads-video'))
return Promise.reject(new DOMException('Ads test controlled NotAllowedError', 'NotAllowedError'))
return play.call(this)
}
})
await openAds(page, core, 'published', { video: '/test/pattern.mp4', muted: true, totalDuration: 2 }, testInfo)
await expect.poll(() => page.evaluate(() => window.adsRejected)).toEqual([{ name: 'NotAllowedError', message: 'Ads test controlled NotAllowedError' }])
expect(await page.locator('.artplayer-plugin-ads-video').evaluate(video => video.paused)).toBe(true)
await expect(page.locator('.artplayer-plugin-ads')).toBeHidden()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
})
test(`${core} core / published Ads historical: rejected content restoration hides ad and leaks rejection`, async ({ page }, testInfo) => {
await page.addInitScript(() => {
window.adsRejected = []
window.addEventListener('unhandledrejection', (event) => {
if (event.reason?.message === 'Ads test controlled content rejection') {
window.adsRejected.push({ name: event.reason.name, message: event.reason.message })
event.preventDefault()
}
})
const play = HTMLMediaElement.prototype.play
HTMLMediaElement.prototype.play = function () {
if (this === window.art?.video && document.querySelector('.artplayer-plugin-ads'))
return Promise.reject(new DOMException('Ads test controlled content rejection', 'NotAllowedError'))
return play.call(this)
}
})
await openAds(page, core, 'published', { html: 'ad', totalDuration: 1 }, testInfo)
await expect.poll(() => page.evaluate(() => window.adsRejected)).toEqual([{ name: 'NotAllowedError', message: 'Ads test controlled content rejection' }])
await expect(page.locator('.artplayer-plugin-ads')).toBeHidden()
expect(await page.evaluate(() => window.art.video.paused)).toBe(true)
expect(await page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(1)
})
test(`${core} core / published Ads historical: zero threshold advertises skip before accepting click`, async ({ page }, testInfo) => {
await openAds(page, core, 'published', { html: 'ad', playDuration: 0, totalDuration: 5 }, testInfo)
const close = page.locator('.artplayer-plugin-ads-close')
await expect(close).toBeVisible()
// Freeze only the countdown after real initialization; this is a button contract.
await page.evaluate(() => window.art.plugins.artplayerPluginAds.pause())
await expect(close).toHaveText('关闭广告')
await close.click()
await expect(page.locator('.artplayer-plugin-ads')).toBeVisible()
expect(await page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(0)
await page.evaluate(() => window.art.plugins.artplayerPluginAds.play())
await expect(page.locator('.artplayer-plugin-ads-countdown')).not.toHaveText('5秒')
await close.click()
await expect(page.locator('.artplayer-plugin-ads')).toBeHidden()
expect(await page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(1)
})
}
+216
View File
@@ -0,0 +1,216 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import vm from 'node:vm'
import validator from 'option-validator'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { loadPackage } from './load.js'
function commonJS(bytes) {
const module = { exports: {} }
vm.runInNewContext(bytes.toString(), {
module,
exports: module.exports,
get document() { return globalThis.document },
get window() { return globalThis.window },
setTimeout: (...args) => globalThis.setTimeout(...args),
clearTimeout: id => globalThis.clearTimeout(id),
console,
}, { timeout: 5000 })
return module.exports.default || module.exports
}
export async function adsImplementations() {
const baseline = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/ads-release.json', import.meta.url)))
const archive = await ensureArchive(baseline.release)
const member = `package/${baseline.release.manifest.main}`
const bytes = readMember(archive, member)
assert.equal(hash(bytes), baseline.release.files[member])
const file = 'packages/artplayer-plugin-ads/dist/artplayer-plugin-ads.js'
const historical = execFileSync('git', ['show', `${baseline.sourceCommit}:${file}`])
assert.equal(hash(historical.toString().replaceAll('\r\n', '\n')), baseline.source[file])
const implementations = [
{ name: 'source', factory: (await loadPackage('artplayer-plugin-ads')).default },
{ name: 'published-1.0.6', historical: true, factory: commonJS(bytes) },
{ name: 'frozen-workspace-2.1.0', historical: true, factory: commonJS(historical) },
]
for (const file of (process.env.ARTPLAYER_TEST_ADS || '').split(path.delimiter).filter(Boolean)) {
const bytes = fs.readFileSync(file)
const factory = file.endsWith('.mjs') ? (await import(`data:text/javascript;base64,${bytes.toString('base64')}`)).default : commonJS(bytes)
implementations.push({ name: `artifact-${path.basename(file)}`, factory })
}
return implementations
}
// Intent fixture only: these nodes do not model layout, HTML parsing or media decoding.
export function adsEnvironment(t) {
const document = Object.assign(new EventTarget(), { hidden: false })
const opened = []
for (const [name, value] of Object.entries({ document, window: { open: url => opened.push(url) } })) {
const previous = Object.getOwnPropertyDescriptor(globalThis, name)
Object.defineProperty(globalThis, name, { configurable: true, value })
t.after(() => previous ? Object.defineProperty(globalThis, name, previous) : Reflect.deleteProperty(globalThis, name))
}
const timers = new Map()
let nextId = 0
let now = 0
t.mock.method(globalThis, 'setTimeout', (callback, delay) => {
const id = ++nextId
timers.set(id, { callback, at: now + Number(delay) })
return id
})
t.mock.method(globalThis, 'clearTimeout', id => timers.delete(id))
t.mock.method(Date, 'now', () => now)
function tick(milliseconds) {
const end = now + milliseconds
let steps = 0
while (true) {
const entry = [...timers].sort((a, b) => a[1].at - b[1].at)[0]
if (!entry || entry[1].at > end)
break
assert(++steps < 10000, 'Unexpected endless timer chain')
const [id, timer] = entry
timers.delete(id)
now = timer.at
timer.callback()
}
now = end
}
function host() {
const calls = []
const listeners = new Map()
const proxies = []
const nodes = new Map()
let normalized
class Node extends EventTarget {
constructor(tag = 'div', className = '', html = '') {
super()
this.tagName = tag.toUpperCase()
this.className = className
this.innerHTML = html
this.style = {}
this.children = []
this.muted = false
this.paused = true
this.ownerDocument = document
if (className)
nodes.set(className, this)
}
play() {
calls.push(['ad.play'])
this.paused = false
return Promise.resolve()
}
pause() {
calls.push(['ad.pause'])
this.paused = true
}
}
const prefix = 'artplayer-plugin-ads'
function append(parent, value) {
if (typeof value !== 'string') {
parent.children.push(value)
return value
}
const match = value.match(/^<(\w+)\s[^>]*class="([^"]*)"[^>]*>([\s\S]*)<\/\w+>$/)
assert(match, 'Extend the fixture for a new wrapper; do not claim a real HTML parser')
const [, tag, className, inner] = match
const node = new Node(tag, className, inner)
parent.children.push(node)
for (const child of inner.matchAll(/<div class="([^"]+)"[^>]*>([\s\S]*?)<\/div>/g))
node.children.push(new Node('div', child[1], child[2]))
return node
}
const art = {
template: { $player: new Node() },
icons: Object.fromEntries(['volume', 'volumeClose', 'fullscreenOn', 'fullscreenOff', 'loading'].map(name => [name, new Node('span', name)])),
constructor: {
version: '5.4.1',
validator(value, schema) {
normalized = validator(value, schema)
return normalized
},
utils: {
append,
query: (selector, parent) => parent.children.find(child => child.className === selector.slice(1)),
setStyle(node, name, value) {
calls.push(['style', node?.className, name, value])
node.style[name] = value
},
errorHandle(condition, message) {
if (!condition)
throw new Error(message)
},
},
},
playing: false,
fullscreen: false,
isDestroy: false,
on(name, callback) {
if (!listeners.has(name))
listeners.set(name, new Set())
listeners.get(name).add(callback)
return art
},
off(name, callback) {
listeners.get(name)?.delete(callback)
return art
},
once(name, callback) {
const once = (...args) => {
art.off(name, once)
callback(...args)
}
return art.on(name, once)
},
emit(name, ...args) {
calls.push(['emit', name, ...args])
for (const callback of [...(listeners.get(name) || [])]) callback(...args)
return art
},
proxy(target, name, callback) {
target.addEventListener(name, callback)
const off = () => target.removeEventListener(name, callback)
proxies.push(off)
return off
},
play() {
calls.push(['content.play', art.isDestroy])
art.playing = true
art.emit('play')
return Promise.resolve()
},
pause() {
calls.push(['content.pause'])
art.playing = false
art.emit('pause')
},
destroy() {
art.isDestroy = true
art.emit('destroy')
proxies.splice(0).forEach(off => off())
listeners.clear()
},
}
return {
art,
nodes,
calls,
listeners,
get normalized() { return normalized },
node: suffix => nodes.get(suffix ? `${prefix}-${suffix}` : prefix),
start() { art.emit('ready').emit('play') },
fire(suffix, name) { nodes.get(`${prefix}-${suffix}`).dispatchEvent(new Event(name)) },
visibility(hidden) {
document.hidden = hidden
document.dispatchEvent(new Event('visibilitychange'))
art.emit('document:visibilitychange')
},
}
}
return { host, tick, timers, document, opened }
}