test(chapter): [PKG-CHAPTER-05] checkpoint native media timing controls

This commit is contained in:
Harvey Zhao committed 2026-09-15 01:51:33 +08:00
1 parent a03e5acc50
commit 34e4136b43
12 files changed
+8158 -5

No files matched your search

+2 -1
View File
@@ -159,7 +159,8 @@
"check:rollback-inventory": "node refactor/scripts/rollback-inventory.mjs --check",
"test:rollback:pages": "node scripts/pages-recovery.mjs",
"test:rollback:pages:browser": "node test/pages-recovery-browser.mjs",
"test:bun-install": "node refactor/scripts/bun-evaluation.mjs"
"test:bun-install": "node refactor/scripts/bun-evaluation.mjs",
"test:chapter-native": "playwright test --config playwright.chapter-native.config.js"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -112,3 +112,13 @@ Animation after entering real fullscreen. It preserves title/opacity/hit-test
assertions and uses actual installed artifacts in the installed browser scope.
`ARTPLAYER_CHAPTER_HOVER_BASELINE=1` restores the old immediate coordinate read for
diagnosis only. This helper corrects test synchronization, not player behavior.
For CHAPTER-TIMING-01, ARTPLAYER_CHAPTER_TIMING_DIAGNOSTICS=1 adds a bounded native
property/event/heartbeat recorder to the combination suite. The default suite is
unchanged. `yarn test:chapter-native` runs a separate page with no ArtPlayer script
to compare metadata restoration and one direct/deferred native correction.
The unassisted Windows WebKit control currently fails its position assertion;
these diagnostic cases are not ordinary CI source tests. Native five-second gaps
remain even with deferred correction, so no timer workaround is in production.
See refactor/changes/2026-09-15-PKG-CHAPTER-05-native-timing.md for exact commands,
retained failures, measured boundaries and the still-open device/release gates.
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from '@playwright/test'
import base from './playwright.config.js'
export default defineConfig(base, {
testMatch: '**/chapter-native-media.native.js',
outputDir: 'refactor/.cache/chapter-native/results',
reporter: [['list'], ['json', { outputFile: 'refactor/.cache/chapter-native/report.json' }]],
})
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,80 @@
# PKG-CHAPTER-05 原生时钟与纠正对照
进一步缩小 CHAPTER-TIMING-01 的范围,任务仍为 doing。没有修改核心或章节的
生产代码,没有扩大测试超时。相似的五秒页面停顿已在未加载 ArtPlayer 的 video
对照中出现;尚未确认底层原因,也不能据此宣称每次章节超时都与核心无关。
## 诊断结构
`test/browser/chapter-timing.js` 在显式开关下包装实际 video 实例的六个属性:
currentTime、duration、readyState、seeking、playbackRate、paused。它转发真实
原生 getter/setter 并记录前后页面时钟、返回值和异常,不替换媒体行为。最多保存
10,000 条属性记录,同时保存不读取媒体属性的原生事件时间和 100 ms 心跳。
本次所有样本均未达到记录上限。get/set 的记录本身会有开销,不能当作零干扰探针。
章节组合默认不启用探针;设置 ARTPLAYER_CHAPTER_TIMING_DIAGNOSTICS=1 才安装。
afterEach 先停止心跳并保存附件,随后保留原有状态与销毁流程。记录/属性包装和
原生监听器属于该测试页面,随 Playwright 的页面销毁释放,不进入播放器包。
`chapter-native-media.native.js` 使用不加载播放器脚本的独立 HTML,只放原生
video 和播放/暂停/切换按钮;使用相同的 pattern.mp4、query 区分两个 URL,
保持媒体字节不变。它提供 metadata 时恢复、seeked 时一次纠正和下一任务纠正
三种显式诊断模式,不改变生产实现。默认 `.native.js` 不纳入 source spec。
独立 `playwright.chapter-native.config.js` 复用原引擎/断言/超时,新增
`yarn test:chapter-native` 入口和独立输出目录;没有新增依赖或更换运行时。
## 固定序列结果
Windows、Node 24.21.0、Yarn 1.22.22、WebKit 26.6;每一组先固定次数再运行,
没有失败重试。既有安装组合使用 run-s8MkZT 已核验的 main,源码 HEAD 为
a03e5acc500ceee13f79f438bd0533995f883eaa。
| 组别 | 通过 / 失败 | 页面心跳间隔 >500 ms 的样本 |
| --- | --- | --- |
| 新旧核心/章节四组合 × 3,默认 trace | 11 / 1 | 4 / 12 |
| 相同四组合 × 3,诊断时 trace=off | 11 / 1 | 7 / 12 |
| 原生 video,仅 metadata 恢复 × 6 | 0 / 6 | 0 / 6 |
| 原生 video,一次直接纠正 × 6 | 6 / 0 | 2 / 6 |
| 原生 video,一次下一任务纠正 × 6 | 6 / 0 | 1 / 6 |
| Chromium/Firefox 原生直接纠正入口各一次 | 2 / 0 | 0 / 2 |
前两组被监测的原生属性访问最长分别为 1 ms、2 ms,但心跳出现约五秒空档,
失败样本连续出现两段。关闭 trace 仍复现,说明录制不是必要条件。停顿发生于
这些被采样的属性访问之外;未分别测量原生方法、布局、媒体线程、GPU、OS 调度,
不能据此断言具体线程死锁或协议问题。
没有纠正的原生对照六次都回到接近零的位置,保留原位置断言失败,不改成成功。
加入一次纠正后六次均回到约三秒、保持暂停并能继续播放,且确认 window.Artplayer
不存在;两次仍有约五秒心跳空档。下一任务纠正也有 5104 ms 空档,因此没有采用
简单 setTimeout 作为核心修复。不能由小样本的数量差异推导某种模式更快或更可靠。
原始结果、错误、每例属性最大耗时、页面事件、心跳、位置与输入摘要见
[机器记录](../baselines/chapter-native-timing-validation.json)。此前的十秒读取
失败及本次失败均保留;后续须定位这些停顿与具体失败的因果关系。
## 复跑
先按 scripts/browser-validation/README.md 准备六包并设置安装 map。
```powershell
$env:ARTPLAYER_CHAPTER_TIMING_DIAGNOSTICS = '1'
yarn test:browser:installed chapter-combinations.spec.js --project=webkit --workers=2 --grep='quality switch' --repeat-each=3
```
`--trace=off` 只用于独立诊断对照,不修改正式 CI。完成进程后先归档该输出目录,
再执行下一组。恢复默认组合测试前清除上述诊断环境变量。
```powershell
# 不带纠正:本次 Windows WebKit 因位置归零而失败的原生对照。
$env:ARTPLAYER_NATIVE_QUALITY_CORRECTION = ''
yarn test:chapter-native --project=webkit --workers=2 --repeat-each=6
# 一次同步纠正,或改为 deferred 测试下一任务纠正。
$env:ARTPLAYER_NATIVE_QUALITY_CORRECTION = '1'
yarn test:chapter-native --project=webkit --workers=2 --repeat-each=6
```
每组都会写 refactor/.cache/chapter-native;先等待原进程终止,再核对目录归属并
归档,不能覆盖之前的失败。源码/工具后续变化要另记输入和结果,不套用旧报告。
scoped lint、严格工具链和计划检查通过;原生入口两引擎运行通过。这不是完整
章节/真机/发布验收,PKG-CHAPTER-05 与 CHAPTER-TIMING-01 继续开放。
撤销本检查点只移除诊断工具和文档,不涉及生产产物回退。未推送、部署或发布。
+1 -1
View File
@@ -539,7 +539,7 @@
- PKG-CHAPTER-02: [记录](changes/2026-09-10-PKG-CHAPTER-02-tests.md) [记录](baselines/chapter-validation.json)
- 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-CHAPTER-05: [记录](changes/2026-09-12-PKG-CHAPTER-05-combinations.md) [记录](baselines/chapter-combinations-checkpoint.json) [记录](changes/2026-09-14-PKG-CHAPTER-05-timing.md) [记录](baselines/chapter-timing-observations.json)
- PKG-CHAPTER-05: [记录](changes/2026-09-12-PKG-CHAPTER-05-combinations.md) [记录](baselines/chapter-combinations-checkpoint.json) [记录](changes/2026-09-14-PKG-CHAPTER-05-timing.md) [记录](baselines/chapter-timing-observations.json) [记录](changes/2026-09-15-PKG-CHAPTER-05-native-timing.md) [记录](baselines/chapter-native-timing-validation.json)
- PKG-AMBILIGHT-01: [记录](changes/2026-09-12-PKG-AMBILIGHT-01-contract.md) [记录](baselines/ambilight-contract.md) [记录](baselines/ambilight-release.json) [记录](baselines/ambilight-contract-validation.json)
- PKG-AMBILIGHT-02: [记录](changes/2026-09-12-PKG-AMBILIGHT-02-tests.md) [记录](baselines/ambilight-behavior-validation.json) [记录](ambilight-validation.md)
- PKG-AMBILIGHT-03: [记录](changes/2026-09-12-PKG-AMBILIGHT-03-lifecycle.md) [记录](ambilight-validation.md) [记录](baselines/ambilight-lifecycle-validation.json)
+8
View File
@@ -1,5 +1,13 @@
# 进度与证据
## PKG-CHAPTER-05 原生时钟对照检查点
默认 trace 与关闭 trace 的两组固定 WebKit 安装组合均为 11/12 通过,保留失败。
被监测媒体属性访问均很短,页面却出现约五秒空档。不加载 ArtPlayer 的原生 video
在一次位置纠正后 6/6 恢复,其中两次也有类似空档;延后纠正仍出现 5104 ms 空档,
因此未改核心或扩大超时。新增显式探针、独立原生入口和[复跑记录](changes/2026-09-15-PKG-CHAPTER-05-native-timing.md)。
具体底层原因仍未确认,05/CHAPTER-TIMING-01 保持开放;任务完成数仍为 196/262。
## CI-01 Ads 接入安装矩阵,完整回归保留章节失败
六包统一 installed 范围实测 426 项:425 通过、1 项 WebKit 章节切画质等待失败;
+4 -2
View File
@@ -2918,11 +2918,13 @@
"refactor/changes/2026-09-14-PKG-CHAPTER-05-timing.md",
"refactor/baselines/chapter-timing-observations.json",
"refactor/baselines/ci-ads-installed-validation.json",
"refactor/changes/2026-09-15-CI-01-ads-installed.md"
"refactor/changes/2026-09-15-CI-01-ads-installed.md",
"refactor/changes/2026-09-15-PKG-CHAPTER-05-native-timing.md",
"refactor/baselines/chapter-native-timing-validation.json"
],
"compatibleResolution": "记录原生事件和绝对/相对时序,进一步区分媒体驱动耗时、夹具和核心源操作;未证明原因前不改核心、不扩大超时或重复到绿灯。",
"closureCriteria": "确认先前超时的来源并有对应可靠回归;后续单次通过不足以关闭。",
"workspaceState": "Six-package installed run: WebKit candidate/core Chapter failed after restart while waiting for non-seeking readyState>=2. A trace evaluation took 9939.104 ms and returned false; initial seeked had already fired, followed later by another seeking/canplay. This is a different observed phase from the previous restart timeout; common root cause remains unconfirmed. Keep both failures and investigate without relaxing timeouts."
"workspaceState": "Six-package installed run: WebKit candidate/core Chapter failed after restart while waiting for non-seeking readyState>=2. A trace evaluation took 9939.104 ms and returned false; initial seeked had already fired, followed later by another seeking/canplay. This is a different observed phase from the previous restart timeout; common root cause remains unconfirmed. Keep both failures and investigate without relaxing timeouts. Fixed diagnostic runs with/without trace each retain one WebKit combination failure, while observed native getters/setters remain <=2 ms and page heartbeats show roughly five-second gaps. A no-ArtPlayer native video control restores with one correction yet reproduces a similar five-second gap; deferring correction still shows 5104 ms. Exact cause and relation to each earlier ten-second failure remain unconfirmed; no production timer workaround adopted."
},
{
"id": "DASH-SDK-01",
+3 -1
View File
@@ -1447,7 +1447,9 @@
"changes/2026-09-12-PKG-CHAPTER-05-combinations.md",
"baselines/chapter-combinations-checkpoint.json",
"changes/2026-09-14-PKG-CHAPTER-05-timing.md",
"baselines/chapter-timing-observations.json"
"baselines/chapter-timing-observations.json",
"changes/2026-09-15-PKG-CHAPTER-05-native-timing.md",
"baselines/chapter-native-timing-validation.json"
]
},
{
@@ -1,5 +1,7 @@
import process from 'node:process'
import { devices } from '@playwright/test'
import { hoverProgress } from './chapter-hover.ts'
import { installChapterTiming, readChapterTiming } from './chapter-timing.js'
import { expect, test } from './fixtures.js'
async function openChapter(page, core, chapter) {
@@ -35,6 +37,8 @@ async function openChapter(page, core, chapter) {
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await expect(page.locator('.art-chapter')).toHaveCount(3)
if (process.env.ARTPLAYER_CHAPTER_TIMING_DIAGNOSTICS === '1')
await installChapterTiming(page)
}
async function hoverChapter(page, percentage, text) {
@@ -80,6 +84,8 @@ async function waitForChapterRestart(page, testInfo) {
}
test.afterEach(async ({ page }, testInfo) => {
if (process.env.ARTPLAYER_CHAPTER_TIMING_DIAGNOSTICS === '1')
await readChapterTiming(page, testInfo)
const state = await page.evaluate(() => ({
at: performance.now(),
timeOrigin: performance.timeOrigin,
@@ -0,0 +1,81 @@
import process from 'node:process'
import { installChapterTiming, readChapterTiming } from './chapter-timing.js'
import { expect, test } from './fixtures.js'
test('native paused video restores position after quality URL replacement without ArtPlayer', async ({ page }, testInfo) => {
await page.route('**/chapter-native.html', route => route.fulfill({ contentType: 'text/html', body: '<!doctype html><video id="media" muted playsinline style="width:640px;height:300px"></video><button id="play">Play</button><button id="pause">Pause</button><button id="quality">Quality A</button>' }))
await page.goto('/chapter-native.html')
await installChapterTiming(page, '#media')
await page.evaluate((correctionMode) => {
const video = document.querySelector('#media')
video.muted = true
window.nativeRestarts = []
document.querySelector('#play').onclick = () => video.play()
document.querySelector('#pause').onclick = () => video.pause()
document.querySelector('#quality').onclick = () => {
const time = video.currentTime
window.nativeRestoreTarget = time
window.nativeCorrections = 0
window.nativeCorrectionMode = correctionMode
if (correctionMode !== 'none') {
video.addEventListener('seeked', () => {
if (!window.nativeCorrections && Math.abs(video.currentTime - time) > 0.05) {
window.nativeCorrections++
if (correctionMode === 'deferred') {
setTimeout(() => {
video.currentTime = time
}, 0)
}
else {
video.currentTime = time
}
}
})
}
video.addEventListener('loadedmetadata', () => {
video.currentTime = time
}, { once: true })
video.addEventListener('loadeddata', () => {
window.nativeRestarts.push(video.currentSrc)
}, { once: true })
video.src = '/test/pattern.mp4?quality=A'
}
video.src = '/test/pattern.mp4?quality=B'
}, process.env.ARTPLAYER_NATIVE_QUALITY_CORRECTION === 'deferred' ? 'deferred' : process.env.ARTPLAYER_NATIVE_QUALITY_CORRECTION === '1' ? 'direct' : 'none')
try {
await expect.poll(() => page.evaluate(() => document.querySelector('#media').readyState >= 2)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => document.querySelector('#media').currentTime)).toBeGreaterThan(0.2)
await page.locator('#pause').click()
await page.evaluate(() => {
document.querySelector('#media').currentTime = 3.008
})
await expect.poll(() => page.evaluate(() => !document.querySelector('#media').seeking)).toBe(true)
await page.locator('#quality').click()
await expect.poll(() => page.evaluate(() => window.nativeRestarts.length)).toBe(1)
await expect.poll(() => page.evaluate(() => {
const video = document.querySelector('#media')
return !video.seeking && video.readyState >= 2
})).toBe(true)
const state = await page.evaluate(() => {
const video = document.querySelector('#media')
return { time: video.currentTime, target: window.nativeRestoreTarget, corrections: window.nativeCorrections, correctionMode: window.nativeCorrectionMode, paused: video.paused, error: video.error?.code || null, artplayerLoaded: typeof window.Artplayer !== 'undefined' }
})
await testInfo.attach('native-quality-position', { contentType: 'application/json', body: JSON.stringify(state) })
expect(state.artplayerLoaded).toBe(false)
expect(state.time).toBeCloseTo(state.target, 1)
expect(state.paused).toBe(true)
expect(state.error).toBeNull()
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => document.querySelector('#media').currentTime)).toBeGreaterThan(state.time + 0.2)
}
finally {
await readChapterTiming(page, testInfo)
await page.evaluate(() => {
const video = document.querySelector('#media')
video.pause()
video.removeAttribute('src')
video.load()
})
}
})
+56
View File
@@ -0,0 +1,56 @@
export async function installChapterTiming(page, selector) {
await page.evaluate((selector) => {
const video = selector ? document.querySelector(selector) : window.art.video
const records = []
let sequence = 0
const record = (value) => {
if (records.length < 10000)
records.push({ at: performance.now(), ...value })
}
for (const name of ['currentTime', 'duration', 'readyState', 'seeking', 'playbackRate', 'paused']) {
const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, name)
if (!descriptor?.get)
throw new Error(`Missing native media descriptor: ${name}`)
const access = (kind, callback, value) => {
const id = sequence++
const start = performance.now()
record({ id, kind, name, phase: 'before', value })
try {
const result = callback()
record({ id, kind, name, phase: 'after', value: result, elapsed: performance.now() - start })
return result
}
catch (error) {
record({ id, kind, name, phase: 'error', error: String(error), elapsed: performance.now() - start })
throw error
}
}
Object.defineProperty(video, name, {
configurable: true,
get() { return access('get', () => descriptor.get.call(this)) },
...(descriptor.set ? { set(value) { access('set', () => descriptor.set.call(this, value), value) } } : {}),
})
}
const events = []
for (const name of ['loadstart', 'loadedmetadata', 'loadeddata', 'canplay', 'seeking', 'seeked', 'playing', 'pause', 'error']) {
video.addEventListener(name, () => events.push({ name, at: performance.now() }))
}
const heartbeat = []
let previous = performance.now()
const timer = setInterval(() => {
const at = performance.now()
heartbeat.push({ at, gap: at - previous })
previous = at
}, 100)
window.chapterTiming = { records, events, heartbeat, started: previous, timeOrigin: performance.timeOrigin }
window.stopChapterTiming = () => clearInterval(timer)
}, selector)
}
export async function readChapterTiming(page, testInfo) {
const observation = await page.evaluate(() => {
window.stopChapterTiming?.()
return window.chapterTiming || null
}).catch(error => ({ error: error.message }))
await testInfo.attach('chapter-native-access-timing', { contentType: 'application/json', body: JSON.stringify(observation) })
}