mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-16 17:46:17 -08:00
test(hls): [PKG-HLS-02] cover released contracts and real SDK playback
This commit is contained in:
1 parent
cbbdc03a7c
commit
33f6d19b77
55 files changed
+8678
-14
No files matched your search
@@ -1,3 +1,5 @@
|
||||
* text=auto
|
||||
*.js text eol=lf
|
||||
test/browser/media/hls/*.m3u8 text eol=lf
|
||||
test/browser/media/hls/*.mpegts binary
|
||||
refactor/baselines/core-vendor/*.txt text eol=lf
|
||||
+1
-1
@@ -50,7 +50,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/playback.test.js test/dash-control.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/playback.test.js test/dash-control.test.js test/hls-control.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",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"task": "PKG-HLS-02",
|
||||
"capturedAt": "2026-09-11T15:36:11.802Z",
|
||||
"metadataUrl": "https://registry.npmjs.org/hls.js/1.5.17",
|
||||
"release": {
|
||||
"name": "hls.js",
|
||||
"version": "1.5.17",
|
||||
"tarball": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.17.tgz",
|
||||
"integrity": "sha512-wA66nnYFvQa1o4DO/BFgLNRKnBTVXpNeldGRBJ2Y0SvFtdwvFKCbqa9zhHoZLoxHhZ+jYsj3aIBkWQQCPNOhMw==",
|
||||
"sha256": "8f9e60001992f59dbfb1dbc397e145743c41bb6dde233ec8a7974013c8a031fe",
|
||||
"files": {
|
||||
"package/package.json": "f2172b9942a23ffb46edd4af815cf1ea161db088e7f63dc518d30e55c95d6ae9",
|
||||
"package/LICENSE": "ca8773cf798c7ed997d4dd7c8e23c348699f8d5b7462636694cc14de6cda12db",
|
||||
"package/dist/hls.min.js": "484054e8cd03d3f6d1781fb7f402bdc318d8a4c527f933a95c624e27cc9a9470",
|
||||
"package/src/hls.ts": "b3d3f797669e89bc8d05d2de2aae243ae110811d6738948f173d789cc1ac1f2d",
|
||||
"package/src/controller/audio-track-controller.ts": "ff34f3cb29b66aa5fcba87758bb20d129b052a053808c150ce779e0ceb1e4626"
|
||||
}
|
||||
},
|
||||
"note": "Test-only pinned integration baseline from the existing example; selected member hashes plus whole archive integrity. No runtime dependency or complete supported version claim."
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
||||
# PKG-HLS-02:建立 HLS 控制与真实 SDK 基线
|
||||
|
||||
新增发布三格式/源码共用契约、发布特有缺陷观察和四组合真实 Hls.js 测试。
|
||||
测试主机、包读取、浏览器场景和媒体生成分别归属 test/helpers/hls-control.js、既有 load/release
|
||||
工具、test/browser/hls-control.spec.js 与 scripts/generate-hls-fixture.mjs。
|
||||
本任务不改生产源码、公开声明和 dist;模块拆分及类型修正由 HLS-03/04 执行。
|
||||
|
||||
45 项 Node 通过;浏览器 31 项通过、14 项明确跳过。最初 Windows WebKit 14 个失败经诊断
|
||||
确认 MSE 缺失,新增能力/失败清理断言,保留 Safari/MSE 未验收风险。不能用最终退出码 0
|
||||
作为全浏览器或发布放行。见 [测试说明](../hls-validation.md) 与 [冻结证据](../baselines/hls-validation.json)。
|
||||
|
||||
真实 Hls.js 1.5.17、双档位/双音轨和无音轨流覆盖播放、画面、手动/Auto、音轨、pause/seek、
|
||||
失败恢复、切源和销毁;该样本没有复现 id/index 错位。旧版 Auto 标签与空轨道菜单残留有
|
||||
浏览器证据,过期回调有受控证据。类型/异步/公开行为不以模拟对象替代真实播放。
|
||||
|
||||
工具变更:Node 单测加入 Yarn 正式链;SDK 固定归档只供测试,不新增运行时依赖;合法点分
|
||||
包名支持及非法路径拒绝测试;媒体生成器拒绝覆盖,生成文件有版本/命令/hash。初次 .ts
|
||||
媒体后缀被 ESLint 当作 TypeScript,已由生成器改成 .mpegts 并重新生成、重新跑浏览器。
|
||||
|
||||
验证:专项 lint、45 项 Node、31 通过/14 明确跳过的浏览器,以及完整 ci:check(485 单元、
|
||||
11 工程、26 基线,共 522 项;249 个生产 TS 文件严格检查)、计划/风险
|
||||
和 diff 检查。未重复核心全量安装包矩阵。本任务测试源码构建,HLS 安装包验收仍属 06。
|
||||
回退本提交撤回测试/样本/来源与风险记录;生产播放器不变。下步 HLS-03,独立提交。
|
||||
|
||||
CI 仍有既有生成文件 docs/assets/ts/artplayer.d.ts 的 unused eslint-disable warning;没有
|
||||
手改生成声明来消除它。媒体 .m3u8 固定 LF、.mpegts 标记二进制,避免跨平台 checkout 改字节。
|
||||
@@ -44,7 +44,7 @@ Node10 是 TS 模块解析模式名,不代表支持 Node.js 10。现代 es2020
|
||||
| artplayer-plugin-danmuku-mask / 1.1.0 | MediaPipe/TensorFlow 模型、GPU/CPU/WASM、停止/销毁;人物短片 | Yarn 与 model URL 分开;默认 solutionPath 无固定版本,下载/推理/失败待 | PKG-MASK-01/02/05/06 |
|
||||
| artplayer-plugin-dash-control / 1.1.0 | dash.js 实际表示/音轨、自动/手动高亮、切换拓扑;固定 MPD/segments | 本地选择逻辑 Node 测试已有;真实 dash.js/浏览器流待,示例版本见 SDK-02 | PKG-DASH-01/02/05/06、EX-03 |
|
||||
| artplayer-plugin-document-pip / 1.1.0 | Document PiP、节点/样式迁移与退出恢复 | 支持浏览器/版本待确认,真实用户手势与 PiP 窗口待 | PKG-DPIP-01/02/05/06 |
|
||||
| artplayer-plugin-hls-control / 1.1.0 | hls.js 清晰度/音轨自动手动切换;主/子列表与拓扑变化 | 实际 SDK/流未运行,示例版本见 SDK-01;Safari 原生 HLS 与 hls.js 路径分开 | PKG-HLS-01/02/05/06、EX-03 |
|
||||
| artplayer-plugin-hls-control / 1.1.0 | hls.js 清晰度/音轨自动手动切换;主/子列表与拓扑变化 | 1.5.17 在 Chromium/Firefox 本地双档位/音轨基线通过;Windows WebKit 无 MSE,14 播放用例 skipped;Safari/native/worker 仍待验,见 hls-validation.md | PKG-HLS-01/02/05/06、EX-03 |
|
||||
| artplayer-plugin-jassub / 1.1.0 | ASS、worker/WASM/font 加载、切源/销毁;短 ASS+明确来源字体 | 内嵌资源哈希已有;版本对应、跨源/worker 限制、实际渲染待 | PKG-JASSUB-01/02/05/06 |
|
||||
| artplayer-plugin-multiple-subtitles / 1.2.0 | VTT 合并/排序/解析错误、URL 回收;双语言/空/坏 VTT | parser 来源头已观察;特有行为和浏览器字幕待 | PKG-MULTI-SUB-01/02/05/06 |
|
||||
| artplayer-plugin-vast / 1.2.0 | Glomex/Google IMA、广告错误/恢复、重复初始化;供应商测试广告标签 | 依赖版本已记录;实际 IMA 网络/广告流程待,PR 用受控 adapter | PKG-VAST-01/02/05/06 |
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# HLS control 测试与环境边界
|
||||
|
||||
PKG-HLS-02 建立迁移前基线。源码目前仍为 src/index.js,不能把测试任务当作 TS 迁移已完成。
|
||||
来源见 [发布契约](baselines/hls-control-contract.md)、[SDK 固定记录](baselines/hls-sdk.json)、
|
||||
[本轮证据](baselines/hls-validation.json)。
|
||||
|
||||
## 可重跑入口
|
||||
|
||||
- `node --test test/hls-control.test.js`:45 项,源码与发布 main/legacy/ESM 共用正常契约,
|
||||
发布包单独复现旧缺陷;已接入 `yarn test:unit`。
|
||||
- `yarn test:browser hls-control.spec.js`:当前源码与发布插件在旧核心 5.4.0/当前核心上的四组合,
|
||||
加上 SDK 能力检查。通过现有 Playwright 工程入口执行;源码候选构建不写 dist。
|
||||
- `node refactor/scripts/hls-contract.mjs`:HLS 插件发布来源核验。
|
||||
- `node scripts/generate-hls-fixture.mjs <新的输出目录>`:只在需要改变样本时执行;
|
||||
测试使用已提交媒体和 manifest,不要求安装 FFmpeg。详见 test/browser/media/hls/README.md。
|
||||
|
||||
SDK 使用示例原有的 Hls.js 1.5.17,首次从固定 npm URL 下载并验证 SHA-512/SHA-256,
|
||||
代码取归档中的 dist/hls.min.js。SDK 仅在测试浏览器执行,不加生产依赖,不改 Yarn 锁文件。
|
||||
归档读取工具允许合法的点分包名 hls.js,同时测试拒绝路径穿越等非法标识。
|
||||
生成器记录 FFmpeg 版本和参数;媒体源为 testsrc2/sine,不依赖远程影片版权或服务可用性。
|
||||
|
||||
## 已验证的内容
|
||||
|
||||
Node 契约覆盖同步注册/update、媒体身份校验、quality/audio 显示开关、名称/顺序/去重、
|
||||
getName 输入引用与一参/两参调用、SDK 写入→notice→check 顺序、回调/SDK 异常传播、替换实例。
|
||||
这些受控测试不提供 DOM、实际 ABR 或媒体解码证明。
|
||||
|
||||
Chromium 与 Firefox 各 14 项播放/失败组合通过,另各 1 项 SDK 能力检查通过:
|
||||
本地两档位和双音轨、可见画面像素与解码帧、实际播放推进、90P→180P、返回 Auto、French
|
||||
音轨切换完成事件、暂停/seek/继续、503 manifest 失败后恢复、切到无音轨 master、SDK 与核心销毁。
|
||||
选择通过真实鼠标操作控件;属性、画面和 SDK 事件联合断言,不只验证菜单文字。
|
||||
|
||||
旧版真实 Auto 状态下 update 将标签改成当前播放档位;旧版切到无音轨 master 后仍保留音轨控件。
|
||||
旧菜单回调写旧实例、destroy 后公开 update 继续工作在受控测试复现;后者不是浏览器泄漏定量证明。
|
||||
SDK 在当前双音轨样本中将 id 与选择索引对齐,没有把静态 id/index 差异误报为已复现错误。
|
||||
|
||||
## Windows WebKit 缺口必须保留
|
||||
|
||||
首轮完整三引擎 28 通过、14 失败。定向诊断确认 Windows Playwright WebKit 26.6 的
|
||||
MediaSource 和 ManagedMediaSource 均为 undefined,Hls.isSupported() 为 false,实际 SDK
|
||||
报告 manifestIncompatibleCodecsError。新旧核心与插件共同受影响,不是 TS 候选特有回归。
|
||||
|
||||
最终报告为 **31 passed、14 skipped、0 failed**:WebKit 只通过显式能力/失败清理断言;
|
||||
14 个播放场景按这个已经验证的宿主限制跳过。能力测试会在环境变化时失败提醒重新核对,
|
||||
不能把跳过转换为播放通过。最初失败和定向诊断报告的摘要/完整性保留在冻结证据中。
|
||||
Safari/macOS/iOS 的真实 MSE/ManagedMediaSource 与原生 HLS fallback 仍由 PKG-HLS-05、
|
||||
EX-03、REL-09/REVIEW-02 接续,SDK-01/HLS-ENV-01 保持 open。
|
||||
|
||||
本轮 enableWorker=false,流通过 Playwright 本地路由提供。远端网络、SDK worker、分组音轨变更、
|
||||
Safari/native fallback、更多 SDK 版本、安装包及示例页面全部组合不是本轮完成内容。
|
||||
显式安装产物映射暂不接受 HLS 源码回退;HLS-06 必须加入 HLS 包对应映射和隔离 tarball 消费。
|
||||
|
||||
## 后续实施
|
||||
|
||||
PKG-HLS-03 拆出映射、UI 提交/删除、Hls 订阅与生命周期;把上述缺陷变成候选修复断言,
|
||||
保留发布版观察。PKG-HLS-04 迁移自有模块和公开类型,修正可选 option、getName 对象与可选 index,
|
||||
保持旧声明接受范围及分发路径。PKG-HLS-05/06 完成实际 SDK、浏览器、示例及安装包验收。
|
||||
+3
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 215 项,范围 22 个包及工作区/示例。
|
||||
|
||||
状态:todo 153 / doing 0 / blocked 0 / done 62 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
状态:todo 152 / doing 0 / blocked 0 / done 63 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
|
||||
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| PKG-HLS-01 | artplayer-plugin-hls-control<br>核对包契约与历史用法 | BASE-05 | quality/audio 配置、getName、update、控件名及 Hls 实例绑定 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done |
|
||||
| PKG-HLS-02 | artplayer-plugin-hls-control<br>建立特有行为与错误测试 | PKG-HLS-01, ENG-03, ENG-05 | Auto/手动实际 level、音轨、去重/过滤和拓扑变化 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | todo |
|
||||
| PKG-HLS-02 | artplayer-plugin-hls-control<br>建立特有行为与错误测试 | PKG-HLS-01, ENG-03, ENG-05 | Auto/手动实际 level、音轨、去重/过滤和拓扑变化 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
|
||||
| PKG-HLS-03 | artplayer-plugin-hls-control<br>整理内部职责与资源 | PKG-HLS-02, CORE-11, CORE-14 | 映射计算与 selector 更新/删除、Hls 事件订阅分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo |
|
||||
| PKG-HLS-04 | artplayer-plugin-hls-control<br>迁移自有源码和公开类型 | PKG-HLS-03, ENG-04, ENG-06, CORE-07 | HLS 能力适配类型、回调上下文和旧 getName 参数 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo |
|
||||
| PKG-HLS-05 | artplayer-plugin-hls-control<br>验证新旧核心和组合 | PKG-HLS-04, CORE-22 | 本地多码率 HLS、换成无轨道来源、旧核心和最终核心 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
|
||||
@@ -466,3 +466,4 @@
|
||||
- PKG-CHAPTER-03: [记录](changes/2026-09-10-PKG-CHAPTER-03-typescript-modules.md) [记录](baselines/chapter-migration-validation.json)
|
||||
- PKG-CHAPTER-04: [记录](changes/2026-09-10-PKG-CHAPTER-04-public-types.md) [记录](baselines/chapter-types-validation.json)
|
||||
- PKG-HLS-01: [记录](baselines/hls-control-contract.md) [记录](baselines/hls-control-release.json) [记录](changes/2026-09-11-PKG-HLS-01-contract.md)
|
||||
- PKG-HLS-02: [记录](hls-validation.md) [记录](baselines/hls-sdk.json) [记录](baselines/hls-validation.json) [记录](changes/2026-09-11-PKG-HLS-02-tests.md)
|
||||
@@ -1,5 +1,13 @@
|
||||
# 进度与证据
|
||||
|
||||
## 最新完成:PKG-HLS-02 行为与 SDK 基线
|
||||
|
||||
45 项 HLS Node 契约通过,覆盖源码及发布 main/legacy/ESM;完整 CI 522 项、249 个生产 TS 文件通过。真实 Hls.js 1.5.17 与本地双档位/双音轨流验证四组合的解码、实际选择、pause/seek、切源、失败恢复和销毁。
|
||||
|
||||
浏览器结果为 31 passed、14 skipped;Windows WebKit 26.6 没有 MSE/ManagedMediaSource,能力/失败清理通过不能替代其播放矩阵。首轮 14 失败及定向诊断已保留,Safari/native/worker 等缺口仍 open。旧 Auto 标签与空音轨菜单残留有真实 SDK 复现,过期回调有受控证据;HLS-03 将修复这些问题。
|
||||
|
||||
见 [测试说明](hls-validation.md)、[冻结证据](baselines/hls-validation.json) 与 [交付记录](changes/2026-09-11-PKG-HLS-02-tests.md)。63 done、152 todo;本任务独立本地 commit,核实后进入 HLS-03 职责拆分。HLS 生产源码尚未迁移 TS,类型和全发布矩阵继续按 04~06 执行,没有发布放行。
|
||||
|
||||
## 最新完成:PKG-HLS-01 发布契约
|
||||
|
||||
HLS control 1.1.0 的六文件归档、历史源码、声明/README/manifest 与两段主逻辑对照通过。新增独立冻结记录及只读重验工具;保留 BASE-01。空轨道旧菜单、过期回调、Auto 状态、getName 类型及示例清理已映射到后续任务,没有把静态观察写成 SDK 或浏览器通过。
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| VENDOR-06 | open / 待取证 | monaco-static-assets 来源、版本与许可闭环 | SITE-01, SITE-05 |
|
||||
| VENDOR-07 | open / 待取证 | vconsole 来源、版本与许可闭环 | SITE-01 |
|
||||
| VENDOR-08 | open / 待取证 | console-bundle 来源、版本与许可闭环 | SITE-01 |
|
||||
| SDK-01 | open / 待取证 | hls.js 实际集成验证范围 | PKG-HLS-01, EX-03 |
|
||||
| SDK-01 | open / 源码/产物事实 | hls.js 实际集成验证范围 | PKG-HLS-03, PKG-HLS-05, EX-03 |
|
||||
| SDK-02 | open / 待取证 | dash.js 实际集成验证范围 | PKG-DASH-01, EX-03 |
|
||||
| SDK-03 | open / 待取证 | flv.js 实际集成验证范围 | EX-03 |
|
||||
| SDK-04 | open / 待取证 | mpegts.js 实际集成验证范围 | EX-03 |
|
||||
@@ -120,3 +120,7 @@
|
||||
| BASE-LIFE-47 | resolved / 已复现 | AirPlay methods can call a picker or publish events and notices after destruction | CORE-20 |
|
||||
| BASE-LIFE-48 | resolved / 已复现 | Proxy-time destruction cannot clean a template before its public assignment | CORE-20 |
|
||||
| BASE-LIFE-49 | resolved / 已复现 | Constructor and Player initialization continue after a callback destroys the instance | CORE-20 |
|
||||
| HLS-UI-01 | open / 已复现 | 空轨道和关闭配置仍残留 HLS 菜单 | PKG-HLS-03 |
|
||||
| HLS-LIFE-01 | open / 已复现 | 过期 HLS 选择回调与销毁后 update 仍工作 | PKG-HLS-03 |
|
||||
| HLS-AUTO-01 | open / 已复现 | 真实 ABR 播放的 Auto 标签在 update 后丢失 | PKG-HLS-03, PKG-HLS-05 |
|
||||
| HLS-ENV-01 | open / 已复现 | Windows WebKit 缺失 MSE,HLS 播放矩阵尚缺 Safari 证据 | PKG-HLS-05, REL-09, REVIEW-02 |
|
||||
+72
-3
@@ -603,15 +603,17 @@
|
||||
{
|
||||
"id": "SDK-01",
|
||||
"title": "hls.js 实际集成验证范围",
|
||||
"confirmation": "unverified",
|
||||
"confirmation": "source-observed",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-HLS-01",
|
||||
"PKG-HLS-03",
|
||||
"PKG-HLS-05",
|
||||
"EX-03"
|
||||
],
|
||||
"evidence": [
|
||||
"packages/artplayer-plugin-hls-control/src/index.js",
|
||||
"docs/assets/example/hls.control.js"
|
||||
"docs/assets/example/hls.control.js",
|
||||
"refactor/hls-validation.md"
|
||||
],
|
||||
"compatibleResolution": "Caller supplies art.hls; homepage demo loads hls.js 1.5.17. Preserve quality/audio selectors and old core integration.",
|
||||
"closureCriteria": "固定 SDK/外部资源版本和环境;成功/失败/切换/销毁有真实证据,设备和网络限制逐项记录。"
|
||||
@@ -2520,6 +2522,73 @@
|
||||
"test/initialization.test.js"
|
||||
],
|
||||
"resolutionRationale": "Player installers and outer construction stop after reentrant closure without publishing later plugins or registering the dead instance. Installed normal descriptors/static/subclass behavior and getter/native-write destruction cases pass."
|
||||
},
|
||||
{
|
||||
"id": "HLS-UI-01",
|
||||
"title": "空轨道和关闭配置仍残留 HLS 菜单",
|
||||
"confirmation": "reproduced",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-HLS-03"
|
||||
],
|
||||
"evidence": [
|
||||
"refactor/hls-validation.md",
|
||||
"test/hls-control.test.js",
|
||||
"test/browser/hls-control.spec.js"
|
||||
],
|
||||
"compatibleResolution": "使用既有 remove API 清理不再可用的 UI,保留菜单名称及选择语义。",
|
||||
"closureCriteria": "候选空轨道、关闭开关及恢复测试通过。"
|
||||
},
|
||||
{
|
||||
"id": "HLS-LIFE-01",
|
||||
"title": "过期 HLS 选择回调与销毁后 update 仍工作",
|
||||
"confirmation": "reproduced",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-HLS-03"
|
||||
],
|
||||
"evidence": [
|
||||
"refactor/hls-validation.md",
|
||||
"test/hls-control.test.js",
|
||||
"test/browser/hls-control.spec.js"
|
||||
],
|
||||
"compatibleResolution": "限制过期实例回调,整理监听器归属,Hls 仍由调用方销毁。",
|
||||
"closureCriteria": "替换、销毁、重入及 SDK 订阅解绑的候选回归通过。"
|
||||
},
|
||||
{
|
||||
"id": "HLS-AUTO-01",
|
||||
"title": "真实 ABR 播放的 Auto 标签在 update 后丢失",
|
||||
"confirmation": "reproduced",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-HLS-03",
|
||||
"PKG-HLS-05"
|
||||
],
|
||||
"evidence": [
|
||||
"refactor/hls-validation.md",
|
||||
"test/hls-control.test.js",
|
||||
"test/browser/hls-control.spec.js"
|
||||
],
|
||||
"compatibleResolution": "区分自动策略与实际播放档位,保留手动及 Auto 选择值。",
|
||||
"closureCriteria": "实际 SDK 自动/手动切换和事件同步通过。"
|
||||
},
|
||||
{
|
||||
"id": "HLS-ENV-01",
|
||||
"title": "Windows WebKit 缺失 MSE,HLS 播放矩阵尚缺 Safari 证据",
|
||||
"confirmation": "reproduced",
|
||||
"status": "open",
|
||||
"owners": [
|
||||
"PKG-HLS-05",
|
||||
"REL-09",
|
||||
"REVIEW-02"
|
||||
],
|
||||
"evidence": [
|
||||
"refactor/hls-validation.md",
|
||||
"test/hls-control.test.js",
|
||||
"test/browser/hls-control.spec.js"
|
||||
],
|
||||
"compatibleResolution": "显式验证宿主不支持,保留 14 skipped,不作为播放通过;补有 MSE 的 Safari/macOS/iOS 环境。",
|
||||
"closureCriteria": "实际 Safari/MSE/native 路径证据齐全,不能仅凭 Windows 能力检查关闭。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function readMember(archive, member) {
|
||||
}
|
||||
|
||||
export async function ensureArchive(release) {
|
||||
assert(/^[a-z0-9-]+$/.test(release.name) && /^\d+\.\d+\.\d+$/.test(release.version), 'Invalid release identifier')
|
||||
assert(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(release.name) && /^\d+\.\d+\.\d+$/.test(release.version), 'Invalid release identifier')
|
||||
const archive = path.join(cacheDir, `${release.name}-${release.version}.tgz`)
|
||||
if (!fs.existsSync(archive)) {
|
||||
const url = new URL(release.tarball)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { hash, verifyIntegrity } from './releases.mjs'
|
||||
import { ensureArchive, hash, verifyIntegrity } from './releases.mjs'
|
||||
|
||||
test('release integrity rejects corrupted bytes and inconsistent frozen digests', () => {
|
||||
const bytes = Buffer.from('known release artifact')
|
||||
@@ -9,3 +9,10 @@ test('release integrity rejects corrupted bytes and inconsistent frozen digests'
|
||||
assert.throws(() => verifyIntegrity(Buffer.from('replaced artifact'), release), /Integrity mismatch/)
|
||||
assert.throws(() => verifyIntegrity(bytes, { ...release, sha256: '0'.repeat(64) }), /Archive hash mismatch/)
|
||||
})
|
||||
|
||||
test('release cache rejects unsafe package and version path identifiers before I/O', async () => {
|
||||
for (const name of ['../hls.js', '.hls', 'hls..js', 'hls/js', 'hls\\js', 'hls.js/evil', '-hls'])
|
||||
await assert.rejects(ensureArchive({ name, version: '1.5.17' }), /Invalid release identifier/)
|
||||
for (const version of ['../1.5.17', '1.5.17/evil', 'latest'])
|
||||
await assert.rejects(ensureArchive({ name: 'hls.js', version }), /Invalid release identifier/)
|
||||
})
|
||||
+7
-2
@@ -1804,11 +1804,16 @@
|
||||
"ENG-03",
|
||||
"ENG-05"
|
||||
],
|
||||
"status": "todo",
|
||||
"status": "done",
|
||||
"risk": "H",
|
||||
"deliverable": "Auto/手动实际 level、音轨、去重/过滤和拓扑变化",
|
||||
"acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言",
|
||||
"evidence": []
|
||||
"evidence": [
|
||||
"hls-validation.md",
|
||||
"baselines/hls-sdk.json",
|
||||
"baselines/hls-validation.json",
|
||||
"changes/2026-09-11-PKG-HLS-02-tests.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "PKG-HLS-03",
|
||||
|
||||
@@ -1023,7 +1023,8 @@
|
||||
"id": "hls.js",
|
||||
"riskId": "SDK-01",
|
||||
"owners": [
|
||||
"PKG-HLS-01",
|
||||
"PKG-HLS-03",
|
||||
"PKG-HLS-05",
|
||||
"EX-03"
|
||||
],
|
||||
"sourceFiles": [
|
||||
@@ -1031,7 +1032,7 @@
|
||||
"docs/assets/example/hls.control.js"
|
||||
],
|
||||
"contract": "Caller supplies art.hls; homepage demo loads hls.js 1.5.17. Preserve quality/audio selectors and old core integration.",
|
||||
"validationStatus": "Source integration observed; complete real SDK/service/device matrix not yet verified.",
|
||||
"validationStatus": "Hls.js 1.5.17 frozen in refactor/baselines/hls-sdk.json; Chromium/Firefox local playback baseline verified in PKG-HLS-02. Windows WebKit has no MSE; Safari/native/worker/version and installed-package matrices remain open. See refactor/hls-validation.md.",
|
||||
"demoPathsSource": "refactor/baselines/demo-inventory.json"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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 { fileURLToPath } from 'node:url'
|
||||
import { hash } from '../refactor/scripts/releases.mjs'
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url))
|
||||
const output = path.resolve(root, process.argv[2] || 'test/browser/media/hls')
|
||||
assert(!fs.existsSync(output), 'Output must be a new directory; never overwrite frozen media')
|
||||
const ffmpeg = process.env.ARTPLAYER_FFMPEG || 'ffmpeg'
|
||||
const version = execFileSync(ffmpeg, ['-version'], { encoding: 'utf8' }).split(/\r?\n/)[0]
|
||||
fs.mkdirSync(output, { recursive: true })
|
||||
const commands = []
|
||||
function generate(name, input, encoding) {
|
||||
const args = ['-hide_banner', '-loglevel', 'error', '-n', '-f', 'lavfi', '-i', input, '-t', '12', ...encoding, '-f', 'hls', '-hls_time', '2', '-hls_playlist_type', 'vod', '-hls_segment_filename', `${name}-%02d.mpegts`, `${name}.m3u8`]
|
||||
execFileSync(ffmpeg, args, { cwd: output, stdio: 'pipe' })
|
||||
commands.push(args)
|
||||
}
|
||||
for (const [name, size] of [['low', '160x90'], ['high', '320x180']]) {
|
||||
generate(name, `testsrc2=size=${size}:rate=24`, ['-an', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-profile:v', 'baseline', '-level:v', '3.0', '-g', '48', '-keyint_min', '48', '-sc_threshold', '0', '-crf', '28'])
|
||||
}
|
||||
for (const [name, frequency] of [['english', 440], ['french', 880]])
|
||||
generate(name, `sine=frequency=${frequency}:sample_rate=48000`, ['-vn', '-c:a', 'aac', '-b:a', '48k', '-ac', '2'])
|
||||
const variants = '#EXT-X-STREAM-INF:BANDWIDTH=200000,RESOLUTION=160x90,CODECS="avc1.42c01e,mp4a.40.2",AUDIO="audio"\nlow.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=320x180,CODECS="avc1.42c01e,mp4a.40.2",AUDIO="audio"\nhigh.m3u8\n'
|
||||
fs.writeFileSync(path.join(output, 'master.m3u8'), `#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,URI="english.m3u8"\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="French",LANGUAGE="fr",DEFAULT=NO,AUTOSELECT=YES,URI="french.m3u8"\n${variants}`)
|
||||
fs.writeFileSync(path.join(output, 'video-only.m3u8'), '#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-STREAM-INF:BANDWIDTH=200000,RESOLUTION=160x90,CODECS="avc1.42c01e"\nlow.m3u8\n')
|
||||
const files = Object.fromEntries(fs.readdirSync(output).sort().map(name => [name, { sha256: hash(fs.readFileSync(path.join(output, name))), bytes: fs.statSync(path.join(output, name)).size }]))
|
||||
fs.writeFileSync(path.join(output, 'manifest.json'), `${JSON.stringify({ kind: 'generated-hls-media', version, duration: 12, commands, files }, null, 2)}\n`)
|
||||
console.log(`Generated ${Object.keys(files).length} HLS files in ${output}`)
|
||||
@@ -14,6 +14,13 @@ Use the pinned Node/Yarn toolchain from `../refactor/toolchain-setup.md`.
|
||||
|
||||
`helpers/playback.js` creates the controlled media facade used by the original playback regressions. It models only the exercised methods and events. It is not HTMLMediaElement validation; actual playback remains in browser tests.
|
||||
|
||||
`node --test test/hls-control.test.js` runs shared HLS control contracts against workspace source
|
||||
and integrity-checked published main/legacy/ESM. It is included in `yarn test:unit`.
|
||||
`helpers/hls-control.js` owns the controlled SDK/registry host; it does not simulate decoding or ABR.
|
||||
Published-only tests preserve historical bug observations without requiring candidate code to keep them.
|
||||
Real Hls.js 1.5.17 playback is in `test/browser/hls-control.spec.js`; see its environment limits in
|
||||
`refactor/hls-validation.md`. Test SDK download is pinned and verified, not an installed runtime dependency.
|
||||
|
||||
`contracts/emitter.js` owns public assertions, independent of module layout. `public-behavior.test.js` runs them against the integrity-checked published core and current bundled source. The combined chain/context contract corresponds to BASE-03 EVENT.chain and EVENT.context-arguments; other EVENT IDs retain their baseline names. Exceptions, callback identity, mutation during dispatch and once reentry must not be weakened during migration.
|
||||
|
||||
Set `ARTPLAYER_TEST_CORE` to a built `.js`, `.legacy.js` or `.mjs` core file to add a candidate to the same public-contract run, then execute `node --test test/public-behavior.test.js`. An invalid path/export fails the run. This is a narrow event contract check, not isolated package installation or complete API compatibility; ENG-07 owns tarball consumption.
|
||||
|
||||
@@ -15,6 +15,12 @@ no retries, a loopback server on 8084, and no existing dev server. Set `ARTPLAYE
|
||||
to use another free port. The server builds core and chapter from current JS/TS entries in memory;
|
||||
it does not modify dist or docs outputs. Restart the test command after source changes.
|
||||
|
||||
`hls-control.spec.js` separately builds HLS control and verifies a pinned Hls.js 1.5.17 archive.
|
||||
Its local playlists/segments are generated fixtures with checked hashes. Windows WebKit lacks MSE:
|
||||
one explicit capability/error-cleanup test covers that limitation and its 14 playback cases are
|
||||
skipped with a reason. Chromium/Firefox run the full four-combination HLS matrix. This is not
|
||||
Safari playback approval; see [HLS validation](../../refactor/hls-validation.md) for evidence and open gates.
|
||||
|
||||
`playback.spec.js` tests published/current core with current chapter, real decoded frame colors,
|
||||
media time advancing, pause, seek, source change to the existing docs video, destroy and real
|
||||
HTTP failure. These are initial smoke contracts, not full chapter or lifecycle coverage.
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
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 sdkCode
|
||||
let publishedCode
|
||||
let sourceCode
|
||||
let evidence
|
||||
let media
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
const state = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
return {
|
||||
supported: window.Hls?.isSupported(),
|
||||
mediaSource: typeof window.MediaSource,
|
||||
managedMediaSource: typeof window.ManagedMediaSource,
|
||||
video: art?.video.outerHTML,
|
||||
readyState: art?.video.readyState,
|
||||
networkState: art?.video.networkState,
|
||||
sdkErrors: window.sdkErrors,
|
||||
sdkEvents: window.sdkEvents,
|
||||
levels: art?.hls.levels.map(level => ({ height: level.height, codec: level.videoCodec })),
|
||||
tracks: art?.hls.audioTracks.map(track => ({ id: track.id, name: track.name })),
|
||||
}
|
||||
}).catch(error => ({ diagnosticError: error.message }))
|
||||
await testInfo.attach('hls-state', { contentType: 'application/json', body: JSON.stringify(state, null, 2) })
|
||||
})
|
||||
test.beforeAll(async () => {
|
||||
const sdk = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/hls-sdk.json', import.meta.url)))
|
||||
const plugin = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/hls-control-release.json', import.meta.url)))
|
||||
async function code(release, member) {
|
||||
const bytes = readMember(await ensureArchive(release), member)
|
||||
assert.equal(hash(bytes), release.files[member])
|
||||
return bytes.toString()
|
||||
}
|
||||
sdkCode = await code(sdk.release, 'package/dist/hls.min.js')
|
||||
publishedCode = await code(plugin.release, 'package/dist/artplayer-plugin-hls-control.js')
|
||||
// This task exercises source builds. Installed artifacts are a separate HLS-06 gate.
|
||||
assert(!process.env.ARTPLAYER_BROWSER_ARTIFACTS, 'HLS source fixture cannot masquerade as an installed plugin')
|
||||
sourceCode = await compilePackage('artplayer-plugin-hls-control', 'umd')
|
||||
const manifest = JSON.parse(fs.readFileSync(new URL('./media/hls/manifest.json', import.meta.url)))
|
||||
media = new Map()
|
||||
for (const [name, expected] of Object.entries(manifest.files)) {
|
||||
assert(/^[\w.-]+$/.test(name))
|
||||
const bytes = fs.readFileSync(new URL(`./media/hls/${name}`, import.meta.url))
|
||||
assert.equal(hash(bytes), expected.sha256)
|
||||
assert.equal(bytes.length, expected.bytes)
|
||||
media.set(name, bytes)
|
||||
}
|
||||
evidence = { sdk: sdk.release, plugin: plugin.release, sourceSHA256: hash(sourceCode), media: manifest, limitations: ['SDK worker disabled', 'local deterministic media', 'source candidate, not installed package', 'no physical device certification'] }
|
||||
})
|
||||
|
||||
async function openHls(page, core, plugin, testInfo, manifest = 'master.m3u8') {
|
||||
await testInfo.attach('hls-inputs', { contentType: 'application/json', body: JSON.stringify(evidence) })
|
||||
await page.route('**/hls-fixture/**', async (route) => {
|
||||
const name = new URL(route.request().url()).pathname.split('/').at(-1)
|
||||
const bytes = media.get(name)
|
||||
await route.fulfill({ status: bytes ? 200 : 503, body: bytes || 'Intentional HLS failure', contentType: name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t' })
|
||||
})
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.addScriptTag({ content: sdkCode })
|
||||
await page.addScriptTag({ content: plugin === 'published' ? publishedCode : sourceCode })
|
||||
await page.evaluate((manifest) => {
|
||||
window.sdkErrors = []
|
||||
window.sdkEvents = []
|
||||
window.destroyedEngines = 0
|
||||
window.art = new window.Artplayer({
|
||||
container: '.player',
|
||||
url: `/hls-fixture/${manifest}`,
|
||||
muted: true,
|
||||
setting: true,
|
||||
plugins: [window.artplayerPluginHlsControl({ quality: { control: true, setting: true }, audio: { control: true, setting: true } })],
|
||||
customType: { m3u8(video, url, art) {
|
||||
if (art.hls)
|
||||
art.hls.destroy()
|
||||
const hls = new window.Hls({ enableWorker: false, manifestLoadingMaxRetry: 0 })
|
||||
art.hls = hls
|
||||
hls.on(window.Hls.Events.ERROR, (_, data) => window.sdkErrors.push({ type: data.type, details: data.details, fatal: data.fatal }))
|
||||
for (const name of ['LEVEL_SWITCHED', 'AUDIO_TRACK_SWITCHED', 'MANIFEST_PARSED'])
|
||||
hls.on(window.Hls.Events[name], (_, data) => window.sdkEvents.push({ name, level: data.level, id: data.id }))
|
||||
hls.on(window.Hls.Events.DESTROYING, () => window.destroyedEngines++)
|
||||
hls.loadSource(url)
|
||||
hls.attachMedia(video)
|
||||
} },
|
||||
})
|
||||
const art = window.art
|
||||
art.on('destroy', () => art.hls.destroy())
|
||||
document.querySelector('#play').onclick = () => art.play()
|
||||
document.querySelector('#pause').onclick = () => art.pause()
|
||||
}, manifest)
|
||||
}
|
||||
|
||||
test('SDK MSE availability is explicit; unavailable hosts report failure and clean up', async ({ page, browserName }, testInfo) => {
|
||||
await openHls(page, 'candidate', 'published', testInfo)
|
||||
const capability = await page.evaluate(() => ({ supported: window.Hls.isSupported(), mse: typeof window.MediaSource, managed: typeof window.ManagedMediaSource }))
|
||||
if (process.platform === 'win32' && browserName === 'webkit') {
|
||||
expect(capability).toEqual({ supported: false, mse: 'undefined', managed: 'undefined' })
|
||||
await expect.poll(() => page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([{ type: 'mediaError', details: 'manifestIncompatibleCodecsError', fatal: true }])
|
||||
expect(await page.evaluate(() => window.art.isReady)).toBe(false)
|
||||
}
|
||||
else {
|
||||
expect(capability.supported).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
expect(await page.evaluate(() => {
|
||||
const sdk = window.art.hls
|
||||
window.art.destroy()
|
||||
return { media: sdk.media, destroyed: window.destroyedEngines }
|
||||
})).toEqual({ media: null, destroyed: 1 })
|
||||
})
|
||||
|
||||
test.describe('MSE playback matrix', () => {
|
||||
// Verified by the separate capability test above. Safari/MSE coverage remains HLS-05 work.
|
||||
test.skip(({ browserName }) => process.platform === 'win32' && browserName === 'webkit', 'Bundled Windows WebKit exposes neither MediaSource nor ManagedMediaSource; this is an unverified playback environment, not compatibility approval')
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const plugin of ['published', 'candidate']) {
|
||||
test(`${core} core / ${plugin} HLS: real decoding, manual quality, Auto and audio`, async ({ page }, testInfo) => {
|
||||
await openHls(page, core, plugin, testInfo)
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(0.3)
|
||||
const initial = await page.evaluate(() => ({
|
||||
supported: window.Hls.isSupported(),
|
||||
version: window.Hls.version,
|
||||
levels: window.art.hls.levels.map(level => level.height),
|
||||
tracks: window.art.hls.audioTracks.map(track => ({ id: track.id, name: track.name })),
|
||||
frames: window.art.video.getVideoPlaybackQuality().totalVideoFrames,
|
||||
}))
|
||||
expect(initial).toMatchObject({ supported: true, version: '1.5.17', levels: [90, 180], tracks: [{ id: 0, name: 'English' }, { id: 1, name: 'French' }] })
|
||||
expect(initial.frames).toBeGreaterThan(0)
|
||||
await page.evaluate(() => {
|
||||
window.art.controls.show = true
|
||||
})
|
||||
const quality = page.locator('.art-control-hls-quality')
|
||||
await quality.hover()
|
||||
await quality.locator('.art-selector-item').filter({ hasText: /^90P$/ }).click()
|
||||
await expect.poll(() => page.evaluate(() => ({ automatic: window.art.hls.autoLevelEnabled, level: window.art.hls.currentLevel, height: window.art.video.videoHeight }))).toEqual({ automatic: false, level: 0, height: 90 })
|
||||
await quality.hover()
|
||||
await quality.locator('.art-selector-item').filter({ hasText: /^180P$/ }).click()
|
||||
await expect.poll(() => page.evaluate(() => ({ manual: window.art.hls.autoLevelEnabled, level: window.art.hls.currentLevel, height: window.art.video.videoHeight }))).toEqual({ manual: false, level: 1, height: 180 })
|
||||
await quality.hover()
|
||||
await quality.locator('.art-selector-item').filter({ hasText: /^Auto$/ }).click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.hls.autoLevelEnabled)).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.art.hls.currentLevel)).toBeGreaterThanOrEqual(0)
|
||||
await page.evaluate(() => {
|
||||
window.art.controls.show = true
|
||||
})
|
||||
const audio = page.locator('.art-control-hls-audio')
|
||||
await audio.hover()
|
||||
await audio.locator('.art-selector-item').filter({ hasText: /^French$/ }).click()
|
||||
await expect.poll(() => page.evaluate(() => window.sdkEvents.some(event => event.name === 'AUDIO_TRACK_SWITCHED' && event.id === 1))).toBe(true)
|
||||
expect(await page.evaluate(() => window.art.hls.audioTrack)).toBe(1)
|
||||
await page.click('#pause')
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.paused)).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.art.seek = 5
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeCloseTo(5, 1)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(5.2)
|
||||
expect(await page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([])
|
||||
const result = await page.evaluate(() => {
|
||||
const video = window.art.video
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 16
|
||||
canvas.height = 16
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(video, 0, 0, 16, 16)
|
||||
const pixels = ctx.getImageData(0, 0, 16, 16).data
|
||||
const colors = new Set()
|
||||
for (let index = 0; index < pixels.length; index += 4)
|
||||
colors.add(`${pixels[index]},${pixels[index + 1]},${pixels[index + 2]}`)
|
||||
const sdk = window.art.hls
|
||||
window.art.destroy()
|
||||
return { colors: colors.size, media: sdk.media, destroyed: window.destroyedEngines, children: document.querySelector('.player').childElementCount }
|
||||
})
|
||||
expect(result.colors).toBeGreaterThan(8)
|
||||
expect(result).toMatchObject({ media: null, destroyed: 1, children: 0 })
|
||||
})
|
||||
|
||||
test(`${core} core / ${plugin} HLS: failed manifest and actual SDK teardown`, async ({ page }, testInfo) => {
|
||||
await openHls(page, core, plugin, testInfo, 'missing.m3u8')
|
||||
await expect.poll(() => page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([{ type: 'networkError', details: 'manifestLoadError', fatal: true }])
|
||||
expect(await page.locator('.art-control-hls-quality').count()).toBe(0)
|
||||
await page.evaluate(() => window.art.switchUrl('/hls-fixture/master.m3u8'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.hls.levels.length)).toBe(2)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(0.2)
|
||||
expect(await page.evaluate(() => {
|
||||
const sdk = window.art.hls
|
||||
window.art.destroy()
|
||||
return { destroyed: window.destroyedEngines, media: sdk.media }
|
||||
})).toEqual({ destroyed: 2, media: null })
|
||||
})
|
||||
|
||||
test(`${core} core / ${plugin} HLS: source switch drops SDK audio topology`, async ({ page }, testInfo) => {
|
||||
await openHls(page, core, plugin, testInfo)
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(0.2)
|
||||
await page.evaluate(() => {
|
||||
window.previousHls = window.art.hls
|
||||
return window.art.switchUrl('/hls-fixture/video-only.m3u8')
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => ({ levels: window.art.hls.levels.length, tracks: window.art.hls.audioTracks.length, height: window.art.video.videoHeight }))).toEqual({ levels: 1, tracks: 0, height: 90 })
|
||||
expect(await page.evaluate(() => ({ oldMedia: window.previousHls.media, replaced: window.previousHls !== window.art.hls, destroyed: window.destroyedEngines }))).toEqual({ oldMedia: null, replaced: true, destroyed: 1 })
|
||||
if (plugin === 'published') {
|
||||
// Real SDK corroboration of the frozen historical empty-topology defect.
|
||||
await expect(page.locator('.art-control-hls-audio')).toHaveCount(1)
|
||||
}
|
||||
expect(await page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([])
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
expect(await page.evaluate(() => window.destroyedEngines)).toBe(2)
|
||||
})
|
||||
}
|
||||
|
||||
test(`${core} core / published HLS: real Auto playback loses Auto label on update`, async ({ page }, testInfo) => {
|
||||
await openHls(page, core, 'published', testInfo)
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(0.2)
|
||||
expect(await page.evaluate(() => window.art.hls.autoLevelEnabled)).toBe(true)
|
||||
const height = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.plugins.artplayerPluginHlsControl.update()
|
||||
return art.hls.levels[art.hls.currentLevel].height
|
||||
})
|
||||
await expect(page.locator('.art-control-hls-quality .art-selector-value')).toHaveText(`${height}P`)
|
||||
expect(await page.evaluate(() => window.art.hls.autoLevelEnabled)).toBe(true)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
# Deterministic HLS fixtures
|
||||
|
||||
Generated by `node scripts/generate-hls-fixture.mjs`, using FFmpeg recorded in manifest.json.
|
||||
No downloaded film or audio: testsrc2 supplies moving patterns, sine supplies 440/880 Hz audio.
|
||||
The 12-second master offers 160x90 and 320x180 AVC video, English/French AAC audio, and a
|
||||
separate video-only master for topology changes. Each media playlist is VOD with 2-second segments.
|
||||
|
||||
Segments use `.mpegts` so source tools cannot mistake MPEG transport streams for TypeScript.
|
||||
The HLS browser fixture serves them as video/mp2t. File contents and byte counts are checked
|
||||
against manifest.json before browser use. Tests need neither FFmpeg nor a remote media service.
|
||||
|
||||
To change the media, generate into a new directory, for example
|
||||
`node scripts/generate-hls-fixture.mjs refactor/.cache/hls-media-next`; inspect the output and
|
||||
update the media, manifest and browser evidence together. The script refuses existing directories.
|
||||
Set ARTPLAYER_FFMPEG to a particular FFmpeg executable if it is not on PATH. Re-encoding with
|
||||
another FFmpeg version is not expected to preserve bytes; do not overwrite hashes to silence drift.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXTINF:2.005333,
|
||||
english-00.mpegts
|
||||
#EXTINF:2.005333,
|
||||
english-01.mpegts
|
||||
#EXTINF:2.005333,
|
||||
english-02.mpegts
|
||||
#EXTINF:1.984000,
|
||||
english-03.mpegts
|
||||
#EXTINF:2.005333,
|
||||
english-04.mpegts
|
||||
#EXTINF:2.005333,
|
||||
english-05.mpegts
|
||||
#EXTINF:0.021333,
|
||||
english-06.mpegts
|
||||
#EXT-X-ENDLIST
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXTINF:2.005333,
|
||||
french-00.mpegts
|
||||
#EXTINF:2.005333,
|
||||
french-01.mpegts
|
||||
#EXTINF:2.005333,
|
||||
french-02.mpegts
|
||||
#EXTINF:1.984000,
|
||||
french-03.mpegts
|
||||
#EXTINF:2.005333,
|
||||
french-04.mpegts
|
||||
#EXTINF:2.005333,
|
||||
french-05.mpegts
|
||||
#EXTINF:0.021333,
|
||||
french-06.mpegts
|
||||
#EXT-X-ENDLIST
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXTINF:2.000000,
|
||||
high-00.mpegts
|
||||
#EXTINF:2.000000,
|
||||
high-01.mpegts
|
||||
#EXTINF:2.000000,
|
||||
high-02.mpegts
|
||||
#EXTINF:2.000000,
|
||||
high-03.mpegts
|
||||
#EXTINF:2.000000,
|
||||
high-04.mpegts
|
||||
#EXTINF:2.000000,
|
||||
high-05.mpegts
|
||||
#EXT-X-ENDLIST
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXTINF:2.000000,
|
||||
low-00.mpegts
|
||||
#EXTINF:2.000000,
|
||||
low-01.mpegts
|
||||
#EXTINF:2.000000,
|
||||
low-02.mpegts
|
||||
#EXTINF:2.000000,
|
||||
low-03.mpegts
|
||||
#EXTINF:2.000000,
|
||||
low-04.mpegts
|
||||
#EXTINF:2.000000,
|
||||
low-05.mpegts
|
||||
#EXT-X-ENDLIST
|
||||
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"kind": "generated-hls-media",
|
||||
"version": "ffmpeg version 2025-11-12-git-6cdd2cbe32-full_build-www.gyan.dev Copyright (c) 2000-2025 the FFmpeg developers",
|
||||
"duration": 12,
|
||||
"commands": [
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-n",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=size=160x90:rate=24",
|
||||
"-t",
|
||||
"12",
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-profile:v",
|
||||
"baseline",
|
||||
"-level:v",
|
||||
"3.0",
|
||||
"-g",
|
||||
"48",
|
||||
"-keyint_min",
|
||||
"48",
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
"-crf",
|
||||
"28",
|
||||
"-f",
|
||||
"hls",
|
||||
"-hls_time",
|
||||
"2",
|
||||
"-hls_playlist_type",
|
||||
"vod",
|
||||
"-hls_segment_filename",
|
||||
"low-%02d.mpegts",
|
||||
"low.m3u8"
|
||||
],
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-n",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=size=320x180:rate=24",
|
||||
"-t",
|
||||
"12",
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-profile:v",
|
||||
"baseline",
|
||||
"-level:v",
|
||||
"3.0",
|
||||
"-g",
|
||||
"48",
|
||||
"-keyint_min",
|
||||
"48",
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
"-crf",
|
||||
"28",
|
||||
"-f",
|
||||
"hls",
|
||||
"-hls_time",
|
||||
"2",
|
||||
"-hls_playlist_type",
|
||||
"vod",
|
||||
"-hls_segment_filename",
|
||||
"high-%02d.mpegts",
|
||||
"high.m3u8"
|
||||
],
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-n",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:sample_rate=48000",
|
||||
"-t",
|
||||
"12",
|
||||
"-vn",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"48k",
|
||||
"-ac",
|
||||
"2",
|
||||
"-f",
|
||||
"hls",
|
||||
"-hls_time",
|
||||
"2",
|
||||
"-hls_playlist_type",
|
||||
"vod",
|
||||
"-hls_segment_filename",
|
||||
"english-%02d.mpegts",
|
||||
"english.m3u8"
|
||||
],
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-n",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=880:sample_rate=48000",
|
||||
"-t",
|
||||
"12",
|
||||
"-vn",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"48k",
|
||||
"-ac",
|
||||
"2",
|
||||
"-f",
|
||||
"hls",
|
||||
"-hls_time",
|
||||
"2",
|
||||
"-hls_playlist_type",
|
||||
"vod",
|
||||
"-hls_segment_filename",
|
||||
"french-%02d.mpegts",
|
||||
"french.m3u8"
|
||||
]
|
||||
],
|
||||
"files": {
|
||||
"english-00.mpegts": {
|
||||
"sha256": "cc599f7c270385e85c28f710a34c5bd90b09d4d27adb3b1e6af3de65584b8207",
|
||||
"bytes": 13912
|
||||
},
|
||||
"english-01.mpegts": {
|
||||
"sha256": "01b17c28defc8a8ebf4978c5236eb759d80521a1cb840898095c35dac420cec5",
|
||||
"bytes": 14100
|
||||
},
|
||||
"english-02.mpegts": {
|
||||
"sha256": "90751b8b5abedd6b2f2e4eb83ccd609098ec7f2901163aaebf977cb9a11c46eb",
|
||||
"bytes": 14100
|
||||
},
|
||||
"english-03.mpegts": {
|
||||
"sha256": "9b1d86b7b261df66dbdace4ef1e846796cd710ffb72b22fc69532c1fe7d1c55e",
|
||||
"bytes": 13912
|
||||
},
|
||||
"english-04.mpegts": {
|
||||
"sha256": "dbfddd4f997942dd0b912f4c8bdbc5c11ee2a3fc51ac67df48d3aed2756715b9",
|
||||
"bytes": 14100
|
||||
},
|
||||
"english-05.mpegts": {
|
||||
"sha256": "9b53790d4f50f172852b7d4ff51bdd4bedd39e4c7a1efbfe734edf7b9a68a533",
|
||||
"bytes": 14100
|
||||
},
|
||||
"english-06.mpegts": {
|
||||
"sha256": "50baaa5e9fe7ca49b22c4d0817c44103b3edc763aee7f1b17d22f796d4189d4a",
|
||||
"bytes": 752
|
||||
},
|
||||
"english.m3u8": {
|
||||
"sha256": "5aae9d117d458f65f7febae68555ea4e48b2055b4cfb123fb919e385a1edda21",
|
||||
"bytes": 365
|
||||
},
|
||||
"french-00.mpegts": {
|
||||
"sha256": "7278acd094bc7caeeeea424a2c11ac4285fc45a8becb6c8ec55068f295ae35cd",
|
||||
"bytes": 13912
|
||||
},
|
||||
"french-01.mpegts": {
|
||||
"sha256": "e9464d109e36a53d7f151442b544d47ce79324bbaa805e0a98f171621c9c7a13",
|
||||
"bytes": 14100
|
||||
},
|
||||
"french-02.mpegts": {
|
||||
"sha256": "3ac48cab6aac942950b59f79ac6bd950dcae425f14821b83b1b5bb72d6815eb0",
|
||||
"bytes": 14100
|
||||
},
|
||||
"french-03.mpegts": {
|
||||
"sha256": "906f2d8b1f29bb7d57240b145563bab975d8f7dfff78cebd1505b6d2a3feb43b",
|
||||
"bytes": 13912
|
||||
},
|
||||
"french-04.mpegts": {
|
||||
"sha256": "cbf82876118d41af254bdef51f6cd08d842e8c6edf53250667fad2b8088e3601",
|
||||
"bytes": 14100
|
||||
},
|
||||
"french-05.mpegts": {
|
||||
"sha256": "a91e564ec54d845837217c45b8e25b08fe208f2b5f97562f513baabd515427d9",
|
||||
"bytes": 14100
|
||||
},
|
||||
"french-06.mpegts": {
|
||||
"sha256": "2c8edc11b06bacf1e62c4db67594ce187aa93e0241012011ebcf587b64ccff46",
|
||||
"bytes": 752
|
||||
},
|
||||
"french.m3u8": {
|
||||
"sha256": "6f7fb3eacb944597fa3105cb6d708eac3a8dc413e1706a11b6fb0e631214634e",
|
||||
"bytes": 358
|
||||
},
|
||||
"high-00.mpegts": {
|
||||
"sha256": "c5a8faac85b0594818255117696dce43fda6b363df40e2f3ffc453bd236cc833",
|
||||
"bytes": 45308
|
||||
},
|
||||
"high-01.mpegts": {
|
||||
"sha256": "5714640c3f17d86c56036453dc17d45f871af63b461318c43527af3482f8661c",
|
||||
"bytes": 52264
|
||||
},
|
||||
"high-02.mpegts": {
|
||||
"sha256": "fea1cbfb199bc867a322c7a646d43594a1d0d1d23fa0bcb6e770f2669b489440",
|
||||
"bytes": 48128
|
||||
},
|
||||
"high-03.mpegts": {
|
||||
"sha256": "4a74a28e7ad6bc626358e32365a0b07df1da82c92753e28032983e22404958b5",
|
||||
"bytes": 54144
|
||||
},
|
||||
"high-04.mpegts": {
|
||||
"sha256": "d04889b3b996d3e79c12bc297f9577690d3ae4f4172cdc0484b584954dab789c",
|
||||
"bytes": 47376
|
||||
},
|
||||
"high-05.mpegts": {
|
||||
"sha256": "36ee0c64b8c3637d0e2282c611e9250650ac493440095fc2efbe9de1c54add15",
|
||||
"bytes": 48128
|
||||
},
|
||||
"high.m3u8": {
|
||||
"sha256": "672c0e8b99242be05b16fc747a00715b2c25579b1ecc1623c0579fa34f0cb11d",
|
||||
"bytes": 311
|
||||
},
|
||||
"low-00.mpegts": {
|
||||
"sha256": "cbd62f5f4641cbe188a15640139bea393b5927fce600ff7513ef9ade9b33fb62",
|
||||
"bytes": 28952
|
||||
},
|
||||
"low-01.mpegts": {
|
||||
"sha256": "f7821fba38c63f5d80daef21cdd3430880bdc9fd5f5adfa939ed6481106c2507",
|
||||
"bytes": 32900
|
||||
},
|
||||
"low-02.mpegts": {
|
||||
"sha256": "3dc93f9f0fd065678b01fc6c568c12350e9ab0b324d1e0fa0e9f77787da7e7d2",
|
||||
"bytes": 30268
|
||||
},
|
||||
"low-03.mpegts": {
|
||||
"sha256": "d94c8913f7fde98161550041287529b90a58d537d6083d4382540c5fce0b430b",
|
||||
"bytes": 32336
|
||||
},
|
||||
"low-04.mpegts": {
|
||||
"sha256": "11596c59b547c2ff23bbba1f70e15f061a3e87bbdc1f4f59daa9b5279a6003af",
|
||||
"bytes": 29704
|
||||
},
|
||||
"low-05.mpegts": {
|
||||
"sha256": "5c6ec25a69fdad73a23cfbc34f0f3c6d8abe44592650c92fa9110a38d6bd9e45",
|
||||
"bytes": 29516
|
||||
},
|
||||
"low.m3u8": {
|
||||
"sha256": "404a8d868ed2af3598269aee89caba0563c2805dac223fe4c00c9a9eda73554f",
|
||||
"bytes": 305
|
||||
},
|
||||
"master.m3u8": {
|
||||
"sha256": "f226bd38f3c76714ada2b5a6866ad1cb39b041023e4a18d971d175f26f917e38",
|
||||
"bytes": 470
|
||||
},
|
||||
"video-only.m3u8": {
|
||||
"sha256": "3d7bb9069f6218191dc65745389bf1ab0ba6d5ed66cbd14e2cf30211913da3fc",
|
||||
"bytes": 108
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,URI="english.m3u8"
|
||||
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="French",LANGUAGE="fr",DEFAULT=NO,AUTOSELECT=YES,URI="french.m3u8"
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=200000,RESOLUTION=160x90,CODECS="avc1.42c01e,mp4a.40.2",AUDIO="audio"
|
||||
low.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=320x180,CODECS="avc1.42c01e,mp4a.40.2",AUDIO="audio"
|
||||
high.m3u8
|
||||
@@ -0,0 +1,4 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=200000,RESOLUTION=160x90,CODECS="avc1.42c01e"
|
||||
low.m3u8
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import fs from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { loadPackage } from './load.js'
|
||||
|
||||
export async function hlsImplementations() {
|
||||
const { release } = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/hls-control-release.json', import.meta.url)))
|
||||
const archive = await ensureArchive(release)
|
||||
const implementations = [{ name: 'source', factory: (await loadPackage(release.name)).default }]
|
||||
for (const format of ['main', 'legacy', 'module']) {
|
||||
const member = `package/${release.manifest[format].replace(/^\.\//, '')}`
|
||||
const bytes = readMember(archive, member)
|
||||
assert.equal(hash(bytes), release.files[member])
|
||||
let factory
|
||||
if (format === 'module') {
|
||||
factory = (await import(`data:text/javascript;base64,${Buffer.from(bytes).toString('base64')}`)).default
|
||||
}
|
||||
else {
|
||||
const context = { module: { exports: {} } }
|
||||
context.exports = context.module.exports
|
||||
vm.runInNewContext(bytes.toString(), context, { timeout: 5000 })
|
||||
factory = context.module.exports
|
||||
}
|
||||
assert.equal(typeof factory, 'function')
|
||||
implementations.push({ name: `published-${format}`, factory })
|
||||
}
|
||||
return implementations
|
||||
}
|
||||
|
||||
// Controlled SDK and registry host: no DOM rendering, decoding or ABR simulation.
|
||||
export function hlsHost() {
|
||||
const listeners = new Map()
|
||||
const calls = []
|
||||
const controls = new Map()
|
||||
const settings = new Map()
|
||||
const video = {}
|
||||
const art = {
|
||||
template: { $video: video },
|
||||
constructor: { utils: { errorHandle: (condition, message) => {
|
||||
if (!condition)
|
||||
throw new Error(message)
|
||||
} } },
|
||||
on(event, callback) {
|
||||
if (!listeners.has(event))
|
||||
listeners.set(event, new Set())
|
||||
listeners.get(event).add(callback)
|
||||
return art
|
||||
},
|
||||
off(event, callback) {
|
||||
listeners.get(event)?.delete(callback)
|
||||
return art
|
||||
},
|
||||
emit(event, ...args) {
|
||||
for (const callback of [...(listeners.get(event) || [])])
|
||||
callback(...args)
|
||||
return art
|
||||
},
|
||||
notice: {},
|
||||
}
|
||||
let notice = ''
|
||||
Object.defineProperty(art.notice, 'show', {
|
||||
get: () => notice,
|
||||
set: (value) => {
|
||||
calls.push(['notice', value])
|
||||
notice = value
|
||||
},
|
||||
})
|
||||
for (const [name, entries] of [['controls', controls], ['setting', settings]]) {
|
||||
art[name] = {
|
||||
update(item) {
|
||||
calls.push([`${name}.update`, item.name])
|
||||
entries.set(item.name, item)
|
||||
},
|
||||
remove(nameToRemove) {
|
||||
calls.push([`${name}.remove`, nameToRemove])
|
||||
entries.delete(nameToRemove)
|
||||
},
|
||||
check(item) { calls.push([`${name}.check`, item]) },
|
||||
}
|
||||
}
|
||||
const hls = {
|
||||
media: video,
|
||||
levels: [{ height: 360 }, { height: 720 }, { height: 1080 }],
|
||||
audioTracks: [{ id: 0, name: 'English' }, { id: 1, lang: 'fr' }],
|
||||
}
|
||||
for (const [key, initial] of [['currentLevel', -1], ['audioTrack', 0]]) {
|
||||
let value = initial
|
||||
Object.defineProperty(hls, key, {
|
||||
configurable: true,
|
||||
get: () => value,
|
||||
set: (next) => {
|
||||
calls.push([key, next])
|
||||
value = next
|
||||
},
|
||||
})
|
||||
}
|
||||
art.hls = hls
|
||||
return { art, hls, calls, controls, settings, listeners }
|
||||
}
|
||||
|
||||
export const bothMenus = () => ({ quality: { control: true, setting: true }, audio: { control: true, setting: true } })
|
||||
export const selections = menu => Array.from(menu.selector, item => [item.html, item.value, item.default])
|
||||
@@ -0,0 +1,221 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- The repo uses Node's runner for controlled contracts.
|
||||
import { test } from 'node:test'
|
||||
import { bothMenus, hlsHost, hlsImplementations, selections } from './helpers/hls-control.js'
|
||||
|
||||
const implementations = await hlsImplementations()
|
||||
for (const { name, factory } of implementations) {
|
||||
test(`${name}: optional factory, synchronous result and deferred core events`, () => {
|
||||
const { art, controls, calls, listeners } = hlsHost()
|
||||
const empty = factory()(art)
|
||||
assert.deepEqual(Object.keys(empty).sort(), ['name', 'update'])
|
||||
assert.equal(empty.name, 'artplayerPluginHlsControl')
|
||||
assert.equal(empty.update(), undefined)
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(listeners.get('ready').has(empty.update), true)
|
||||
assert.equal(listeners.get('restart').has(empty.update), true)
|
||||
const plugin = factory(bothMenus())(art)
|
||||
assert.equal(controls.size, 0)
|
||||
art.emit('ready')
|
||||
assert.equal(controls.size, 2)
|
||||
calls.length = 0
|
||||
art.emit('restart', 'next.m3u8')
|
||||
assert.deepEqual(calls.map(call => call[0]), ['controls.update', 'setting.update', 'controls.update', 'setting.update'])
|
||||
assert.equal(plugin.update(), undefined)
|
||||
})
|
||||
|
||||
test(`${name}: missing or foreign Hls fails before any menu changes`, () => {
|
||||
const { art, calls, hls } = hlsHost()
|
||||
const plugin = factory(bothMenus())(art)
|
||||
for (const invalid of [undefined, null, {}, { media: {} }]) {
|
||||
art.hls = invalid
|
||||
assert.throws(() => plugin.update(), { message: 'Cannot find instance of HLS from "art.hls"' })
|
||||
assert.equal(calls.length, 0)
|
||||
}
|
||||
art.hls = hls
|
||||
plugin.update()
|
||||
assert.equal(calls.length, 4)
|
||||
})
|
||||
|
||||
test(`${name}: quality and audio retain names, ordering, defaults and UI fields`, () => {
|
||||
const { art, hls, controls, settings } = hlsHost()
|
||||
hls.levels = [{ name: 'SD', height: 360 }, { height: 720 }, { name: 'SD', height: 480 }, { height: 1080 }]
|
||||
hls.audioTracks.push({ id: 2, language: 'de' }, { id: 3, name: 'English' })
|
||||
const plugin = factory(bothMenus())(art)
|
||||
plugin.update()
|
||||
assert.deepEqual(selections(controls.get('hls-quality')), [['1080P', 3, false], ['720P', 1, false], ['SD', 0, false], ['Auto', -1, true]])
|
||||
assert.deepEqual(selections(controls.get('hls-audio')), [['English', 0, true], ['fr', 1, false], ['de', 2, false]])
|
||||
for (const [key, title] of [['hls-quality', 'Quality'], ['hls-audio', 'Audio']]) {
|
||||
const control = controls.get(key)
|
||||
const setting = settings.get(key)
|
||||
assert.equal(control.position, 'right')
|
||||
assert.equal(control.style.padding, '0 10px')
|
||||
assert.equal(setting.width, 200)
|
||||
assert.match(setting.icon, /<svg/)
|
||||
assert.equal(setting.html, title)
|
||||
assert.equal(setting.tooltip, control.html)
|
||||
assert.equal(setting.selector, control.selector)
|
||||
assert.equal(setting.onSelect, control.onSelect)
|
||||
}
|
||||
})
|
||||
|
||||
test(`${name}: selection writes SDK then notice then checks the same item`, () => {
|
||||
for (const [key, field, title] of [['hls-quality', 'currentLevel', 'Quality'], ['hls-audio', 'audioTrack', 'Audio']]) {
|
||||
const { art, calls, controls, hls } = hlsHost()
|
||||
factory(bothMenus())(art).update()
|
||||
const menu = controls.get(key)
|
||||
const item = menu.selector[1]
|
||||
calls.length = 0
|
||||
assert.equal(menu.onSelect(item), item.html)
|
||||
assert.equal(hls[field], item.value)
|
||||
assert.deepEqual(calls, [[field, item.value], ['notice', `${title}: ${item.html}`], ['controls.check', item], ['setting.check', item]])
|
||||
if (key === 'hls-quality') {
|
||||
assert.equal(menu.onSelect(menu.selector.at(-1)), 'Auto')
|
||||
assert.equal(hls.currentLevel, -1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test(`${name}: getName receives original SDK item and optional map index without binding`, () => {
|
||||
const { art, hls, controls } = hlsHost()
|
||||
hls.currentLevel = 1
|
||||
const invocations = []
|
||||
const option = bothMenus()
|
||||
option.quality.getName = function (...args) {
|
||||
invocations.push({ context: this, args })
|
||||
return `Level ${args[0].height}`
|
||||
}
|
||||
factory(option)(art).update()
|
||||
assert.equal(invocations.length, 4)
|
||||
assert.equal(invocations[0].args.length, 1)
|
||||
assert.equal(invocations[0].args[0], hls.levels[1])
|
||||
for (let index = 0; index < 3; index++) {
|
||||
assert.equal(invocations[index + 1].context, undefined)
|
||||
assert.equal(invocations[index + 1].args[0], hls.levels[index])
|
||||
assert.equal(invocations[index + 1].args[1], index)
|
||||
}
|
||||
assert.equal(controls.get('hls-quality').html, 'Level 720')
|
||||
})
|
||||
|
||||
test(`${name}: callback failure is synchronous and later update can recover`, () => {
|
||||
const { art, calls, controls } = hlsHost()
|
||||
const failure = new Error('consumer callback failure')
|
||||
const option = bothMenus()
|
||||
option.audio.getName = () => {
|
||||
throw failure
|
||||
}
|
||||
const plugin = factory(option)(art)
|
||||
assert.throws(() => plugin.update(), error => error === failure)
|
||||
assert.deepEqual(calls.map(call => call[1]), ['hls-quality', 'hls-quality'])
|
||||
assert.equal(controls.has('hls-audio'), false)
|
||||
delete option.audio.getName
|
||||
assert.equal(plugin.update(), undefined)
|
||||
assert.equal(controls.has('hls-audio'), true)
|
||||
})
|
||||
|
||||
test(`${name}: rejected SDK selection propagates before notice or checks`, () => {
|
||||
for (const [key, field] of [['hls-quality', 'currentLevel'], ['hls-audio', 'audioTrack']]) {
|
||||
const { art, hls, controls, calls } = hlsHost()
|
||||
factory(bothMenus())(art).update()
|
||||
const failure = new Error('SDK selection rejected')
|
||||
const selected = hls[field]
|
||||
Object.defineProperty(hls, field, {
|
||||
get: () => selected,
|
||||
set() {
|
||||
throw failure
|
||||
},
|
||||
})
|
||||
calls.length = 0
|
||||
const menu = controls.get(key)
|
||||
assert.throws(() => menu.onSelect(menu.selector[0]), error => error === failure)
|
||||
assert.equal(calls.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test(`${name}: individual surfaces, translated labels and falsy defaults`, () => {
|
||||
for (const surface of ['control', 'setting']) {
|
||||
const { art, controls, settings, calls } = hlsHost()
|
||||
const options = { quality: { [surface]: true, title: 'Definition', auto: 'Automatic' }, audio: { [surface]: true, title: '', auto: '' } }
|
||||
factory(options)(art).update()
|
||||
const active = surface === 'control' ? controls : settings
|
||||
assert.equal((surface === 'control' ? settings : controls).size, 0)
|
||||
const menu = active.get('hls-quality')
|
||||
assert.equal(menu.selector.at(-1).html, 'Automatic')
|
||||
calls.length = 0
|
||||
menu.onSelect(menu.selector[0])
|
||||
assert.equal(art.notice.show, 'Definition: 1080P')
|
||||
assert.deepEqual(calls.map(call => call[0]), ['currentLevel', 'notice', `${surface === 'control' ? 'controls' : 'setting'}.check`])
|
||||
active.get('hls-audio').onSelect(active.get('hls-audio').selector[0])
|
||||
assert.equal(art.notice.show, 'Audio: English')
|
||||
}
|
||||
})
|
||||
|
||||
test(`${name}: update reads replacement engine and new list indexes`, () => {
|
||||
const host = hlsHost()
|
||||
const plugin = factory(bothMenus())(host.art)
|
||||
plugin.update()
|
||||
const replacement = hlsHost().hls
|
||||
replacement.media = host.art.template.$video
|
||||
replacement.levels = [{ height: 144 }, { height: 240 }]
|
||||
replacement.currentLevel = 1
|
||||
host.art.hls = replacement
|
||||
host.art.emit('restart')
|
||||
const menu = host.controls.get('hls-quality')
|
||||
assert.deepEqual(selections(menu), [['240P', 1, true], ['144P', 0, false], ['Auto', -1, false]])
|
||||
menu.onSelect(menu.selector[1])
|
||||
assert.equal(replacement.currentLevel, 0)
|
||||
assert.equal(host.hls.currentLevel, -1)
|
||||
})
|
||||
}
|
||||
|
||||
// Published-only observations freeze bugs, not requirements for the new implementation.
|
||||
for (const { name, factory } of implementations.filter(item => item.name.startsWith('published-'))) {
|
||||
test(`${name}: historical empty topology and disabled flags retain stale menus`, () => {
|
||||
const { art, hls, controls, settings } = hlsHost()
|
||||
const option = bothMenus()
|
||||
const plugin = factory(option)(art)
|
||||
plugin.update()
|
||||
const old = controls.get('hls-quality')
|
||||
hls.levels = []
|
||||
hls.audioTracks = []
|
||||
plugin.update()
|
||||
assert.equal(controls.get('hls-quality'), old)
|
||||
assert.equal(settings.size, 2)
|
||||
hls.levels = [{ height: 240 }]
|
||||
option.quality.control = false
|
||||
option.quality.setting = false
|
||||
plugin.update()
|
||||
assert.equal(controls.get('hls-quality'), old)
|
||||
})
|
||||
|
||||
test(`${name}: historical callbacks still write old engines after replacement and destroy`, () => {
|
||||
const { art, hls, controls, calls } = hlsHost()
|
||||
const plugin = factory(bothMenus())(art)
|
||||
plugin.update()
|
||||
const old = controls.get('hls-quality')
|
||||
const replacement = hlsHost().hls
|
||||
replacement.media = art.template.$video
|
||||
art.hls = replacement
|
||||
plugin.update()
|
||||
old.onSelect(old.selector[0])
|
||||
assert.equal(hls.currentLevel, 2)
|
||||
assert.equal(replacement.currentLevel, -1)
|
||||
art.emit('destroy')
|
||||
calls.length = 0
|
||||
plugin.update()
|
||||
assert.equal(calls.length, 4, 'Retained update remains active after core destroy event')
|
||||
})
|
||||
|
||||
test(`${name}: historical Auto label follows playing level, duplicate default is lost`, () => {
|
||||
const { art, hls, controls } = hlsHost()
|
||||
hls.autoLevelEnabled = true
|
||||
hls.currentLevel = 1
|
||||
const plugin = factory(bothMenus())(art)
|
||||
plugin.update()
|
||||
assert.equal(controls.get('hls-quality').html, '720P')
|
||||
assert.equal(controls.get('hls-quality').selector.at(-1).default, false)
|
||||
hls.levels = [{ height: 720 }, { height: 720 }]
|
||||
plugin.update()
|
||||
assert.equal(controls.get('hls-quality').selector.some(item => item.default), false)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user