test(dash): [PKG-DASH-05] isolate SDK paused-seek buffer metric deadlock

This commit is contained in:
Harvey Zhao committed 2026-09-12 06:51:25 +08:00
1 parent a9cf50bc4e
commit bafbf2c8b0
12 files changed
+3921 -21

No files matched your search

@@ -151,8 +151,12 @@ devices and installed package acceptance remain separate release gates.
`dash-sdk.spec.js` now separately loads hash-pinned npm dash.js 4.5.2/5.2.1 and local `dash-sdk.spec.js` now separately loads hash-pinned npm dash.js 4.5.2/5.2.1 and local
multi-quality/multi-audio DASH segments. It covers decoding, explicit update after multi-quality/multi-audio DASH segments. It covers decoding, explicit update after
external selection, source topology replacement, retained callbacks and SDK ownership. external selection, source topology replacement, retained callbacks and SDK ownership.
It also includes a native SDK control without an ArtPlayer instance. Early SDK 4.5.2 It also includes a native SDK control without an ArtPlayer instance. SDK 4.5.2
paused-seek stalls remain open; later passes do not resolve them. Unsupported MSE is paused-seek stalls reproduce independently: its empty buffer-clear path preserves
stale buffer metrics and can prevent further scheduling. A diagnostic backport of
the SDK 5 buffer-level refresh confirms the cause; no production workaround ships.
See the [diagnosis](../../refactor/changes/2026-09-12-PKG-DASH-05-seek-diagnosis.md).
The compatibility risk remains open. Unsupported MSE is
recorded as a playback capability gap, not acceptance. SDK event tests also cover recorded as a playback capability gap, not acceptance. SDK event tests also cover
external selections without explicit update, same-SDK source replacement, asynchronous external selections without explicit update, same-SDK source replacement, asynchronous
formatter error/recovery, and actual setting clicks during SDK refresh. formatter error/recovery, and actual setting clicks during SDK refresh.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,68 @@
# PKG-DASH-05:暂停 seek 停滞的 SDK 根因
起点 `a9cf50bc`。本批没有修改生产源码、公开声明、依赖或产物。DASH-SEEK-01 从
“偶发且来源未知”推进到可独立复现的 SDK 4.5.2 调度缺陷;**风险仍 open**,不能把诊断
补丁的通过当成实际发布 SDK 或本项目的修复。DASH-05 继续 doing。
## 证据链
1. 给现有真实 SDK 测试增加原生 SourceBuffer 观测。原生 addSourceBuffer 仍以相同 this、
参数执行一次,返回原对象;额外监听 updateend 并读取缓冲,不改写媒体时间或片段。
首次 10 项中 9 通过、1 失败,再次复现新核心/已发布插件的 6 秒停滞。
2. 失败时视频 SourceBuffer 与 video.buffered 均止于 5.999999 秒,排除 Chromium 上
“SourceBuffer 其实已覆盖 6 秒”的猜测。SDK 仍保留暂停前约 2.975 秒的视频缓冲指标。
3. 加入实际 SDK seekTime、timeupdate、bufferLevelUpdated 和处理器状态:10 项中
8 通过、2 失败,新旧核心搭配发布插件均失败。SDK 收到的目标为 6;视频 hasTarget=false、
pruning=false、complete=false,旧缓冲量约 2.96 秒;seek 后没有后续时间/缓冲更新。
4. 原生 SDK 对照不创建 ArtPlayer,只使用同一媒体、相同 SDK 设置和选择顺序。等待音轨
渲染完成,在约 3 秒暂停,并观察半秒内没有时钟/追加事件,再 seek 到 6 秒。
实际 npm 4.5.2 在 Chromium/Firefox 都失败,5.2.1 两边通过。该等待用于建立真实暂停
稳态、排除仍排队的进度更新,没有延长恢复超时或改变目标。
5. 仅在上述断言失败后,启用诊断开关注入一次原生元素 timeupdate:SDK 4 视频指标立即
从 2.81/2.748 变成 0,随后请求下一视频片段,buffered 延长到约 8 秒,播放越过 6.2。
原测试仍记为失败,合成事件没有进入生产实现或默认验证路径。
6. 读取校验过完整 npm 归档的 debug 成员:4.5.2 的 BufferController.clearBuffers 在
ranges 为空时直接 resolve;5.2.1 同一分支先调用 _updateBufferLevel。
4.x ScheduleController 以缓存指标是否小于目标决定请求;失败时视频指标约 2.8 而
目标为 2,即使当前位置实际缺帧也不会下载。无后续 progress/timeupdate 时形成停滞。
7. 保持 SDK 4 debug 字节不变的原生对照 1 通过/1 失败;仅在空裁剪分支增加
_updateBufferLevel 的诊断补丁,原生及新旧核心/插件组合 10 项全部通过。
这是因果对照,不是正式 SDK 的发布准入。
Firefox 的视频范围恰好止于 6,SDK 的包含端点判断返回 true,但解码下一帧仍需下一片段。
该路径的 pruneBuffer 也经过空 clearBuffers;相同缓冲量刷新解释了两引擎的恢复。
这批证据说明是 SDK 调度所需数据未更新,不是 ArtPlayer 新核心修改了 seek 目标;历史
Firefox/候选插件失败仍保留,不能据此声称所有其他 DASH 风险已经解决。
## 测试入口与隔离
默认 `yarn test:browser test/browser/dash-sdk.spec.js` 仍加载原始固定 npm min 产物。
新增 `test/browser/dash-buffer-observer.js` 保存浏览器缓冲、SDK 指标及可用处理器状态。
SDK 内部诊断 getter 不可用时只标记 unavailable;不通过修改 SDK 来模拟这些状态。
每页新建后安装观测器,页面关闭释放资源,不复用到产品 demo。
原生复现可用 `--grep 'native SDK' --project chromium --project firefox --trace on`。
诊断环境变量(用完应清空;每次运行前后归档共享报告和 traces):
| 变量 | 值与用途 |
| --- | --- |
| ARTPLAYER_DASH_DIAGNOSE_STALL | 1:已失败且停在 6 秒时,保存合成时间更新的前后状态,不改变失败结果 |
| ARTPLAYER_DASH_DIAGNOSTIC_SDK | none/未设置:原始 min;upstream4:原始 SDK 4 debug;bufferlevel4:单分支诊断补丁 |
debug 成员来自完整性校验后的 npm 归档;补丁仅在内存替换唯一匹配的完整分支,匹配数
不是一就报错。输入附件记录原始/有效代码 SHA256、member、模式和 acceptance=false。
不得在正式报告中省略诊断模式,不得把补丁归档冒充上游包,也不得将合成事件作为产品修复。
过程中一次括号笔误导致测试发现失败,日志保留;修正语法后继续取证。加入 seekTime 的
中间运行 10 项通过,但没有以它关闭风险。全部失败、通过和对照的报告/trace 分别冻结,
见 [机器证据](../baselines/dash-seek-diagnosis.json)。本地缓存不提交,测试和摘要可重跑。
## 后续与回退
旧 SDK 合法调用仍须兼容;不能要求用户升级 SDK 后才算重构完成。后续应评审隔离且不改变
调用方媒体事件/SDK 所有权的兼容处置,或正式记录第三方边界与发布处理。在此之前,风险和
设备/完整分发门槛保持开放。不得在核心伪造 timeupdate、偏移 seek 或全局放宽测试。
局部 lint、既有 SDK/媒体来源测试及计划/风险链接校验用于本批验证;没有改生产代码,
不重复宣称完成全项目 CI 或 114 项完整浏览器验收。回退本批仅涉及诊断测试与文档,
生产产物与 a9cf50bc 的指纹保持一致。
+8
View File
@@ -79,3 +79,11 @@ UMD 测试 VM 显式注入宿主 console,以检查原始错误身份;没有
纯配置修改但没有 SDK 事件时,保留同步 update 为即时刷新入口;播放 tick 仅检查 Auto 纯配置修改但没有 SDK 事件时,保留同步 update 为即时刷新入口;播放 tick 仅检查 Auto
布尔值变化,没有新定时器。真实 SDK/媒体验证与剩余 seek/设备边界见 布尔值变化,没有新定时器。真实 SDK/媒体验证与剩余 seek/设备边界见
[事件改造记录](changes/2026-09-12-PKG-DASH-05-sdk-events.md)。历史失败证据保留,后续通过不抹去它们。 [事件改造记录](changes/2026-09-12-PKG-DASH-05-sdk-events.md)。历史失败证据保留,后续通过不抹去它们。
## 05 暂停 seek 诊断
实际 SDK 4.5.2 在不创建 ArtPlayer 的稳定暂停用例中也会停滞,SDK 5.2.1 的对应用例通过。
根因与空裁剪分支漏更新缓冲指标有关,DASH-SEEK-01 仍 open;没有发布兼容补丁。
`dash-buffer-observer.js` 记录原生 SourceBuffer/SDK 指标,两个显式诊断环境变量只用于
失败后的合成事件和内存 SDK 补丁对照,不能用于正式验收。命令、证据、限制和回退见
[seek 根因记录](changes/2026-09-12-PKG-DASH-05-seek-diagnosis.md)。
+1 -1
View File
@@ -483,4 +483,4 @@
- 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)
- PKG-DASH-04: [记录](changes/2026-09-12-PKG-DASH-04-types.md) [记录](baselines/dash-types-validation.json) [记录](dash-validation.md) - PKG-DASH-04: [记录](changes/2026-09-12-PKG-DASH-04-types.md) [记录](baselines/dash-types-validation.json) [记录](dash-validation.md)
- PKG-DASH-05: [记录](changes/2026-09-12-PKG-DASH-05-sdk-checkpoint.md) [记录](baselines/dash-sdk.json) [记录](baselines/dash-sdk-checkpoint.json) [记录](dash-validation.md) [记录](changes/2026-09-12-PKG-DASH-05-sdk-types.md) [记录](baselines/dash-sdk-types-validation.json) [记录](changes/2026-09-12-PKG-DASH-05-sdk-events.md) [记录](baselines/dash-sdk-events-validation.json) - PKG-DASH-05: [记录](changes/2026-09-12-PKG-DASH-05-sdk-checkpoint.md) [记录](baselines/dash-sdk.json) [记录](baselines/dash-sdk-checkpoint.json) [记录](dash-validation.md) [记录](changes/2026-09-12-PKG-DASH-05-sdk-types.md) [记录](baselines/dash-sdk-types-validation.json) [记录](changes/2026-09-12-PKG-DASH-05-sdk-events.md) [记录](baselines/dash-sdk-events-validation.json) [记录](changes/2026-09-12-PKG-DASH-05-seek-diagnosis.md) [记录](baselines/dash-seek-diagnosis.json)
+12
View File
@@ -1,5 +1,17 @@
# 进度与证据 # 进度与证据
## 当前实施:DASH-05 暂停 seek 根因已定位
真实 SourceBuffer、SDK seekTime/缓冲事件和处理器状态表明,SDK 4.5.2 在空裁剪分支未更新
暂停前缓冲量,调度器继续按旧值阻止下载。不创建 ArtPlayer 的稳定暂停用例在 Chromium/
Firefox 都复现;失败后合成一次 timeupdate 能恢复,测试仍保留失败。SDK 5.2.1 原生用例
两边通过;将其缓冲刷新移入 SDK 4 的单分支诊断补丁后,原生/新旧组合 10 项通过。
本批只增加诊断测试和证据,没有向生产注入事件、修补 SDK、增加依赖或改变产物。
DASH-SEEK-01 根因已知但兼容处置尚未完成,仍 open。DASH-05 仍 doing,217 项仍为
74 done、4 doing、139 todo。详见 [根因记录](changes/2026-09-12-PKG-DASH-05-seek-diagnosis.md)
和 [执行证据](baselines/dash-seek-diagnosis.json)。其余包的独立源码迁移可继续,发布门槛不降低。
## 当前实施:DASH-05 SDK 事件刷新源码改造 ## 当前实施:DASH-05 SDK 事件刷新源码改造
新增第六个严格 TS 模块 sdk-events:按 SDK 质量/音轨/流事件合并刷新菜单,保留同步 新增第六个严格 TS 模块 sdk-events:按 SDK 质量/音轨/流事件合并刷新菜单,保留同步
+1 -1
View File
@@ -140,4 +140,4 @@
| DASH-STATE-01 | resolved / 已复现 | DASH 当前项高亮使用真值 ID、音轨身份和标签去重,边界待核实 | PKG-DASH-02, PKG-DASH-03, PKG-DASH-05 | | DASH-STATE-01 | resolved / 已复现 | DASH 当前项高亮使用真值 ID、音轨身份和标签去重,边界待核实 | PKG-DASH-02, PKG-DASH-03, PKG-DASH-05 |
| DASH-TYPE-01 | resolved / 源码/产物事实 | DASH 类型未表达默认工厂参数、SDK 回调对象和现代模块入口 | PKG-DASH-04 | | DASH-TYPE-01 | resolved / 源码/产物事实 | DASH 类型未表达默认工厂参数、SDK 回调对象和现代模块入口 | PKG-DASH-04 |
| DASH-DEMO-01 | open / 源码/产物事实 | DASH 示例仅新 SDK 且重复安装会累积销毁回调 | PKG-DASH-06 | | DASH-DEMO-01 | open / 源码/产物事实 | DASH 示例仅新 SDK 且重复安装会累积销毁回调 | PKG-DASH-06 |
| DASH-SEEK-01 | open / 已复现 | 真实 dash.js 4.5.2 暂停 seek 到缓冲片段边界后可能持续 waiting | PKG-DASH-05, REVIEW-02 | | DASH-SEEK-01 | open / 已复现 | dash.js 4.5.2 空裁剪漏更新缓冲量,稳定暂停 seek 后调度停滞 | PKG-DASH-05, REVIEW-02 |
+6 -3
View File
@@ -2964,7 +2964,7 @@
}, },
{ {
"id": "DASH-SEEK-01", "id": "DASH-SEEK-01",
"title": "真实 dash.js 4.5.2 暂停 seek 到缓冲片段边界后可能持续 waiting", "title": "dash.js 4.5.2 空裁剪漏更新缓冲量,稳定暂停 seek 后调度停滞",
"confirmation": "reproduced", "confirmation": "reproduced",
"status": "open", "status": "open",
"owners": [ "owners": [
@@ -2974,9 +2974,12 @@
"evidence": [ "evidence": [
"test/browser/dash-sdk.spec.js", "test/browser/dash-sdk.spec.js",
"refactor/baselines/dash-sdk-checkpoint.json", "refactor/baselines/dash-sdk-checkpoint.json",
"refactor/changes/2026-09-12-PKG-DASH-05-sdk-checkpoint.md" "refactor/changes/2026-09-12-PKG-DASH-05-sdk-checkpoint.md",
"refactor/changes/2026-09-12-PKG-DASH-05-seek-diagnosis.md",
"refactor/baselines/dash-seek-diagnosis.json",
"test/browser/dash-buffer-observer.js"
], ],
"compatibleResolution": "先比较实际 SDK、SourceBuffer/video 缓冲与暂停 seek 调度;不偏移公开 seek 目标,不吞异常或增加超时伪造通过。", "compatibleResolution": "已用原生 SDK、SourceBuffer/调度指标和 SDK 5 空裁剪刷新的一行诊断回植确认根因。兼容处置尚未落地;不在核心伪造 timeupdate、不偏移 seek、不强制所有旧用户升级 SDK,也不将诊断补丁计为正式上游通过。",
"closureCriteria": "原失败的新旧核心/插件组合有确定根因与对应修复/可验证环境边界;原生对照及原顺序、原目标回归成立,后续偶然通过不能关闭。" "closureCriteria": "原失败的新旧核心/插件组合有确定根因与对应修复/可验证环境边界;原生对照及原顺序、原目标回归成立,后续偶然通过不能关闭。"
} }
] ]
+3 -1
View File
@@ -2070,7 +2070,9 @@
"changes/2026-09-12-PKG-DASH-05-sdk-types.md", "changes/2026-09-12-PKG-DASH-05-sdk-types.md",
"baselines/dash-sdk-types-validation.json", "baselines/dash-sdk-types-validation.json",
"changes/2026-09-12-PKG-DASH-05-sdk-events.md", "changes/2026-09-12-PKG-DASH-05-sdk-events.md",
"baselines/dash-sdk-events-validation.json" "baselines/dash-sdk-events-validation.json",
"changes/2026-09-12-PKG-DASH-05-seek-diagnosis.md",
"baselines/dash-seek-diagnosis.json"
] ]
}, },
{ {
+1 -1
View File
@@ -1054,7 +1054,7 @@
"refactor/scripts/dash-sdk-types.test.mjs" "refactor/scripts/dash-sdk-types.test.mjs"
], ],
"contract": "Caller owns art.dash. Candidate adapts the released 4.x quality method surface and workspace 5.x stable representation ID surface. Controlled contracts and DOM validation pass; actual pinned 4.5.2/5.2.1 SDK media remains PKG-DASH-05.", "contract": "Caller owns art.dash. Candidate adapts the released 4.x quality method surface and workspace 5.x stable representation ID surface. Controlled contracts and DOM validation pass; actual pinned 4.5.2/5.2.1 SDK media remains PKG-DASH-05.",
"validationStatus": "Pinned npm SDK 4.5.2/5.2.1 main/legacy media, automatic state refresh, same-SDK source replacement, settings selection and formatter recovery exercised. DASH-SEEK-01 and Windows WebKit MSE gap remain. Actual SDK declarations and exact peer closure tested across ten compiler/module configurations, with no added plugin errors and upstream-only Node10/old DOM limitations. Devices and full distribution remain incomplete; see refactor/baselines/dash-sdk-events-validation.json.", "validationStatus": "Pinned npm SDK 4.5.2/5.2.1 main/legacy media, automatic state refresh, same-SDK source replacement, settings selection and formatter recovery exercised. DASH-SEEK-01 is isolated to SDK 4 stale buffer metrics in its empty-clear branch using native playback and a diagnostic SDK 5 backport; no production mitigation ships and the risk remains open (refactor/baselines/dash-seek-diagnosis.json). Windows WebKit MSE, devices and full distribution remain incomplete. Actual SDK declarations and peer closure retain the ten-configuration matrix and upstream-only Node10/old DOM limits.",
"demoPathsSource": "refactor/baselines/demo-inventory.json" "demoPathsSource": "refactor/baselines/demo-inventory.json"
}, },
{ {
+50
View File
@@ -0,0 +1,50 @@
// Runs inside the page. Every SDK operation still uses the native SourceBuffer.
export function observeDashBuffers() {
const buffers = []
const events = []
const ranges = value => Array.from({ length: value.length }, (_, index) => [value.start(index), value.end(index)])
function snapshot() {
const video = window.art?.video || window.nativeVideo
const dash = window.art?.dash || window.nativeDash
const metrics = dash?.getDashMetrics()
let processors
try {
processors = dash?.getActiveStream()?.getProcessors().map((processor) => {
const buffer = processor.getBufferController()
return { type: processor.getType(), level: processor.getBufferLevel(), target: processor.getScheduleController().getBufferTarget(), complete: processor.isBufferingCompleted(), pruning: buffer.getIsPruningInProgress(), hasTarget: video ? buffer.hasBufferAtTime(video.currentTime) : null }
})
}
catch (error) {
// SDK initialization/teardown and generations expose different internals.
processors = { unavailable: String(error) }
}
return {
time: video?.currentTime,
paused: video?.paused,
seeking: video?.seeking,
readyState: video?.readyState,
video: video ? ranges(video.buffered) : [],
levels: metrics ? { video: metrics.getCurrentBufferLevel('video'), audio: metrics.getCurrentBufferLevel('audio') } : null,
processors,
buffers: buffers.map(({ source, buffer, mime }) => {
try {
return { mime, sourceState: source.readyState, updating: buffer.updating, ranges: ranges(buffer.buffered), timestampOffset: buffer.timestampOffset }
}
catch (error) {
return { mime, sourceState: source.readyState, error: error.name }
}
}),
}
}
const mark = name => events.push({ name, at: performance.now(), ...snapshot() })
for (const Source of new Set([window.MediaSource, window.ManagedMediaSource].filter(Boolean))) {
const addSourceBuffer = Source.prototype.addSourceBuffer
Source.prototype.addSourceBuffer = function (...args) {
const buffer = Reflect.apply(addSourceBuffer, this, args)
buffers.push({ source: this, buffer, mime: args[0] })
buffer.addEventListener('updateend', () => mark('sourcebuffer:updateend'))
return buffer
}
}
window.dashBuffers = { snapshot, mark, events }
}
+60 -12
View File
@@ -3,6 +3,7 @@ import fs from 'node:fs'
import process from 'node:process' import process from 'node:process'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs' import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js' import { compilePackage } from '../helpers/load.js'
import { observeDashBuffers } from './dash-buffer-observer.js'
import { expect, test } from './fixtures.js' import { expect, test } from './fixtures.js'
const sdks = new Map() const sdks = new Map()
@@ -11,6 +12,8 @@ let candidate
let published let published
let mediaManifest let mediaManifest
let pluginRelease let pluginRelease
const diagnosticSDK = process.env.ARTPLAYER_DASH_DIAGNOSTIC_SDK || 'none'
assert(['none', 'upstream4', 'bufferlevel4'].includes(diagnosticSDK), 'Unknown diagnostic SDK mode')
test.beforeAll(async () => { test.beforeAll(async () => {
const baseline = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/dash-sdk.json', import.meta.url))) const baseline = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/dash-sdk.json', import.meta.url)))
@@ -18,7 +21,20 @@ test.beforeAll(async () => {
const archive = await ensureArchive(sdk.release) const archive = await ensureArchive(sdk.release)
const bytes = readMember(archive, sdk.codeMember) const bytes = readMember(archive, sdk.codeMember)
assert.equal(hash(bytes), sdk.release.files[sdk.codeMember]) assert.equal(hash(bytes), sdk.release.files[sdk.codeMember])
sdks.set(sdk.release.version, { ...sdk, code: bytes.toString() }) let code = bytes.toString()
let diagnostic
if (sdk.release.version === '4.5.2' && diagnosticSDK !== 'none') {
const member = 'package/dist/dash.all.debug.js'
code = readMember(archive, member).toString()
const upstreamSHA256 = hash(code)
if (diagnosticSDK === 'bufferlevel4') {
const before = 'function clearBuffers(ranges) {\n return new Promise(function (resolve, reject) {\n if (!ranges || !sourceBufferSink || ranges.length === 0) {\n resolve();'
assert.equal(code.split(before).length, 2, 'Diagnostic patch must match exactly one upstream branch')
code = code.replace(before, before.replace(' resolve();', ' _updateBufferLevel();\n resolve();'))
}
diagnostic = { mode: diagnosticSDK, member, upstreamSHA256, effectiveSHA256: hash(code), acceptance: false }
}
sdks.set(sdk.release.version, { ...sdk, code, diagnostic })
} }
pluginRelease = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/dash-control-release.json', import.meta.url))).release pluginRelease = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/dash-control-release.json', import.meta.url))).release
const bytes = readMember(await ensureArchive(pluginRelease), 'package/dist/artplayer-plugin-dash-control.js') const bytes = readMember(await ensureArchive(pluginRelease), 'package/dist/artplayer-plugin-dash-control.js')
@@ -50,9 +66,21 @@ test.afterEach(async ({ page }, testInfo) => {
} }
const video = art?.video || window.nativeVideo const video = art?.video || window.nativeVideo
const buffered = video ? Array.from({ length: video.buffered.length }, (_, index) => [video.buffered.start(index), video.buffered.end(index)]) : [] const buffered = video ? Array.from({ length: video.buffered.length }, (_, index) => [video.buffered.start(index), video.buffered.end(index)]) : []
return { errors: window.sdkErrors, events: window.sdkEvents, mediaEvents: window.mediaEvents, nativePlay: window.nativePlay, tracks, levels, buffered, paused: video?.paused, seeking: video?.seeking, time: video?.currentTime, ready: art?.isReady, width: video?.videoWidth, height: video?.videoHeight, error: video?.error?.code, readyState: video?.readyState, destroyed: window.sdkDestroyed } return { errors: window.sdkErrors, events: window.sdkEvents, mediaEvents: window.mediaEvents, nativePlay: window.nativePlay, tracks, levels, buffered, paused: video?.paused, seeking: video?.seeking, time: video?.currentTime, ready: art?.isReady, width: video?.videoWidth, height: video?.videoHeight, error: video?.error?.code, readyState: video?.readyState, destroyed: window.sdkDestroyed, bufferState: window.dashBuffers?.snapshot(), bufferEvents: window.dashBuffers?.events }
}).catch(error => ({ error: error.message })) }).catch(error => ({ error: error.message }))
await testInfo.attach('dash-sdk-state', { contentType: 'application/json', body: JSON.stringify(state) }) await testInfo.attach('dash-sdk-state', { contentType: 'application/json', body: JSON.stringify(state) })
// Opt-in counterfactual after a failed assertion; it never turns a failure green.
if (process.env.ARTPLAYER_DASH_DIAGNOSE_STALL === '1' && testInfo.status !== testInfo.expectedStatus && state.seeking && state.time === 6) {
const recovery = await page.evaluate(() => {
const before = window.dashBuffers.snapshot()
const video = window.art?.video || window.nativeVideo
video.dispatchEvent(new Event('timeupdate'))
return { before, afterDispatch: window.dashBuffers.snapshot() }
})
recovery.advanced = await page.waitForFunction(() => (window.art?.video || window.nativeVideo).currentTime > 6.2, null, { timeout: 3000 }).then(() => true, () => false)
recovery.final = await page.evaluate(() => window.dashBuffers.snapshot())
await testInfo.attach('diagnostic-synthetic-timeupdate', { contentType: 'application/json', body: JSON.stringify(recovery) })
}
if (!page.isClosed()) { if (!page.isClosed()) {
await page.evaluate(() => { await page.evaluate(() => {
if (window.art && !window.art.isDestroy) if (window.art && !window.art.isDestroy)
@@ -65,9 +93,10 @@ test.afterEach(async ({ page }, testInfo) => {
async function loadSDK(page, version, core, testInfo) { async function loadSDK(page, version, core, testInfo) {
const sdk = sdks.get(version) const sdk = sdks.get(version)
await page.goto(`/test/player.html?core=${core}`) await page.goto(`/test/player.html?core=${core}`)
await page.evaluate(observeDashBuffers)
await page.addScriptTag({ content: sdk.code }) await page.addScriptTag({ content: sdk.code })
const capability = await page.evaluate(() => ({ sdk: window.dashjs.supportsMediaSource(), mse: typeof window.MediaSource, managed: typeof window.ManagedMediaSource, avc: Boolean(window.MediaSource?.isTypeSupported('video/mp4; codecs="avc1.42c01e"')), aac: Boolean(window.MediaSource?.isTypeSupported('audio/mp4; codecs="mp4a.40.2"')) })) const capability = await page.evaluate(() => ({ sdk: window.dashjs.supportsMediaSource(), mse: typeof window.MediaSource, managed: typeof window.ManagedMediaSource, avc: Boolean(window.MediaSource?.isTypeSupported('video/mp4; codecs="avc1.42c01e"')), aac: Boolean(window.MediaSource?.isTypeSupported('audio/mp4; codecs="mp4a.40.2"')) }))
await testInfo.attach('dash-sdk-inputs', { contentType: 'application/json', body: JSON.stringify({ sdk: sdk.release, codeMember: sdk.codeMember, pluginRelease, candidateSHA256: hash(candidate), candidate: process.env.ARTPLAYER_DASH_ARTIFACT || 'workspace source build', media: mediaManifest, capability }) }) await testInfo.attach('dash-sdk-inputs', { contentType: 'application/json', body: JSON.stringify({ sdk: sdk.release, codeMember: sdk.diagnostic?.member || sdk.codeMember, diagnostic: sdk.diagnostic, pluginRelease, candidateSHA256: hash(candidate), candidate: process.env.ARTPLAYER_DASH_ARTIFACT || 'workspace source build', media: mediaManifest, capability }) })
return capability return capability
} }
@@ -98,16 +127,19 @@ async function openDash(page, version, core, plugin, testInfo) {
dash.updateSettings({ debug: { logLevel: 0 }, streaming: { abr: { initialBitrate: { video: 150 } }, buffer: { fastSwitchEnabled: true, bufferTimeDefault: 2, bufferTimeAtTopQuality: 2 } } }) dash.updateSettings({ debug: { logLevel: 0 }, streaming: { abr: { initialBitrate: { video: 150 } }, buffer: { fastSwitchEnabled: true, bufferTimeDefault: 2, bufferTimeAtTopQuality: 2 } } })
const events = window.dashjs.MediaPlayer.events const events = window.dashjs.MediaPlayer.events
dash.on(events.ERROR, event => window.sdkErrors.push({ code: event.error?.code, message: event.error?.message, event: event.type })) dash.on(events.ERROR, event => window.sdkErrors.push({ code: event.error?.code, message: event.error?.message, event: event.type }))
for (const name of ['STREAM_INITIALIZED', 'QUALITY_CHANGE_REQUESTED', 'QUALITY_CHANGE_RENDERED', 'TRACK_CHANGE_RENDERED', 'STREAM_TEARDOWN_COMPLETE', 'BUFFER_LEVEL_STATE_CHANGED', 'FRAGMENT_LOADING_COMPLETED', 'PLAYBACK_SEEKING', 'PLAYBACK_SEEKED']) { for (const name of ['STREAM_INITIALIZED', 'QUALITY_CHANGE_REQUESTED', 'QUALITY_CHANGE_RENDERED', 'TRACK_CHANGE_RENDERED', 'STREAM_TEARDOWN_COMPLETE', 'BUFFER_LEVEL_STATE_CHANGED', 'BUFFER_LEVEL_UPDATED', 'FRAGMENT_LOADING_COMPLETED', 'PLAYBACK_SEEKING', 'PLAYBACK_SEEKED']) {
if (events[name]) if (events[name])
dash.on(events[name], event => window.sdkEvents.push({ name, at: performance.now(), mediaType: event.mediaType, oldQuality: event.oldQuality, newQuality: event.newQuality, state: event.state, url: event.request?.url, time: video.currentTime })) dash.on(events[name], event => window.sdkEvents.push({ name, at: performance.now(), mediaType: event.mediaType, oldQuality: event.oldQuality, newQuality: event.newQuality, state: event.state, bufferLevel: event.bufferLevel, url: event.request?.url, time: video.currentTime, seekTime: event.seekTime }))
} }
dash.initialize(video, url, false) dash.initialize(video, url, false)
} }, } },
}) })
const art = window.art const art = window.art
for (const name of ['play', 'playing', 'pause', 'seeking', 'seeked', 'waiting', 'emptied', 'loadedmetadata', 'loadeddata', 'canplay', 'error']) { for (const name of ['play', 'playing', 'pause', 'seeking', 'seeked', 'waiting', 'timeupdate', 'emptied', 'loadedmetadata', 'loadeddata', 'canplay', 'error']) {
art.video.addEventListener(name, () => window.mediaEvents.push({ name, at: performance.now(), time: art.video.currentTime, paused: art.video.paused, readyState: art.video.readyState, buffered: Array.from({ length: art.video.buffered.length }, (_, index) => [art.video.buffered.start(index), art.video.buffered.end(index)]) })) art.video.addEventListener(name, () => {
window.mediaEvents.push({ name, at: performance.now(), time: art.video.currentTime, paused: art.video.paused, readyState: art.video.readyState, buffered: Array.from({ length: art.video.buffered.length }, (_, index) => [art.video.buffered.start(index), art.video.buffered.end(index)]) })
window.dashBuffers.mark(`media:${name}`)
})
} }
art.on('destroy', () => { art.on('destroy', () => {
art.dash.destroy() art.dash.destroy()
@@ -164,7 +196,9 @@ for (const core of ['published', 'candidate']) {
await page.locator('#pause').click() await page.locator('#pause').click()
expect(await page.evaluate(() => window.art.video.paused)).toBe(true) expect(await page.evaluate(() => window.art.video.paused)).toBe(true)
await page.evaluate(() => { await page.evaluate(() => {
window.dashBuffers.mark('before-seek')
window.art.seek = 6 window.art.seek = 6
window.dashBuffers.mark('assigned-seek')
}) })
await page.locator('#play').click() await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(6.2) await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(6.2)
@@ -192,11 +226,14 @@ for (const version of ['4.5.2', '5.2.1']) {
dash.updateSettings({ debug: { logLevel: 0 }, streaming: { abr: { initialBitrate: { video: 150 } }, buffer: { fastSwitchEnabled: true, bufferTimeDefault: 2, bufferTimeAtTopQuality: 2 } } }) dash.updateSettings({ debug: { logLevel: 0 }, streaming: { abr: { initialBitrate: { video: 150 } }, buffer: { fastSwitchEnabled: true, bufferTimeDefault: 2, bufferTimeAtTopQuality: 2 } } })
const events = window.dashjs.MediaPlayer.events const events = window.dashjs.MediaPlayer.events
dash.on(events.ERROR, event => window.sdkErrors.push({ code: event.error?.code, message: event.error?.message })) dash.on(events.ERROR, event => window.sdkErrors.push({ code: event.error?.code, message: event.error?.message }))
for (const name of ['FRAGMENT_LOADING_COMPLETED', 'TRACK_CHANGE_RENDERED', 'QUALITY_CHANGE_RENDERED', 'BUFFER_LEVEL_STATE_CHANGED', 'PLAYBACK_SEEKING', 'PLAYBACK_SEEKED']) { for (const name of ['FRAGMENT_LOADING_COMPLETED', 'TRACK_CHANGE_RENDERED', 'QUALITY_CHANGE_RENDERED', 'BUFFER_LEVEL_STATE_CHANGED', 'BUFFER_LEVEL_UPDATED', 'PLAYBACK_SEEKING', 'PLAYBACK_SEEKED']) {
dash.on(events[name], event => window.sdkEvents.push({ name, at: performance.now(), mediaType: event.mediaType, state: event.state, url: event.request?.url, time: video.currentTime })) dash.on(events[name], event => window.sdkEvents.push({ name, at: performance.now(), mediaType: event.mediaType, state: event.state, bufferLevel: event.bufferLevel, url: event.request?.url, time: video.currentTime, seekTime: event.seekTime }))
} }
for (const name of ['play', 'playing', 'pause', 'seeking', 'seeked', 'waiting']) { for (const name of ['play', 'playing', 'pause', 'seeking', 'seeked', 'waiting', 'timeupdate']) {
video.addEventListener(name, () => window.mediaEvents.push({ name, at: performance.now(), time: video.currentTime, paused: video.paused, readyState: video.readyState, buffered: Array.from({ length: video.buffered.length }, (_, index) => [video.buffered.start(index), video.buffered.end(index)]) })) video.addEventListener(name, () => {
window.mediaEvents.push({ name, at: performance.now(), time: video.currentTime, paused: video.paused, readyState: video.readyState, buffered: Array.from({ length: video.buffered.length }, (_, index) => [video.buffered.start(index), video.buffered.end(index)]) })
window.dashBuffers.mark(`media:${name}`)
})
} }
dash.initialize(video, '/dash-fixture/master.mpd', false) dash.initialize(video, '/dash-fixture/master.mpd', false)
document.querySelector('#play').onclick = () => { document.querySelector('#play').onclick = () => {
@@ -222,16 +259,27 @@ for (const version of ['4.5.2', '5.2.1']) {
}) })
await expect.poll(() => page.evaluate(() => window.nativeVideo.videoHeight)).toBe(180) await expect.poll(() => page.evaluate(() => window.nativeVideo.videoHeight)).toBe(180)
// Match the failed integrated run: video data ends at the next seek target. // Match the failed integrated run: video data ends at the next seek target.
await expect.poll(() => page.evaluate(() => window.nativeVideo.buffered.end(window.nativeVideo.buffered.length - 1))).toBeCloseTo(6, 4) await expect.poll(() => page.evaluate(() => {
const video = window.nativeVideo
return video.currentTime >= 2.8 && Math.abs(video.buffered.end(video.buffered.length - 1) - 6) < 0.0001
})).toBe(true)
await page.evaluate(() => { await page.evaluate(() => {
const dash = window.nativeDash const dash = window.nativeDash
dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: true } } } }) dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: true } } } })
dash.setCurrentTrack(dash.getTracksFor('audio').find(track => track.lang === 'fr')) dash.setCurrentTrack(dash.getTracksFor('audio').find(track => track.lang === 'fr'))
}) })
await expect.poll(() => page.evaluate(() => window.nativeDash.getCurrentTrackFor('audio').lang)).toBe('fr') await expect.poll(() => page.evaluate(() => window.nativeDash.getCurrentTrackFor('audio').lang)).toBe('fr')
// The recorded integrated stall happened after the audio switch was rendered.
await expect.poll(() => page.evaluate(() => window.sdkEvents.some(event => event.name === 'TRACK_CHANGE_RENDERED' && event.mediaType === 'audio'))).toBe(true)
await page.locator('#pause').click() await page.locator('#pause').click()
// Observe a genuinely held pause: no clock/append events for half a second.
// This separates the seek from progress events still queued by the track switch.
await page.waitForFunction(() => window.nativeVideo.paused && performance.now() - window.dashBuffers.events.at(-1).at >= 500)
expect(await page.evaluate(() => window.nativeVideo.paused)).toBe(true)
await page.evaluate(() => { await page.evaluate(() => {
window.dashBuffers.mark('before-seek')
window.nativeVideo.currentTime = 6 window.nativeVideo.currentTime = 6
window.dashBuffers.mark('assigned-seek')
}) })
await page.locator('#play').click() await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.nativeVideo.currentTime)).toBeGreaterThan(6.2) await expect.poll(() => page.evaluate(() => window.nativeVideo.currentTime)).toBeGreaterThan(6.2)