test(hls): [PKG-HLS-02] cover released contracts and real SDK playback

This commit is contained in:
Harvey Zhao committed 2026-09-11 23:53:16 +08:00
1 parent cbbdc03a7c
commit 33f6d19b77
55 files changed
+8678 -14

No files matched your search

+2
View File
@@ -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
View File
@@ -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",
+21
View File
@@ -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 改字节。
+1 -1
View File
@@ -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 |
+57
View File
@@ -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
View File
@@ -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)
+8
View File
@@ -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 或浏览器通过。
+5 -1
View File
@@ -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
View File
@@ -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 能力检查关闭。"
}
]
}
+1 -1
View File
@@ -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)
+8 -1
View File
@@ -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
View File
@@ -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",
+3 -2
View File
@@ -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"
},
{
+31
View File
@@ -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}`)
+7
View File
@@ -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.
+6
View File
@@ -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.
+236
View File
@@ -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())
})
}
})
+16
View File
@@ -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.
+20
View File
@@ -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.
+20
View File
@@ -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.
+18
View File
@@ -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.
+18
View File
@@ -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
+269
View File
@@ -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
}
}
}
+8
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=200000,RESOLUTION=160x90,CODECS="avc1.42c01e"
low.m3u8
+104
View File
@@ -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])
+221
View File
@@ -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)
})
}