refactor(audio): [PKG-AUDIO-03] separate track ownership and native lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-12 02:22:13 +08:00
1 parent 803e87252e
commit 9c07ea830c
20 files changed
+12654 -250

No files matched your search

+100 -1
View File
@@ -136,7 +136,7 @@ for (const { name, factory } of implementations) {
left.audio.calls.length = 0
right.audio.calls.length = 0
first.art.emit('destroy')
assert.deepEqual(left.audio.calls, [['pause'], ['src', ''], ['load']])
assert.deepEqual(left.audio.calls, [['pause'], name.startsWith('published') ? ['src', ''] : ['removeAttribute', 'src'], ['load']])
assert.deepEqual(right.audio.calls, [])
second.art.currentTime = 2
second.art.emit('seek')
@@ -145,6 +145,105 @@ for (const { name, factory } of implementations) {
}
const historical = implementations.find(item => item.name === 'published-main').factory
for (const { name, factory: candidate } of implementations.filter(item => !item.name.startsWith('published'))) {
test(`${name}: closed callbacks and retained update cannot revive resources`, (t) => {
const { art, listeners } = audioHost(t)
const plugin = candidate({ url: 'audio.aac' })(art)
const retained = [...listeners.values()].flatMap(set => [...set])
art.emit('destroy')
assert.equal([...listeners.values()].reduce((count, set) => count + set.size, 0), 0)
plugin.audio.calls.length = 0
Object.defineProperty(art, 'playing', { get() {
throw new Error('Closed host read')
} })
assert.equal(plugin.update({ url: 'revived.aac' }), undefined)
for (const callback of retained)
callback()
assert.deepEqual(plugin.audio.calls, [])
})
test(`${name}: pending playback rejection becomes inert after destroy`, async (t) => {
const { art, setPlay, warnings } = audioHost(t)
let reject
const pending = new Promise((resolve, fail) => {
reject = fail
})
setPlay(() => pending)
candidate({ url: 'audio.aac' })(art)
art.emit('play').emit('destroy')
reject(new Error('Cancelled playback'))
await Promise.resolve()
assert.deepEqual(warnings, [])
})
test(`${name}: unsubscription failure does not prevent remaining cleanup`, (t) => {
const { art } = audioHost(t)
const plugin = candidate({ url: 'audio.aac' })(art)
const off = art.off
const failure = new Error('Controlled off failure')
t.mock.method(art, 'off', (event, callback) => {
if (event === 'play')
throw failure
return off(event, callback)
})
plugin.audio.calls.length = 0
assert.throws(() => art.emit('destroy'), error => error === failure)
assert.deepEqual(plugin.audio.calls, [['pause'], ['removeAttribute', 'src'], ['load']])
plugin.audio.calls.length = 0
art.emit('play')
plugin.update({ url: 'revived.aac' })
assert.deepEqual(plugin.audio.calls, [])
})
test(`${name}: partial subscription failure releases the media and original error`, (t) => {
const { art, instances, listeners } = audioHost(t)
const on = art.on
const failure = new Error('Controlled on failure')
t.mock.method(art, 'on', (event, callback) => {
on(event, callback)
if (event === 'pause')
throw failure
return art
})
assert.throws(() => candidate({ url: 'audio.aac' })(art), error => error === failure)
assert.deepEqual(instances[0].calls.slice(-3), [['pause'], ['removeAttribute', 'src'], ['load']])
assert.equal([...listeners.values()].reduce((count, set) => count + set.size, 0), 0)
})
test(`${name}: source initialization and pause failures still attempt media release`, (t) => {
const failure = new Error('Controlled src failure')
const { art, instances } = audioHost(t, (audio) => {
Object.defineProperty(audio, 'src', {
get: () => '',
set() {
throw failure
},
})
audio.pause = () => {
audio.calls.push(['pause'])
throw new Error('Secondary cleanup failure')
}
})
assert.throws(() => candidate({ url: 'audio.aac' })(art), error => error === failure)
assert.deepEqual(instances[0].calls.slice(-3), [['pause'], ['removeAttribute', 'src'], ['load']])
})
test(`${name}: native pause/end/seek synchronize audio without public pause calls`, (t) => {
const { art } = audioHost(t)
const { audio } = candidate({ url: 'audio.aac' })(art)
audio.calls.length = 0
art.emit('video:pause').emit('video:ended').emit('video:seeking')
assert.deepEqual(audio.calls, [['pause'], ['pause'], ['pause']])
art.currentTime = 3
art.emit('video:seeked')
assert.equal(audio.currentTime, 3)
art.playing = true
art.currentTime = 4
art.emit('video:playing')
assert.deepEqual(audio.calls.slice(-2), [['currentTime', 4], ['play']])
})
}
test('published defect AUDIO-LIFE-01: retained update reloads audio after destroy', (t) => {
const { art, listeners } = audioHost(t)
const plugin = historical({ url: 'audio.aac' })(art)
+29 -4
View File
@@ -37,8 +37,8 @@ test.beforeAll(async () => {
const bytes = readMember(await ensureArchive(release), member)
assert.equal(hash(bytes), release.files[member])
publishedCode = bytes.toString()
sourceCode = await compilePackage(release.name, 'umd')
evidence = { release, sourceSHA256: hash(sourceCode), 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 browser baseline; no physical device or installed package acceptance' }
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) => {
@@ -116,6 +116,31 @@ test('published AUDIO-LIFE-01: empty src cleanup produces a native media error',
})
for (const core of ['published', 'candidate']) {
test(`${core} core + source audio: native pause/end stop audio and closed references stay inert`, async ({ page }, testInfo) => {
await openAudio(page, core, 'source', testInfo)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(0.2)
await page.evaluate(() => window.art.video.pause())
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
await page.evaluate(() => {
window.art.video.currentTime = 4
})
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeCloseTo(4, 1)
await page.evaluate(() => {
window.art.seek = 7.8
window.art.play()
})
await expect.poll(() => page.evaluate(() => window.art.video.ended)).toBe(true)
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
await page.evaluate(() => window.art.destroy())
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.networkState)).toBe(0)
expect(await page.evaluate(() => window.audioPlugin.audio.readyState)).toBe(0)
expect(await page.evaluate(() => window.audioPlugin.audio.error)).toBeNull()
await page.evaluate(() => window.audioPlugin.update({ url: '/test/audio-tone.m4a?source=revived' }))
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBeNull()
expect(await page.evaluate(() => window.audioPlugin.audio.networkState)).toBe(0)
})
test(`${core} core + published audio: native pause and end leave external audio playing`, async ({ page }, testInfo) => {
await openAudio(page, core, 'published', testInfo)
await page.locator('#play').click()
@@ -181,7 +206,7 @@ for (const core of ['published', 'candidate']) {
window.art.destroy()
})
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.readyState)).toBe(0)
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe('')
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe(plugin === 'published' ? '' : null)
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
expect(await page.locator('.art-video-player').count()).toBe(0)
})
@@ -200,7 +225,7 @@ for (const core of ['published', 'candidate']) {
await page.evaluate(() => window.art.destroy())
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.readyState)).toBe(0)
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe('')
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe(plugin === 'published' ? '' : null)
})
}
}
+31 -20
View File
@@ -1,10 +1,27 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import vm from 'node:vm'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { loadPackage } from './load.js'
async function readFactory(bytes, esm) {
if (esm)
return (await import(`data:text/javascript;base64,${Buffer.from(bytes).toString('base64')}`)).default
const context = {
module: { exports: {} },
Audio: function Audio() {
return new globalThis.Audio()
},
console,
}
context.exports = context.module.exports
vm.runInNewContext(bytes.toString(), context, { timeout: 5000 })
return context.module.exports
}
export async function audioImplementations() {
const { release } = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/audio-track-release.json', import.meta.url)))
const archive = await ensureArchive(release)
@@ -13,29 +30,16 @@ export async function audioImplementations() {
const member = `package/${release.manifest[format].replace(/^\.\//, '')}`
const bytes = readMember(archive, member)
assert.equal(hash(bytes), release.files[member])
let factory
if (format === 'module') {
factory = (await import(`data:text/javascript;base64,${Buffer.from(bytes).toString('base64')}`)).default
}
else {
const context = {
module: { exports: {} },
Audio: function Audio() {
return new globalThis.Audio()
},
console,
}
context.exports = context.module.exports
vm.runInNewContext(bytes.toString(), context, { timeout: 5000 })
factory = context.module.exports
}
const factory = await readFactory(bytes, format === 'module')
implementations.push({ name: `published-${format}`, factory })
}
for (const file of (process.env.ARTPLAYER_TEST_AUDIO || '').split(path.delimiter).filter(Boolean))
implementations.push({ name: `artifact-${path.basename(file)}`, factory: await readFactory(fs.readFileSync(file), file.endsWith('.mjs')) })
return implementations
}
// Records plugin intent only. It does not decode media or emulate browser timing.
export function audioHost(t) {
export function audioHost(t, initialize = () => {}) {
const instances = []
const warnings = []
let play = () => Promise.resolve()
@@ -43,17 +47,20 @@ export function audioHost(t) {
constructor() {
instances.push(this)
this.calls = []
this.values = new Map()
this.paused = true
for (const [name, initial] of Object.entries({ src: '', preload: '', currentTime: 0, playbackRate: 1, volume: 1, muted: false })) {
let value = initial
this.values.set(name, initial)
Object.defineProperty(this, name, {
get: () => value,
configurable: true,
get: () => this.values.get(name),
set: (next) => {
this.calls.push([name, next])
value = next
this.values.set(name, next)
},
})
}
initialize(this)
}
play() {
@@ -63,6 +70,10 @@ export function audioHost(t) {
pause() { this.calls.push(['pause']) }
load() { this.calls.push(['load']) }
removeAttribute(name) {
this.calls.push(['removeAttribute', name])
this.values.set(name, '')
}
}
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'Audio')
Object.defineProperty(globalThis, 'Audio', { configurable: true, value: Audio })