test(audio): [PKG-AUDIO-05] checkpoint source-switch regressions

This commit is contained in:
Harvey Zhao committed 2026-09-12 03:06:54 +08:00
1 parent 13c898c149
commit b5e96664ee
11 files changed
+8785 -87

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
import { useAudioFixture } from './audio-fixture.js'
import { expect, test } from './fixtures.js'
const { openAudio } = useAudioFixture()
for (const core of ['published', 'candidate']) {
for (const plugin of ['published', 'source']) {
test(`${core} core + ${plugin} audio: rapid main/audio switches settle on the last pair`, async ({ page }, testInfo) => {
await openAudio(page, core, plugin, testInfo)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(0.3)
await page.evaluate(async () => {
const { art, audioPlugin } = window
window.originalAudio = audioPlugin.audio
const pending = []
for (const source of ['first', 'second', 'last']) {
pending.push(art.switchUrl(`/test/pattern.mp4?pair=${source}`))
audioPlugin.update({ url: `/test/audio-tone.m4a?pair=${source}` })
}
await Promise.all(pending)
})
await expect.poll(() => page.evaluate(() => window.art.video.currentSrc)).toContain('pair=last')
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentSrc)).toContain('pair=last')
await expect.poll(() => page.evaluate(() => !window.art.video.paused && !window.audioPlugin.audio.paused && window.art.currentTime > 0.3)).toBe(true)
await expect.poll(() => page.evaluate(() => Math.abs(window.art.currentTime - window.audioPlugin.audio.currentTime))).toBeLessThan(0.5)
expect(await page.evaluate(() => window.originalAudio === window.audioPlugin.audio)).toBe(true)
await page.evaluate(async () => {
window.art.pause()
await window.art.switchUrl('/test/pattern.mp4?pair=paused')
window.audioPlugin.update({ url: '/test/audio-tone.m4a?pair=paused' })
})
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.readyState)).toBeGreaterThanOrEqual(2)
expect(await page.evaluate(() => window.art.video.paused && window.audioPlugin.audio.paused)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(0.3)
expect(await page.evaluate(() => [window.art.video.error, window.audioPlugin.audio.error])).toEqual([null, null])
await page.evaluate(() => window.art.destroy())
})
test(`${core} core + ${plugin} audio: two active players keep clocks and ownership independent`, async ({ page }, testInfo) => {
await openAudio(page, core, plugin, testInfo)
await page.evaluate(() => {
window.art.option.mutex = false
const container = document.createElement('div')
container.style.cssText = 'width:320px;height:180px'
document.body.append(container)
window.otherArt = new window.Artplayer({ container, url: '/test/pattern.mp4?instance=other', mutex: false, muted: true, plugins: [window.artplayerPluginAudioTrack({ url: '/test/audio-tone.m4a?instance=other', offset: 0.5, sync: 0.1 })] })
window.otherAudio = window.otherArt.plugins.artplayerPluginAudioTrack.audio
const play = document.querySelector('#play')
play.onclick = () => Promise.all([window.art.play(), window.otherArt.play()])
})
await expect.poll(() => page.evaluate(() => window.otherArt.isReady && window.otherAudio.readyState >= 2)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => !window.audioPlugin.audio.paused && !window.otherAudio.paused && window.art.currentTime > 0.2 && window.otherArt.currentTime > 0.2)).toBe(true)
await page.evaluate(() => {
window.art.pause()
window.art.seek = 3
window.art.volume = 0.2
window.art.playbackRate = 1.5
window.otherArt.volume = 0.7
})
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeCloseTo(3, 1)
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.playbackRate)).toBe(1.5)
await expect.poll(() => page.evaluate(() => window.otherAudio.volume)).toBeCloseTo(0.7)
expect(await page.evaluate(() => window.otherAudio.playbackRate)).toBe(1)
expect(await page.evaluate(() => window.otherAudio !== window.audioPlugin.audio)).toBe(true)
await expect.poll(() => page.evaluate(() => Math.abs(window.otherAudio.currentTime - window.otherArt.currentTime - 0.5))).toBeLessThan(0.5)
const before = await page.evaluate(() => {
window.art.destroy()
return window.otherAudio.currentTime
})
await expect.poll(() => page.evaluate(() => window.otherAudio.currentTime)).toBeGreaterThan(before + 0.3)
expect(await page.evaluate(() => window.audioPlugin.audio.paused && !window.otherAudio.paused)).toBe(true)
expect(await page.evaluate(() => window.otherAudio.currentSrc)).toContain('instance=other')
expect(await page.evaluate(() => window.otherAudio.error)).toBeNull()
await testInfo.attach('independent-audio', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ left: { time: window.audioPlugin.audio.currentTime, paused: window.audioPlugin.audio.paused, rate: window.audioPlugin.audio.playbackRate }, right: { time: window.otherAudio.currentTime, videoTime: window.otherArt.currentTime, rate: window.otherAudio.playbackRate, paused: window.otherAudio.paused } }))) })
await page.evaluate(() => window.otherArt.destroy())
expect(await page.evaluate(() => window.otherAudio.paused)).toBe(true)
})
test(`${core} core + ${plugin} audio: native offset bounds recover to an in-range seek`, async ({ page }, testInfo) => {
await openAudio(page, core, plugin, testInfo)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(0.2)
await page.evaluate(() => {
window.art.pause()
window.audioPlugin.update({ offset: -2 })
window.art.seek = 0.5
})
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking)).toBe(true)
const negative = await page.evaluate(() => ({ time: window.audioPlugin.audio.currentTime, error: window.audioPlugin.audio.error?.code || null }))
expect(negative).toEqual({ time: 0, error: null })
await page.evaluate(() => {
window.audioPlugin.update({ offset: 20 })
window.art.seek = 1
})
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking)).toBe(true)
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBe(16)
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
await page.evaluate(() => {
window.audioPlugin.update({ offset: -0.5 })
window.art.seek = 3
})
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeCloseTo(2.5, 1)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(2.8)
await expect.poll(() => page.evaluate(() => Math.abs(window.audioPlugin.audio.currentTime - window.art.currentTime + 0.5))).toBeLessThan(0.5)
await testInfo.attach('offset-boundary', { contentType: 'application/json', body: JSON.stringify({ negative, overflow: 16, recovered: true }) })
await page.evaluate(() => window.art.destroy())
})
}
}
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
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'
export function useAudioFixture() {
let publishedCode
let sourceCode
let evidence
const samples = 16000 * 16
const tone = fs.readFileSync(new URL('./media/audio-tone.m4a', import.meta.url))
const toneManifest = JSON.parse(fs.readFileSync(new URL('./media/audio-tone.json', import.meta.url)))
const wav = Buffer.alloc(44 + samples * 2)
wav.write('RIFF', 0)
wav.writeUInt32LE(wav.length - 8, 4)
wav.write('WAVEfmt ', 8)
wav.writeUInt32LE(16, 16)
wav.writeUInt16LE(1, 20)
wav.writeUInt16LE(1, 22)
wav.writeUInt32LE(16000, 24)
wav.writeUInt32LE(32000, 28)
wav.writeUInt16LE(2, 32)
wav.writeUInt16LE(16, 34)
wav.write('data', 36)
wav.writeUInt32LE(samples * 2, 40)
for (let index = 0; index < samples; index++)
wav.writeInt16LE(index % 80 < 40 ? 2000 : -2000, 44 + index * 2)
test.beforeAll(async () => {
assert.equal(hash(tone), toneManifest.sha256)
assert.equal(tone.length, toneManifest.bytes)
assert(!process.env.ARTPLAYER_BROWSER_ARTIFACTS, 'Audio source tests cannot represent installed artifacts')
const { release } = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/audio-track-release.json', import.meta.url)))
const member = 'package/dist/artplayer-plugin-audio-track.js'
const bytes = readMember(await ensureArchive(release), member)
assert.equal(hash(bytes), release.files[member])
publishedCode = bytes.toString()
sourceCode = process.env.ARTPLAYER_AUDIO_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_AUDIO_ARTIFACT, 'utf8') : await compilePackage(release.name, 'umd')
evidence = { release, sourceSHA256: hash(sourceCode), candidate: process.env.ARTPLAYER_AUDIO_ARTIFACT || 'workspace source build', audio: { sha256: hash(tone), bytes: tone.length, format: 'AAC mono 48000 Hz, 16 seconds, 440 Hz tone in MP4' }, wav: { sha256: hash(wav), bytes: wav.length, format: 'PCM16LE mono 16000 Hz, 16 seconds, integer square wave 200 Hz' }, scope: 'Source or explicit artifact browser checks; no physical device or installed package acceptance' }
})
test.afterEach(async ({ page }, testInfo) => {
const state = await page.evaluate(() => {
const audio = window.audioPlugin?.audio
const video = window.art?.video
const media = element => element && ({ src: element.currentSrc, attribute: element.getAttribute('src'), time: element.currentTime, paused: element.paused, readyState: element.readyState, networkState: element.networkState, error: element.error?.code, rate: element.playbackRate, seekable: Array.from({ length: element.seekable.length }, (_, index) => [element.seekable.start(index), element.seekable.end(index)]) })
return { audio: media(audio), video: media(video), events: window.audioEvents, hostEvents: window.audioHostEvents, warnings: window.audioWarnings }
}).catch(error => ({ error: error.message }))
await testInfo.attach('audio-state', { contentType: 'application/json', body: JSON.stringify(state) })
})
async function openAudio(page, core, plugin, testInfo, url = '/test/audio-tone.m4a?source=first') {
await testInfo.attach('audio-inputs', { contentType: 'application/json', body: JSON.stringify(evidence) })
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: plugin === 'published' ? publishedCode : sourceCode })
await page.evaluate((url) => {
window.audioEvents = []
window.audioHostEvents = []
window.audioWarnings = []
const warn = console.warn.bind(console)
console.warn = (...args) => {
window.audioWarnings.push(args.map(String))
warn(...args)
}
window.art = new window.Artplayer({
container: '.player',
url: '/test/pattern.mp4',
muted: true,
plugins: [window.artplayerPluginAudioTrack({ url, sync: 0.1 })],
})
window.audioPlugin = window.art.plugins.artplayerPluginAudioTrack
for (const event of ['play', 'pause', 'seek', 'video:waiting', 'video:playing', 'video:seeking', 'video:seeked', 'video:pause', 'video:ended'])
window.art.on(event, () => window.audioHostEvents.push({ event, videoTime: window.art.currentTime, audioTime: window.audioPlugin.audio.currentTime, audioPaused: window.audioPlugin.audio.paused }))
for (const event of ['loadedmetadata', 'playing', 'pause', 'seeked', 'error', 'emptied'])
window.audioPlugin.audio.addEventListener(event, () => window.audioEvents.push({ event, time: window.audioPlugin.audio.currentTime, src: window.audioPlugin.audio.currentSrc }))
document.querySelector('#play').onclick = () => window.art.play()
}, url)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
if (url)
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.readyState)).toBeGreaterThanOrEqual(2)
}
return { openAudio, wav }
}
+2 -81
View File
@@ -1,86 +1,7 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
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 { useAudioFixture } from './audio-fixture.js'
import { expect, test } from './fixtures.js'
let publishedCode
let sourceCode
let evidence
const samples = 16000 * 16
const tone = fs.readFileSync(new URL('./media/audio-tone.m4a', import.meta.url))
const toneManifest = JSON.parse(fs.readFileSync(new URL('./media/audio-tone.json', import.meta.url)))
const wav = Buffer.alloc(44 + samples * 2)
wav.write('RIFF', 0)
wav.writeUInt32LE(wav.length - 8, 4)
wav.write('WAVEfmt ', 8)
wav.writeUInt32LE(16, 16)
wav.writeUInt16LE(1, 20)
wav.writeUInt16LE(1, 22)
wav.writeUInt32LE(16000, 24)
wav.writeUInt32LE(32000, 28)
wav.writeUInt16LE(2, 32)
wav.writeUInt16LE(16, 34)
wav.write('data', 36)
wav.writeUInt32LE(samples * 2, 40)
for (let index = 0; index < samples; index++)
wav.writeInt16LE(index % 80 < 40 ? 2000 : -2000, 44 + index * 2)
test.beforeAll(async () => {
assert.equal(hash(tone), toneManifest.sha256)
assert.equal(tone.length, toneManifest.bytes)
assert(!process.env.ARTPLAYER_BROWSER_ARTIFACTS, 'Audio source tests cannot represent installed artifacts')
const { release } = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/audio-track-release.json', import.meta.url)))
const member = 'package/dist/artplayer-plugin-audio-track.js'
const bytes = readMember(await ensureArchive(release), member)
assert.equal(hash(bytes), release.files[member])
publishedCode = bytes.toString()
sourceCode = process.env.ARTPLAYER_AUDIO_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_AUDIO_ARTIFACT, 'utf8') : await compilePackage(release.name, 'umd')
evidence = { release, sourceSHA256: hash(sourceCode), candidate: process.env.ARTPLAYER_AUDIO_ARTIFACT || 'workspace source build', audio: { sha256: hash(tone), bytes: tone.length, format: 'AAC mono 48000 Hz, 16 seconds, 440 Hz tone in MP4' }, wav: { sha256: hash(wav), bytes: wav.length, format: 'PCM16LE mono 16000 Hz, 16 seconds, integer square wave 200 Hz' }, scope: 'Source or explicit artifact browser checks; no physical device or installed package acceptance' }
})
test.afterEach(async ({ page }, testInfo) => {
const state = await page.evaluate(() => {
const audio = window.audioPlugin?.audio
const video = window.art?.video
const media = element => element && ({ src: element.currentSrc, attribute: element.getAttribute('src'), time: element.currentTime, paused: element.paused, readyState: element.readyState, networkState: element.networkState, error: element.error?.code, rate: element.playbackRate, seekable: Array.from({ length: element.seekable.length }, (_, index) => [element.seekable.start(index), element.seekable.end(index)]) })
return { audio: media(audio), video: media(video), events: window.audioEvents, hostEvents: window.audioHostEvents, warnings: window.audioWarnings }
}).catch(error => ({ error: error.message }))
await testInfo.attach('audio-state', { contentType: 'application/json', body: JSON.stringify(state) })
})
async function openAudio(page, core, plugin, testInfo, url = '/test/audio-tone.m4a?source=first') {
await testInfo.attach('audio-inputs', { contentType: 'application/json', body: JSON.stringify(evidence) })
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: plugin === 'published' ? publishedCode : sourceCode })
await page.evaluate((url) => {
window.audioEvents = []
window.audioHostEvents = []
window.audioWarnings = []
const warn = console.warn.bind(console)
console.warn = (...args) => {
window.audioWarnings.push(args.map(String))
warn(...args)
}
window.art = new window.Artplayer({
container: '.player',
url: '/test/pattern.mp4',
muted: true,
plugins: [window.artplayerPluginAudioTrack({ url, sync: 0.1 })],
})
window.audioPlugin = window.art.plugins.artplayerPluginAudioTrack
for (const event of ['play', 'pause', 'seek', 'video:waiting', 'video:playing', 'video:seeking', 'video:seeked', 'video:pause', 'video:ended'])
window.art.on(event, () => window.audioHostEvents.push({ event, videoTime: window.art.currentTime, audioTime: window.audioPlugin.audio.currentTime, audioPaused: window.audioPlugin.audio.paused }))
for (const event of ['loadedmetadata', 'playing', 'pause', 'seeked', 'error', 'emptied'])
window.audioPlugin.audio.addEventListener(event, () => window.audioEvents.push({ event, time: window.audioPlugin.audio.currentTime, src: window.audioPlugin.audio.currentSrc }))
document.querySelector('#play').onclick = () => window.art.play()
}, url)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
if (url)
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.readyState)).toBeGreaterThanOrEqual(2)
}
const { openAudio, wav } = useAudioFixture()
async function playToEnd(page) {
await page.evaluate(() => {