test(hls): [PKG-HLS-SDK-01] preserve group stall evidence and verify ongoing playback

This commit is contained in:
Harvey Zhao committed 2026-09-15 15:02:14 +08:00
1 parent f37658d657
commit ca967ea1a2
9 files changed
+38631 -19

No files matched your search

@@ -135,6 +135,16 @@ HTTP without ArtPlayer/plugin or a Worker observer still reproduced crashes with
both reset-first and SDK-first teardown. A passing no-worker control does not both reset-first and SDK-first teardown. A passing no-worker control does not
replace worker acceptance. See the 2026-09-14 HTTP teardown checkpoint in refactor. replace worker acceptance. See the 2026-09-14 HTTP teardown checkpoint in refactor.
For the separate grouped-playback stall, `--switch-boundary switched|selected|immediate`
compares completed SDK audio switching, selected UI/getter state, and same-call audio/level
selection. The default remains switched. `--prefill` adds a controlled fully buffered
fixture condition; it is not a player workaround. Both diagnostic and integration paths
now require the clock and frame count to advance at the target height after high/low
switches. Keep the original 7000ms height assertion and the failed trace even when a
later control passes. Diagnostic private SDK fields are read-only, version-specific;
never read load-level getters after SDK destruction. See the 2026-09-15 switch-boundary
checkpoint in refactor for the reproduced empty-buffer failure and remaining attribution.
## Package file boundary ## Package file boundary
The package .npmignore excludes src and tsconfig.json. The implementation config The package .npmignore excludes src and tsconfig.json. The implementation config
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,75 @@
# PKG-HLS-SDK-01 分组切换边界与持续播放
来源 HEAD:`f37658d657dc89f4a1597ebd73b311529fc8e012`。任务仍为 doing,
HLS-PLAYBACK-01 和独立的 HLS-CRASH-01 均未关闭。本轮没有生产源码、类型、
依赖、媒体夹具或分发产物修改,没有改变 SDK 所有权或消费者配置。
## 本轮取得的失败证据
原分组集成场景再次复现 Firefox 155.0、Hls.js 1.7.2、发布核心、候选插件组合失败。
SDK 音轨已为 high 组 index 0,但视频高度仍为 90,播放位置停在 0.999146,
paused=false、ended=false、readyState=2、buffered=[]。原 7000ms 高度断言失败,
发生在本次新增持续播放断言之前。该轮四项测试为三通过、一失败,不删失败或重试。
该失败的 SDK error 数组为空。独立 trace 的 42 条媒体夹具请求均返回 200,
最终切到 high 组后继续请求 French 音频,但没有再次请求 high 视频片段。
早期自动选择阶段曾请求 high-01 至 high-05,不能写成整个测试从未请求 high 视频。
这些事实将下一步收窄到加载/缓冲状态转换,但尚不足以认定 SDK 或核心的根因。
失败报告和 trace 保留在独立的 hls-boundary-suite 路径,后续测试使用各自的输出
目录。机器证据包含原始报告指纹、失败附件及从 trace 提取的完整夹具请求序列。
较早 hls-sdk-validation.json 的历史失败保持原样。
## 验证补强
`test/browser/hls-sdk.spec.js` 原先返回 low 后只验证音轨数量与菜单。现在 high 和
返回 low 均要求未暂停、目标高度、媒体时钟推进超过 0.3 秒、额外帧数超过两帧。
没有额外 play/seek/reload,也没有扩大已有断言超时。检查新增观察和播放时间,
因此不能把本轮销毁成功用于关闭此前独立的 Firefox 销毁崩溃。
新增失败现场的 SDK 控制器状态、nextLoadPosition、独立缓冲区及加载/flush 事件。
这些私有字段仅供固定 SDK 的只读诊断,不作为生产依赖或跨版本公共契约。
SDK 销毁后不读取 currentLevel/loadLevel/nextLoadLevel:初版采集在成功销毁后读取
nextLoadLevel 时出现失效 getter,导致诊断附件只有 diagnosticError。该两轮原始
报告保留,最终轮明确检查附件没有 diagnosticError;不把它们当完整状态证据。
## 可重跑对照
`refactor/scripts/hls-sdk-diagnostic.mjs` 增加三个音轨/分组边界:
- `--switch-boundary switched`:默认,保持等待 AUDIO_TRACK_SWITCHED。
- `--switch-boundary selected`:插件宿主等待 UI 显示 French,直接宿主检查 audioTrack=1。
- `--switch-boundary immediate`:同一次页面调用内选择音轨后立即选择 high。
`--prefill` 等待单个原生 buffered 区间到达 duration-0.1;固定夹具视频 12 秒、
音频 12.032 秒,因此允许编码尾部差异。它在初次选择 low 前等待,非 immediate
模式还在选择 high 前等待。该受控对照不修改默认播放器,不是修复或延时兜底。
```sh
node refactor/scripts/hls-sdk-diagnostic.mjs --version 1.7.2 --iterations 3 --switch-boundary immediate --capture-before-destroy
node refactor/scripts/hls-sdk-diagnostic.mjs --version 1.7.2 --iterations 2 --host published --plugin --switch-boundary selected --transport route --observe-workers --capture-before-destroy
node refactor/scripts/hls-sdk-diagnostic.mjs --version 1.7.2 --iterations 2 --switch-boundary selected --prefill --capture-before-destroy
yarn test:browser hls-sdk.spec.js --grep "group changes" --workers=1
```
19 个可读取的诊断迭代通过:早期三种宿主/边界九次;加入双向持续播放后的直接
三次、发布核心两次、候选核心两次、prefill 两次、默认 switched 一次,共十次。
全部使用 Windows Firefox 155.0 和固定 Hls.js 1.7.2。直接模式不加载 ArtPlayer 或
插件。诊断宿主使用报告中哈希的既有 dist,集成用例使用源码选择的候选构建;
本轮不声称重新构建或隔离安装了这些 dist。
最初两轮共八次只打印 passed 后便在 JSON 序列化阶段失败,不能计入上述十九次。
原因是新增 LEVEL_SWITCHING 的 details 为带循环引用的对象;已限定采集标量字段。
不是播放器失败,也没有把未写出的报告认定为成功。
## 最终验证与剩余
最终三引擎分组用例八通过、四 Windows WebKit MSE 能力跳过,无重试;SDK 1.5.17
与 1.7.2、新旧核心均包括在有 MSE 的八项中。原失败之后的中间轮另行保存,不能
用最终通过覆盖或关闭原故障。定向 lint、固定 SDK 来源单测、严格工具链通过。
Node 24.21.0、Yarn Classic 1.22.22;无新依赖,无重打包或远端执行。
见 [机器证据](../baselines/hls-switch-boundary-validation.json)。完整目标仍需要解释
空缓冲且不再请求视频的路径,结合新增控制器/缓冲事件建立更小的可重复案例,
再决定归属与兼容修复;单次或多次通过不能关闭风险。Safari/真机/全部分发与
三轮发布复盘仍按原门槛执行。回退本提交仅移除诊断和验收增强,不涉及运行时。
+1 -1
View File
@@ -569,7 +569,7 @@
- PKG-HLS-02: [记录](hls-validation.md) [记录](baselines/hls-sdk.json) [记录](baselines/hls-validation.json) [记录](changes/2026-09-11-PKG-HLS-02-tests.md) - PKG-HLS-02: [记录](hls-validation.md) [记录](baselines/hls-sdk.json) [记录](baselines/hls-validation.json) [记录](changes/2026-09-11-PKG-HLS-02-tests.md)
- PKG-HLS-03: [记录](changes/2026-09-12-PKG-HLS-03-modules.md) [记录](baselines/hls-modules-validation.json) [记录](hls-validation.md) - PKG-HLS-03: [记录](changes/2026-09-12-PKG-HLS-03-modules.md) [记录](baselines/hls-modules-validation.json) [记录](hls-validation.md)
- PKG-HLS-04: [记录](changes/2026-09-12-PKG-HLS-04-types.md) [记录](baselines/hls-types-validation.json) [记录](hls-validation.md) - PKG-HLS-04: [记录](changes/2026-09-12-PKG-HLS-04-types.md) [记录](baselines/hls-types-validation.json) [记录](hls-validation.md)
- PKG-HLS-SDK-01: [记录](changes/2026-09-12-PKG-HLS-SDK-01-integration.md) [记录](baselines/hls-sdk-validation.json) [记录](baselines/hls-sdk-diagnostics.json) [记录](changes/2026-09-14-PKG-HLS-SDK-01-http-teardown.md) [记录](baselines/hls-http-teardown-validation.json) - PKG-HLS-SDK-01: [记录](changes/2026-09-12-PKG-HLS-SDK-01-integration.md) [记录](baselines/hls-sdk-validation.json) [记录](baselines/hls-sdk-diagnostics.json) [记录](changes/2026-09-14-PKG-HLS-SDK-01-http-teardown.md) [记录](baselines/hls-http-teardown-validation.json) [记录](changes/2026-09-15-PKG-HLS-SDK-01-switch-boundary.md) [记录](baselines/hls-switch-boundary-validation.json)
- PKG-DASH-01: [记录](changes/2026-09-12-PKG-DASH-01-contract.md) [记录](baselines/dash-control-contract.md) [记录](baselines/dash-control-release.json) - PKG-DASH-01: [记录](changes/2026-09-12-PKG-DASH-01-contract.md) [记录](baselines/dash-control-contract.md) [记录](baselines/dash-control-release.json)
- PKG-DASH-02: [记录](changes/2026-09-12-PKG-DASH-02-tests.md) [记录](dash-validation.md) [记录](baselines/dash-validation.json) - PKG-DASH-02: [记录](changes/2026-09-12-PKG-DASH-02-tests.md) [记录](dash-validation.md) [记录](baselines/dash-validation.json)
- PKG-DASH-03: [记录](changes/2026-09-12-PKG-DASH-03-runtime.md) [记录](baselines/dash-runtime.json) [记录](dash-validation.md) - PKG-DASH-03: [记录](changes/2026-09-12-PKG-DASH-03-runtime.md) [记录](baselines/dash-runtime.json) [记录](dash-validation.md)
+14
View File
@@ -1,5 +1,19 @@
# 进度与证据 # 进度与证据
## PKG-HLS-SDK-01 分组切换与持续播放
再次复现 Firefox/Hls.js 1.7.2/发布核心分组切换失败:音轨已更新,视频仍90高、
约1秒、未暂停且缓冲区为空。独立trace中最终切组后没有新的视频片段请求,
42条媒体请求均200;不是仅有菜单或网络错误的推断。保留三通过一失败的原轮。
增加音轨选择/完成/同调用切组及整段缓冲对照,双向档位切换要求真实时钟和帧
增长;19次有效诊断通过不能解释失败。修正两处诊断采集问题,失败报告独立保留。
最终分组矩阵8通过、4 Windows WebKit MSE跳过;lint、来源单测和工具链通过。
见[记录](changes/2026-09-15-PKG-HLS-SDK-01-switch-boundary.md)及
[证据](baselines/hls-switch-boundary-validation.json)。任务和两项风险继续开放,
总数203 done、21 doing、43 todo不变。后续围绕控制器/分轨缓冲状态定位,
不更改消费者SDK参数或以反复通过宣布修复;未推送、部署或发布。
## PKG-ASR-05 首次媒体失败与恢复 ## PKG-ASR-05 首次媒体失败与恢复
首次metadata之前的原生媒体失败、play拒绝、零音频资源、stop/destroy以及首个 首次metadata之前的原生媒体失败、play拒绝、零音频资源、stop/destroy以及首个
+3 -1
View File
@@ -2832,7 +2832,9 @@
"evidence": [ "evidence": [
"test/browser/hls-sdk.spec.js", "test/browser/hls-sdk.spec.js",
"refactor/changes/2026-09-12-PKG-HLS-SDK-01-integration.md", "refactor/changes/2026-09-12-PKG-HLS-SDK-01-integration.md",
"refactor/baselines/hls-sdk-validation.json" "refactor/baselines/hls-sdk-validation.json",
"refactor/changes/2026-09-15-PKG-HLS-SDK-01-switch-boundary.md",
"refactor/baselines/hls-switch-boundary-validation.json"
], ],
"compatibleResolution": "区分 SDK、测试媒体/时序和宿主问题,保留真实档位/解码断言及 trace;不扩大等待或只验证菜单。", "compatibleResolution": "区分 SDK、测试媒体/时序和宿主问题,保留真实档位/解码断言及 trace;不扩大等待或只验证菜单。",
"closureCriteria": "直接 Hls 与新旧核心对照定位切换停滞,验证处置和正常/连续组切换,不能以重复通过关闭。" "closureCriteria": "直接 Hls 与新旧核心对照定位切换停滞,验证处置和正常/连续组切换,不能以重复通过关闭。"
+65 -14
View File
@@ -10,13 +10,14 @@ import { observeWorkers } from '../../test/helpers/worker-observer.js'
import { ensureArchive, hash, readMember, refactorDir } from './releases.mjs' import { ensureArchive, hash, readMember, refactorDir } from './releases.mjs'
// Direct SDK control: no ArtPlayer or plugin is loaded, and no worker output is substituted. // Direct SDK control: no ArtPlayer or plugin is loaded, and no worker output is substituted.
export async function diagnose({ version = '1.7.2', iterations = 5, transport = 'http', teardown = 'reset-first', worker = true, host = 'direct', plugin = false, sdkLogs = false, workerObserver = false, captureBeforeDestroy = false } = {}) { export async function diagnose({ version = '1.7.2', iterations = 5, transport = 'http', teardown = 'reset-first', worker = true, host = 'direct', plugin = false, sdkLogs = false, workerObserver = false, captureBeforeDestroy = false, switchBoundary = 'switched', prefill = false } = {}) {
const runnerSHA256 = hash(fs.readFileSync(new URL(import.meta.url))) const runnerSHA256 = hash(fs.readFileSync(new URL(import.meta.url)))
const observerSHA256 = hash(fs.readFileSync(new URL('../../test/helpers/worker-observer.js', import.meta.url))) const observerSHA256 = hash(fs.readFileSync(new URL('../../test/helpers/worker-observer.js', import.meta.url)))
assert(Number.isInteger(iterations) && iterations > 0 && iterations <= 50) assert(Number.isInteger(iterations) && iterations > 0 && iterations <= 50)
assert(['http', 'route'].includes(transport)) assert(['http', 'route'].includes(transport))
assert(['reset-first', 'sdk-first'].includes(teardown)) assert(['reset-first', 'sdk-first'].includes(teardown))
assert(['direct', 'published', 'candidate'].includes(host)) assert(['direct', 'published', 'candidate'].includes(host))
assert(['switched', 'selected', 'immediate'].includes(switchBoundary))
assert(!plugin || host !== 'direct', 'Plugin comparison requires an ArtPlayer host') assert(!plugin || host !== 'direct', 'Plugin comparison requires an ArtPlayer host')
const matrix = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/hls-sdk-matrix.json'))) const matrix = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/hls-sdk-matrix.json')))
const release = matrix.releases.find(item => item.version === version) const release = matrix.releases.find(item => item.version === version)
@@ -98,7 +99,7 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
let art let art
const records = [] const records = []
const buffer = ranges => Array.from({ length: ranges.length }, (_, index) => [ranges.start(index), ranges.end(index)]) const buffer = ranges => Array.from({ length: ranges.length }, (_, index) => [ranges.start(index), ranges.end(index)])
const state = () => ({ time: video.currentTime, readyState: video.readyState, height: video.videoHeight, paused: video.paused, buffered: buffer(video.buffered), seekable: buffer(video.seekable) }) const state = () => ({ time: video.currentTime, readyState: video.readyState, height: video.videoHeight, paused: video.paused, frames: video.getVideoPlaybackQuality().totalVideoFrames, buffered: buffer(video.buffered), seekable: buffer(video.seekable) })
const record = (event, data) => { const record = (event, data) => {
records.push({ at: performance.now(), event, data, media: state() }) records.push({ at: performance.now(), event, data, media: state() })
if (records.length > 400) if (records.length > 400)
@@ -108,8 +109,8 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
function attach(element) { function attach(element) {
video = element video = element
hls = new window.Hls({ enableWorker: worker, startLevel: 0, debug: sdkLogs ? logger : false }) hls = new window.Hls({ enableWorker: worker, startLevel: 0, debug: sdkLogs ? logger : false })
for (const key of ['ERROR', 'AUDIO_TRACKS_UPDATED', 'AUDIO_TRACK_SWITCHED', 'LEVEL_SWITCHED', 'FRAG_BUFFERED', 'BUFFER_FLUSHED']) { for (const key of ['ERROR', 'AUDIO_TRACKS_UPDATED', 'AUDIO_TRACK_SWITCHING', 'AUDIO_TRACK_SWITCHED', 'LEVEL_SWITCHING', 'LEVEL_SWITCHED', 'FRAG_BUFFERED', 'BUFFER_FLUSHED']) {
hls.on(window.Hls.Events[key], (_, data) => record(key, { id: data.id, level: data.level, fatal: data.fatal, details: data.details, frag: data.frag && { type: data.frag.type, sn: data.frag.sn, level: data.frag.level }, tracks: data.audioTracks?.map(track => ({ id: track.id, name: track.name, group: track.groupId })) })) hls.on(window.Hls.Events[key], (_, data) => record(key, { id: data.id, level: typeof data.level === 'number' ? data.level : undefined, fatal: data.fatal, details: typeof data.details === 'string' ? data.details : undefined, frag: data.frag && { type: data.frag.type, sn: data.frag.sn, level: data.frag.level }, tracks: data.audioTracks?.map(track => ({ id: track.id, name: track.name, group: track.groupId })) }))
} }
for (const name of ['waiting', 'stalled', 'seeking', 'seeked', 'playing', 'pause', 'ended', 'loadedmetadata', 'canplay', 'error']) for (const name of ['waiting', 'stalled', 'seeking', 'seeked', 'playing', 'pause', 'ended', 'loadedmetadata', 'canplay', 'error'])
video.addEventListener(name, () => record(name)) video.addEventListener(name, () => record(name))
@@ -143,6 +144,7 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
get hls() { return hls }, get hls() { return hls },
get video() { return video }, get video() { return video },
records, records,
record,
state, state,
destroy() { destroy() {
record('destroy:start', teardown) record('destroy:start', teardown)
@@ -170,21 +172,60 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
await wait(() => window.direct.video?.readyState >= 3) await wait(() => window.direct.video?.readyState >= 3)
await page.evaluate(() => window.direct.video.play()) await page.evaluate(() => window.direct.video.play())
await wait(() => window.direct.video.currentTime > 0.3) await wait(() => window.direct.video.currentTime > 0.3)
if (prefill) {
entry.phase = 'initial-prefill'
await wait(() => {
const video = window.direct.video
return video.buffered.length === 1 && video.buffered.end(0) >= video.duration - 0.1
})
}
entry.phase = 'low-group' entry.phase = 'low-group'
await page.evaluate(() => { await page.evaluate(() => {
window.direct.hls.currentLevel = 0 window.direct.hls.currentLevel = 0
}) })
await wait(() => window.direct.hls.audioTracks.length === 2 && window.direct.hls.audioTracks.every(track => track.groupId === 'low')) await wait(() => window.direct.hls.audioTracks.length === 2 && window.direct.hls.audioTracks.every(track => track.groupId === 'low'))
await page.evaluate(() => { await page.evaluate((switchBoundary) => {
window.direct.mark = performance.now() window.direct.mark = performance.now()
window.direct.hls.audioTrack = 1 window.direct.hls.audioTrack = 1
}) if (switchBoundary === 'immediate') {
await wait(() => window.direct.records.some(item => item.at >= window.direct.mark && item.event === 'AUDIO_TRACK_SWITCHED' && item.data.id === 1)) window.direct.record('high-group:requested', { boundary: switchBoundary, audioTrack: window.direct.hls.audioTrack })
window.direct.hls.currentLevel = 1
}
}, switchBoundary)
if (switchBoundary === 'switched') {
await wait(() => window.direct.records.some(item => item.at >= window.direct.mark && item.event === 'AUDIO_TRACK_SWITCHED' && item.data.id === 1))
}
else if (switchBoundary === 'selected') {
if (plugin)
await expect(page.locator('.art-control-hls-audio .art-selector-value')).toHaveText('French', { timeout: 7000 })
else
await wait(() => window.direct.hls.audioTrack === 1)
}
entry.phase = 'high-group' entry.phase = 'high-group'
await page.evaluate(() => { if (switchBoundary !== 'immediate') {
window.direct.hls.currentLevel = 1 if (prefill) {
}) entry.phase = 'low-prefill'
await wait(() => {
const video = window.direct.video
return video.buffered.length === 1 && video.buffered.end(0) >= video.duration - 0.1
})
entry.phase = 'high-group'
}
await page.evaluate((switchBoundary) => {
window.direct.record('high-group:requested', { boundary: switchBoundary, audioTrack: window.direct.hls.audioTrack })
window.direct.hls.currentLevel = 1
}, switchBoundary)
}
await wait(() => window.direct.video.videoHeight === 180 && window.direct.hls.audioTrack === 0) await wait(() => window.direct.video.videoHeight === 180 && window.direct.hls.audioTrack === 0)
entry.phase = 'high-group-playback'
await page.evaluate(() => {
window.direct.playbackStart = window.direct.state()
})
await wait(() => {
const current = window.direct.state()
return !current.paused && current.height === 180 && current.time > window.direct.playbackStart.time + 0.3 && current.frames > window.direct.playbackStart.frames + 2
})
entry.highPlayback = await page.evaluate(() => ({ start: window.direct.playbackStart, end: window.direct.state() }))
entry.phase = 'commentary-track' entry.phase = 'commentary-track'
await page.evaluate(() => { await page.evaluate(() => {
window.direct.hls.audioTrack = 2 window.direct.hls.audioTrack = 2
@@ -195,6 +236,16 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
window.direct.hls.currentLevel = 0 window.direct.hls.currentLevel = 0
}) })
await wait(() => window.direct.hls.audioTracks.length === 2) await wait(() => window.direct.hls.audioTracks.length === 2)
entry.phase = 'return-low-playback'
await wait(() => window.direct.video.videoHeight === 90)
await page.evaluate(() => {
window.direct.playbackStart = window.direct.state()
})
await wait(() => {
const current = window.direct.state()
return !current.paused && current.height === 90 && current.time > window.direct.playbackStart.time + 0.3 && current.frames > window.direct.playbackStart.frames + 2
})
entry.lowPlayback = await page.evaluate(() => ({ start: window.direct.playbackStart, end: window.direct.state() }))
if (captureBeforeDestroy) { if (captureBeforeDestroy) {
entry.phase = 'capture-before-destroy' entry.phase = 'capture-before-destroy'
entry.beforeDestroy = await page.evaluate(() => ({ coreLoaded: typeof window.Artplayer, pluginLoaded: typeof window.artplayerPluginHlsControl, media: window.direct.state(), sdk: { version: window.Hls.version, workerEnabled: window.direct.hls.config.enableWorker, level: window.direct.hls.currentLevel, audioTrack: window.direct.hls.audioTrack }, events: window.direct.records })) entry.beforeDestroy = await page.evaluate(() => ({ coreLoaded: typeof window.Artplayer, pluginLoaded: typeof window.artplayerPluginHlsControl, media: window.direct.state(), sdk: { version: window.Hls.version, workerEnabled: window.direct.hls.config.enableWorker, level: window.direct.hls.currentLevel, audioTrack: window.direct.hls.audioTrack }, events: window.direct.records }))
@@ -214,7 +265,7 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
finally { finally {
entry.failurePhase = entry.status === 'failed' ? entry.phase : undefined entry.failurePhase = entry.status === 'failed' ? entry.phase : undefined
entry.phase = 'final-state' entry.phase = 'final-state'
entry.state = await page.evaluate(() => ({ coreLoaded: typeof window.Artplayer, pluginLoaded: typeof window.artplayerPluginHlsControl, workers: window.workerEvidence, media: window.direct?.state(), events: window.direct?.records, sdk: { version: window.Hls?.version, level: window.direct?.hls.currentLevel, audioTrack: window.direct?.hls.audioTrack } })).catch(error => ({ error: error.message })) entry.state = await page.evaluate(() => ({ coreLoaded: typeof window.Artplayer, pluginLoaded: typeof window.artplayerPluginHlsControl, workers: window.workerEvidence, media: window.direct?.state(), events: window.direct?.records, sdk: { version: window.Hls?.version, level: window.direct?.hls.currentLevel, audioTrack: window.direct?.hls.audioTrack, mainState: window.direct?.hls.streamController?.state, audioState: window.direct?.hls.audioStreamController?.state } })).catch(error => ({ error: error.message }))
entry.phase = 'trace-stop' entry.phase = 'trace-stop'
await context.tracing.stop({ path: path.join(directory, `${iteration}.zip`) }).catch((error) => { await context.tracing.stop({ path: path.join(directory, `${iteration}.zip`) }).catch((error) => {
entry.traceError = error.message entry.traceError = error.message
@@ -236,14 +287,14 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
await browser?.close() await browser?.close()
server.closeAllConnections() server.closeAllConnections()
await new Promise(resolve => server.close(resolve)) await new Promise(resolve => server.close(resolve))
fs.writeFileSync(path.join(directory, 'report.json'), `${JSON.stringify({ version, iterations, transport, teardown, worker, host, plugin, sdkLogs, workerObserver, captureBeforeDestroy, runnerSHA256, observerSHA256, browserVersion, release, inputs: Object.fromEntries([...files].map(([file, bytes]) => [file, hash(bytes)])), results }, null, 2)}\n`) fs.writeFileSync(path.join(directory, 'report.json'), `${JSON.stringify({ version, iterations, transport, teardown, worker, host, plugin, sdkLogs, workerObserver, captureBeforeDestroy, switchBoundary, prefill, runnerSHA256, observerSHA256, browserVersion, release, inputs: Object.fromEntries([...files].map(([file, bytes]) => [file, hash(bytes)])), results }, null, 2)}\n`)
console.log(`Diagnostic report: ${directory}`) console.log(`Diagnostic report: ${directory}`)
} }
return results return results
} }
const { values } = parseArgs({ options: { 'version': { type: 'string', default: '1.7.2' }, 'iterations': { type: 'string', default: '5' }, 'transport': { type: 'string', default: 'http' }, 'teardown': { type: 'string', default: 'reset-first' }, 'no-worker': { type: 'boolean', default: false }, 'host': { type: 'string', default: 'direct' }, 'plugin': { type: 'boolean', default: false }, 'sdk-logs': { type: 'boolean', default: false }, 'observe-workers': { type: 'boolean', default: false }, 'capture-before-destroy': { type: 'boolean', default: false } } }) const { values } = parseArgs({ options: { 'version': { type: 'string', default: '1.7.2' }, 'iterations': { type: 'string', default: '5' }, 'transport': { type: 'string', default: 'http' }, 'teardown': { type: 'string', default: 'reset-first' }, 'host': { type: 'string', default: 'direct' }, 'no-worker': { type: 'boolean', default: false }, 'plugin': { type: 'boolean', default: false }, 'sdk-logs': { type: 'boolean', default: false }, 'observe-workers': { type: 'boolean', default: false }, 'capture-before-destroy': { type: 'boolean', default: false }, 'switch-boundary': { type: 'string', default: 'switched' }, 'prefill': { type: 'boolean', default: false } } })
diagnose({ version: values.version, iterations: Number(values.iterations), transport: values.transport, teardown: values.teardown, worker: !values['no-worker'], host: values.host, plugin: values.plugin, sdkLogs: values['sdk-logs'], workerObserver: values['observe-workers'], captureBeforeDestroy: values['capture-before-destroy'] }).then((results) => { diagnose({ version: values.version, iterations: Number(values.iterations), transport: values.transport, teardown: values.teardown, worker: !values['no-worker'], host: values.host, plugin: values.plugin, sdkLogs: values['sdk-logs'], workerObserver: values['observe-workers'], captureBeforeDestroy: values['capture-before-destroy'], switchBoundary: values['switch-boundary'], prefill: values.prefill }).then((results) => {
process.exitCode = results.some(result => result.status !== 'passed') ? 1 : 0 process.exitCode = results.some(result => result.status !== 'passed') ? 1 : 0
}).catch((error) => { }).catch((error) => {
console.error(error) console.error(error)
+3 -1
View File
@@ -2149,7 +2149,9 @@
"baselines/hls-sdk-validation.json", "baselines/hls-sdk-validation.json",
"baselines/hls-sdk-diagnostics.json", "baselines/hls-sdk-diagnostics.json",
"changes/2026-09-14-PKG-HLS-SDK-01-http-teardown.md", "changes/2026-09-14-PKG-HLS-SDK-01-http-teardown.md",
"baselines/hls-http-teardown-validation.json" "baselines/hls-http-teardown-validation.json",
"changes/2026-09-15-PKG-HLS-SDK-01-switch-boundary.md",
"baselines/hls-switch-boundary-validation.json"
] ]
}, },
{ {
+18 -2
View File
@@ -41,12 +41,18 @@ test.beforeAll(async () => {
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {
const state = await page.evaluate(() => { const state = await page.evaluate(() => {
const video = window.art?.video const video = window.art?.video
const hls = window.art?.hls
const ranges = buffered => buffered && Array.from({ length: buffered.length }, (_, index) => [buffered.start(index), buffered.end(index)])
const controller = source => source && ({ state: source.state, level: source.level, nextLoadPosition: source.nextLoadPosition, altAudio: source.altAudio, buffered: ranges(source.mediaBuffer?.buffered), frag: source.fragCurrent && { sn: source.fragCurrent.sn, level: source.fragCurrent.level, type: source.fragCurrent.type } })
return { return {
workers: window.workerEvidence, workers: window.workerEvidence,
errors: window.sdkErrors, errors: window.sdkErrors,
events: window.sdkEvents, events: window.sdkEvents,
supported: window.Hls?.isSupported(), supported: window.Hls?.isSupported(),
destroyed: window.destroyedEngines, destroyed: window.destroyedEngines,
controllers: { main: controller(hls?.streamController), audio: controller(hls?.audioStreamController) },
sdk: !window.destroyedEngines && hls ? { currentLevel: hls.currentLevel, loadLevel: hls.loadLevel, nextLoadLevel: hls.nextLoadLevel, mediaSourceState: hls.bufferController?.mediaSource?.readyState } : { destroyed: window.destroyedEngines },
buffers: hls?.bufferController?.sourceBuffers?.map(([type, source]) => ({ type, updating: source?.updating, buffered: ranges(source?.buffered) })),
tracks: window.art?.hls?.audioTracks?.map(track => ({ id: track.id, name: track.name, groupId: track.groupId })), tracks: window.art?.hls?.audioTracks?.map(track => ({ id: track.id, name: track.name, groupId: track.groupId })),
media: video && { time: video.currentTime, paused: video.paused, ended: video.ended, readyState: video.readyState, height: video.videoHeight, buffered: Array.from({ length: video.buffered.length }, (_, index) => [video.buffered.start(index), video.buffered.end(index)]) }, media: video && { time: video.currentTime, paused: video.paused, ended: video.ended, readyState: video.readyState, height: video.videoHeight, buffered: Array.from({ length: video.buffered.length }, (_, index) => [video.buffered.start(index), video.buffered.end(index)]) },
} }
@@ -81,8 +87,8 @@ async function open(page, testInfo, release, core, plugin = 'candidate', manifes
art.hls = hls art.hls = hls
const mediaState = () => ({ time: video.currentTime, paused: video.paused, readyState: video.readyState, height: video.videoHeight, buffered: Array.from({ length: video.buffered.length }, (_, index) => [video.buffered.start(index), video.buffered.end(index)]) }) const mediaState = () => ({ time: video.currentTime, paused: video.paused, readyState: video.readyState, height: video.videoHeight, buffered: Array.from({ length: video.buffered.length }, (_, index) => [video.buffered.start(index), video.buffered.end(index)]) })
hls.on(window.Hls.Events.ERROR, (_, data) => window.sdkErrors.push({ type: data.type, details: data.details, fatal: data.fatal, media: mediaState() })) hls.on(window.Hls.Events.ERROR, (_, data) => window.sdkErrors.push({ type: data.type, details: data.details, fatal: data.fatal, media: mediaState() }))
for (const key of ['AUDIO_TRACKS_UPDATED', 'AUDIO_TRACK_SWITCHED', 'LEVEL_SWITCHED', 'MEDIA_ATTACHED', 'MEDIA_DETACHED']) for (const key of ['AUDIO_TRACKS_UPDATED', 'AUDIO_TRACK_SWITCHED', 'LEVEL_SWITCHED', 'MEDIA_ATTACHED', 'MEDIA_DETACHED', 'BUFFER_FLUSHING', 'BUFFER_FLUSHED', 'FRAG_LOADING', 'FRAG_BUFFERED'])
hls.on(window.Hls.Events[key], (_, data) => window.sdkEvents.push({ key, id: data.id, level: data.level, tracks: data.audioTracks?.map(track => ({ id: track.id, name: track.name, groupId: track.groupId })), media: mediaState() })) hls.on(window.Hls.Events[key], (_, data) => window.sdkEvents.push({ key, id: data.id, level: data.level, type: data.type, frag: data.frag && { sn: data.frag.sn, level: data.frag.level, type: data.frag.type }, tracks: data.audioTracks?.map(track => ({ id: track.id, name: track.name, groupId: track.groupId })), media: mediaState() }))
hls.on(window.Hls.Events.DESTROYING, () => window.destroyedEngines++) hls.on(window.Hls.Events.DESTROYING, () => window.destroyedEngines++)
hls.loadSource(url) hls.loadSource(url)
hls.attachMedia(video) hls.attachMedia(video)
@@ -112,6 +118,14 @@ async function destroyed(page) {
expect(await page.evaluate(() => ({ media: window.art.hls.media, children: document.querySelector('.player').childElementCount, errors: window.sdkErrors.filter(error => error.fatal) }))).toEqual({ media: null, children: 0, errors: [] }) expect(await page.evaluate(() => ({ media: window.art.hls.media, children: document.querySelector('.player').childElementCount, errors: window.sdkErrors.filter(error => error.fatal) }))).toEqual({ media: null, children: 0, errors: [] })
} }
async function playbackAdvances(page, height) {
const start = await page.evaluate(() => ({ time: window.art.video.currentTime, frames: window.art.video.getVideoPlaybackQuality().totalVideoFrames }))
await expect.poll(() => page.evaluate(({ start, height }) => {
const video = window.art.video
return !video.paused && video.videoHeight === height && video.currentTime > start.time + 0.3 && video.getVideoPlaybackQuality().totalVideoFrames > start.frames + 2
}, { start, height })).toBe(true)
}
for (const release of matrix.releases) { for (const release of matrix.releases) {
test(`SDK ${release.version}: MSE capability and unsupported cleanup`, async ({ page, browserName }, testInfo) => { test(`SDK ${release.version}: MSE capability and unsupported cleanup`, async ({ page, browserName }, testInfo) => {
await open(page, testInfo, release, 'candidate') await open(page, testInfo, release, 'candidate')
@@ -165,6 +179,7 @@ for (const release of matrix.releases) {
await expect.poll(() => page.evaluate(() => window.art.hls.audioTracks.map(track => ({ id: track.id, name: track.name, group: track.groupId })))).toEqual([{ id: 0, name: 'French', group: 'high' }, { id: 1, name: 'English', group: 'high' }, { id: 2, name: 'Commentary', group: 'high' }]) await expect.poll(() => page.evaluate(() => window.art.hls.audioTracks.map(track => ({ id: track.id, name: track.name, group: track.groupId })))).toEqual([{ id: 0, name: 'French', group: 'high' }, { id: 1, name: 'English', group: 'high' }, { id: 2, name: 'Commentary', group: 'high' }])
await expect.poll(() => page.evaluate(() => ({ index: window.art.hls.audioTrack, height: window.art.video.videoHeight }))).toEqual({ index: 0, height: 180 }) await expect.poll(() => page.evaluate(() => ({ index: window.art.hls.audioTrack, height: window.art.video.videoHeight }))).toEqual({ index: 0, height: 180 })
await expect(page.locator('.art-control-hls-audio .art-selector-value')).toHaveText('French') await expect(page.locator('.art-control-hls-audio .art-selector-value')).toHaveText('French')
await playbackAdvances(page, 180)
await page.evaluate(() => { await page.evaluate(() => {
window.art.controls.show = true window.art.controls.show = true
}) })
@@ -179,6 +194,7 @@ for (const release of matrix.releases) {
}) })
await expect.poll(() => page.evaluate(() => window.art.hls.audioTracks.length)).toBe(2) await expect.poll(() => page.evaluate(() => window.art.hls.audioTracks.length)).toBe(2)
await expect(control.locator('.art-selector-item')).toHaveCount(2) await expect(control.locator('.art-selector-item')).toHaveCount(2)
await playbackAdvances(page, 90)
await destroyed(page) await destroyed(page)
}) })
test(`${core} core: actual SDK detaches and reattaches to the same media`, async ({ page }, testInfo) => { test(`${core} core: actual SDK detaches and reattaches to the same media`, async ({ page }, testInfo) => {