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

@@ -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) })
}