fix(dash): [PKG-DASH-05] synchronize SDK events and own refresh lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-12 06:34:31 +08:00
1 parent 2ad00730ee
commit a9cf50bc4e
24 files changed
+3913 -40

No files matched your search

+99 -3
View File
@@ -81,12 +81,13 @@ async function openDash(page, version, core, plugin, testInfo) {
window.sdkEvents = []
window.mediaEvents = []
window.sdkDestroyed = 0
window.dashOption = { quality: { control: true, setting: true }, audio: { control: true, setting: true } }
window.art = new window.Artplayer({
container: '.player',
url: '/dash-fixture/master.mpd',
muted: true,
setting: true,
plugins: [window.artplayerPluginDashControl({ quality: { control: true, setting: true }, audio: { control: true, setting: true } })],
plugins: [window.artplayerPluginDashControl(window.dashOption)],
customType: { mpd(video, url, art) {
if (art.dash) {
art.dash.destroy()
@@ -215,9 +216,9 @@ for (const version of ['4.5.2', '5.2.1']) {
const dash = window.nativeDash
dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: false } } } })
if (dash.setQualityFor)
dash.setQualityFor('video', 1, true)
dash.setQualityFor('video', 1)
else
dash.setRepresentationForTypeById('video', '1', true)
dash.setRepresentationForTypeById('video', '1')
})
await expect.poll(() => page.evaluate(() => window.nativeVideo.videoHeight)).toBe(180)
// Match the failed integrated run: video data ends at the next seek target.
@@ -242,6 +243,101 @@ for (const version of ['4.5.2', '5.2.1']) {
for (const core of ['published', 'candidate']) {
for (const version of ['4.5.2', '5.2.1']) {
for (const type of ['quality', 'audio']) {
test(`${core} core / DASH ${version}: actual settings ${type} selection survives SDK refresh`, async ({ page }, testInfo) => {
await openDash(page, version, core, 'candidate', testInfo)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
const label = type === 'quality' ? '180p' : 'fr'
await page.locator('.art-control-setting').click()
await page.locator(`[data-name="dash-${type}"]`).click()
await page.locator('.art-setting-panel.art-current .art-setting-item-left-text').filter({ hasText: new RegExp(`^${label}$`) }).click()
if (type === 'quality') {
await expect.poll(() => page.evaluate(() => ({ height: window.art.video.videoHeight, auto: window.art.dash.getSettings().streaming.abr.autoSwitchBitrate.video }))).toEqual({ height: 180, auto: false })
}
else {
await expect.poll(() => page.evaluate(() => window.art.dash.getCurrentTrackFor('audio').lang)).toBe('fr')
}
await expect(page.locator(`.art-control-dash-${type} .art-selector-value`)).toHaveText(label)
expect(await page.evaluate(type => window.art.setting.find(`dash-${type}`).tooltip, type)).toBe(label)
expect(await page.evaluate(() => window.sdkErrors)).toEqual([])
await testInfo.attach('dash-sdk-settings', { contentType: 'image/png', body: await page.screenshot() })
})
}
test(`${core} core / DASH ${version}: same SDK source replacement refreshes topology without plugin update`, async ({ page }, testInfo) => {
await openDash(page, version, core, 'candidate', testInfo)
await expect(page.locator('.art-control-dash-audio .art-selector-value')).toHaveText('en')
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
await page.evaluate(() => window.art.dash.attachSource('/dash-fixture/single.mpd'))
await expect(page.locator('.art-control-dash-audio')).toHaveCount(0)
await expect(page.locator('.art-control-dash-quality .art-selector-item')).toHaveText(['90p', 'Auto'])
await expect.poll(() => page.evaluate(() => window.art.video.readyState)).toBeGreaterThanOrEqual(2)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
await page.evaluate(() => window.art.dash.attachSource('/dash-fixture/master.mpd'))
await expect(page.locator('.art-control-dash-audio .art-selector-value')).toHaveText('en')
await expect(page.locator('.art-control-dash-quality .art-selector-item')).toHaveText(['180p', '90p', 'Auto'])
expect(await page.evaluate(() => window.sdkDestroyed)).toBe(0)
expect(await page.evaluate(() => window.sdkErrors)).toEqual([])
})
test(`${core} core / DASH ${version}: asynchronous formatter error clears menus and explicit update recovers`, async ({ page }, testInfo) => {
const warnings = []
page.on('console', (message) => {
if (message.type() === 'warning')
warnings.push({ text: message.text(), detail: message.text().includes('ArtPlayer DASH refresh failed:') ? message.args()[1].evaluate(error => ({ message: error.message, original: error === window.dashFormatterError })) : Promise.resolve(null) })
})
await openDash(page, version, core, 'candidate', testInfo)
await expect(page.locator('.art-control-dash-audio .art-selector-value')).toHaveText('en')
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
await page.evaluate(() => {
window.dashFormatterError = new Error('Intentional DASH formatter failure')
window.dashOption.audio.getName = () => {
throw window.dashFormatterError
}
const dash = window.art.dash
dash.setCurrentTrack(dash.getTracksFor('audio').find(track => track.lang === 'fr'))
})
await expect(page.locator('.art-control-dash-quality, .art-control-dash-audio')).toHaveCount(0)
const failures = warnings.filter(message => message.text.includes('ArtPlayer DASH refresh failed:'))
expect(failures).toHaveLength(1)
expect(await Promise.all(failures.map(message => message.detail))).toEqual([{ message: 'Intentional DASH formatter failure', original: true }])
await page.evaluate(() => {
window.dashOption.audio.getName = track => track.lang || String(track.id)
window.art.plugins.artplayerPluginDashControl.update()
})
await expect(page.locator('.art-control-dash-audio .art-selector-value')).toHaveText('fr')
await expect(page.locator('.art-control-dash-quality .art-selector-item')).toHaveCount(3)
expect(await page.evaluate(() => window.sdkErrors)).toEqual([])
})
test(`${core} core / DASH ${version}: SDK events refresh external selections without explicit update`, async ({ page }, testInfo) => {
await openDash(page, version, core, 'candidate', testInfo)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
await page.evaluate(() => {
const dash = window.art.dash
dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: false } } } })
if (dash.setQualityFor)
dash.setQualityFor('video', 1)
else
dash.setRepresentationForTypeById('video', '1')
dash.setCurrentTrack(dash.getTracksFor('audio').find(track => track.lang === 'fr'))
})
await expect.poll(() => page.evaluate(() => window.art.video.videoHeight)).toBe(180)
await expect(page.locator('.art-control-dash-quality .art-selector-value')).toHaveText('180p')
await expect(page.locator('.art-control-dash-audio .art-selector-value')).toHaveText('fr')
expect(await page.evaluate(() => ({ quality: window.art.setting.find('dash-quality').tooltip, audio: window.art.setting.find('dash-audio').tooltip }))).toEqual({ quality: '180p', audio: 'fr' })
await page.evaluate(() => window.art.dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: true } } } }))
await expect(page.locator('.art-control-dash-quality .art-selector-value')).toHaveText('Auto')
expect(await page.evaluate(() => window.sdkErrors)).toEqual([])
})
test(`${core} core / DASH ${version}: explicit update reflects external SDK selection`, async ({ page }, testInfo) => {
await openDash(page, version, core, 'candidate', testInfo)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
+271
View File
@@ -0,0 +1,271 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- SDK event ownership uses Node's lifecycle runner.
import test from 'node:test'
import { bothMenus, dashHost, dashImplementations } from './helpers/dash-control.js'
const candidates = (await dashImplementations()).filter(item => item.name.startsWith('source') || item.name.startsWith('artifact'))
const flush = () => Promise.resolve()
function eventHost(version) {
const host = dashHost(version)
const events = new Map()
host.dash.on = (name, callback) => {
if (!events.has(name))
events.set(name, new Set())
events.get(name).add(callback)
}
host.dash.off = (name, callback) => events.get(name)?.delete(callback)
return { ...host, events, fire(name) {
for (const callback of [...(events.get(name) || [])])
callback()
}, listenerCount: () => [...events.values()].reduce((sum, callbacks) => sum + callbacks.size, 0) }
}
for (const { name, sdk, factory } of candidates) {
test(`DASH ${sdk} (${name}): SDK events coalesce without interrupting synchronous selection`, async () => {
const host = eventHost(sdk)
factory(bothMenus())(host.art).update()
assert.equal(host.listenerCount(), 7)
const oldSelect = host.dash.setCurrentTrack
host.dash.setCurrentTrack = (track) => {
oldSelect.call(host.dash, track)
host.fire('trackChangeRendered')
host.fire('streamUpdated')
}
const menu = host.controls.get('dash-audio')
host.calls.length = 0
assert.equal(menu.onSelect(menu.selector[1]), 'fr')
assert.deepEqual(host.calls.map(call => call[0]), ['setCurrentTrack', 'notice', 'controls.check', 'setting.check'])
await flush()
assert.equal(host.controls.get('dash-audio').html, 'fr')
assert.equal(host.calls.filter(call => call[0] === 'controls.update').length, 2)
})
test(`DASH ${sdk} (${name}): time ticks only refresh changed Auto state`, async () => {
const host = eventHost(sdk)
const plugin = factory(bothMenus())(host.art)
plugin.update()
host.calls.length = 0
host.fire('playbackTimeUpdated')
await flush()
assert.equal(host.calls.length, 0)
host.state.settings.streaming.abr.autoSwitchBitrate.video = false
host.fire('playbackTimeUpdated')
host.fire('playbackTimeUpdated')
await flush()
assert.equal(host.controls.get('dash-quality').html, '720p')
assert.equal(host.calls.filter(call => call[0] === 'controls.update').length, 2)
host.state.settings.streaming.abr.autoSwitchBitrate.video = true
plugin.update()
host.calls.length = 0
host.fire('playbackTimeUpdated')
await flush()
assert.equal(host.calls.length, 0)
})
test(`DASH ${sdk} (${name}): changed SDK and destroy invalidate queued work and only remove owned listeners`, async () => {
const host = eventHost(sdk)
const next = eventHost(sdk)
const external = () => {}
host.dash.on('streamUpdated', external)
const plugin = factory(bothMenus())(host.art)
plugin.update()
host.fire('streamUpdated')
next.state.video = host.video
host.art.dash = next.dash
host.calls.length = 0
await flush()
assert.equal(host.calls.length, 0)
plugin.update()
assert.equal(host.listenerCount(), 1)
assert(host.events.get('streamUpdated').has(external))
assert.equal(next.listenerCount(), 7)
next.fire('qualityChangeRendered')
const retained = [...next.events.get('qualityChangeRendered')][0]
host.art.destroy()
host.calls.length = 0
retained()
await flush()
assert.equal(next.listenerCount(), 0)
assert.equal(host.calls.length, 0)
assert(!next.calls.some(call => call[0] === 'dash.destroy'))
})
test(`DASH ${sdk} (${name}): SDK teardown clears menus and source initialization restores them`, async () => {
const host = eventHost(sdk)
factory(bothMenus())(host.art).update()
host.fire('qualityChangeRendered')
host.fire('streamTeardownComplete')
assert.equal(host.controls.size + host.settings.size, 0)
host.calls.length = 0
host.fire('playbackTimeUpdated')
await flush()
assert.equal(host.calls.length, 0)
host.fire('streamInitialized')
await flush()
assert.equal(host.controls.size + host.settings.size, 4)
assert.equal(host.listenerCount(), 7)
})
test(`DASH ${sdk} (${name}): a partially installed SDK subscription is rolled back with its original error`, () => {
const host = eventHost(sdk)
const failure = new Error('SDK subscription failed')
const on = host.dash.on
host.dash.on = (name, callback) => {
on(name, callback)
if (host.listenerCount() === 2)
throw failure
}
assert.throws(() => factory(bothMenus())(host.art).update(), error => error === failure)
assert.equal(host.listenerCount(), 0)
assert.equal(host.controls.size + host.settings.size, 0)
})
test(`DASH ${sdk} (${name}): throwing SDK removal still attempts every owned listener and menu`, () => {
const host = eventHost(sdk)
const failure = new Error('SDK unsubscribe failed')
const off = host.dash.off
let removed = 0
host.dash.off = (name, callback) => {
off(name, callback)
removed++
if (removed === 1)
throw failure
}
factory(bothMenus())(host.art).update()
assert.throws(() => host.art.destroy(), error => error === failure)
assert.equal(removed, 7)
assert.equal(host.listenerCount(), 0)
assert.equal(host.controls.size + host.settings.size, 0)
})
test(`DASH ${sdk} (${name}): asynchronous formatter failure closes observation and reports the original error`, async (t) => {
const host = eventHost(sdk)
const option = bothMenus()
factory(option)(host.art).update()
const failure = new Error('SDK refresh formatter failed')
option.audio.getName = () => {
throw failure
}
const warnings = []
t.mock.method(console, 'warn', (...args) => warnings.push(args))
host.fire('trackChangeRendered')
await flush()
assert.equal(host.listenerCount(), 0)
assert.equal(host.controls.size + host.settings.size, 0)
assert.equal(warnings.length, 1)
assert.equal(warnings[0][1], failure)
})
test(`DASH ${sdk} (${name}): destroy during SDK subscription cannot install later listeners`, () => {
const host = eventHost(sdk)
const on = host.dash.on
host.dash.on = (name, callback) => {
on(name, callback)
host.art.destroy()
}
factory(bothMenus())(host.art).update()
assert.equal(host.listenerCount(), 0)
assert.equal(host.controls.size + host.settings.size, 0)
})
test(`DASH ${sdk} (${name}): SDK events emitted by a formatter cannot create an endless refresh loop`, async () => {
const host = eventHost(sdk)
const option = bothMenus()
let formats = 0
option.audio.getName = (track) => {
formats++
host.fire('streamUpdated')
return track.lang
}
factory(option)(host.art).update()
const initial = formats
await flush()
const refreshed = formats
assert(refreshed > initial)
await flush()
assert.equal(formats, refreshed)
})
test(`DASH ${sdk} (${name}): destruction from a repeated binding snapshot leaves no stale writes`, () => {
const host = eventHost(sdk)
const plugin = factory(bothMenus())(host.art)
plugin.update()
const getSettings = host.dash.getSettings
host.dash.getSettings = () => {
host.art.destroy()
return getSettings.call(host.dash)
}
plugin.update()
assert.equal(host.listenerCount(), 0)
assert.equal(host.controls.size + host.settings.size, 0)
})
test(`DASH ${sdk} (${name}): unsubscribe reentry keeps the newer SDK binding exactly once`, () => {
const host = eventHost(sdk)
const next = eventHost(sdk)
next.state.video = host.video
const off = host.dash.off
let entered = false
let plugin
host.dash.off = (name, callback) => {
off(name, callback)
if (!entered) {
entered = true
plugin.update()
}
}
plugin = factory(bothMenus())(host.art)
plugin.update()
host.art.dash = next.dash
plugin.update()
assert.equal(host.listenerCount(), 0)
assert.equal(next.listenerCount(), 7)
assert.equal(host.controls.size + host.settings.size, 4)
host.art.destroy()
assert.equal(next.listenerCount(), 0)
})
test(`DASH ${sdk} (${name}): failed Auto observation stops reads and an explicit update can recover`, async (t) => {
const host = eventHost(sdk)
const plugin = factory(bothMenus())(host.art)
plugin.update()
const getSettings = host.dash.getSettings
const failure = new Error('SDK settings unavailable')
let reads = 0
host.dash.getSettings = () => {
reads++
throw failure
}
const warnings = []
t.mock.method(console, 'warn', (...args) => warnings.push(args))
host.fire('playbackTimeUpdated')
host.fire('playbackTimeUpdated')
await flush()
assert.equal(reads, 1)
assert.equal(host.listenerCount(), 0)
assert.equal(host.controls.size + host.settings.size, 0)
assert.equal(warnings.length, 1)
assert.equal(warnings[0][1], failure)
host.dash.getSettings = getSettings
plugin.update()
assert.equal(host.listenerCount(), 7)
assert.equal(host.controls.size + host.settings.size, 4)
})
test(`DASH ${sdk} (${name}): explicit update errors stay synchronous when SDK events are supported`, (t) => {
const host = eventHost(sdk)
const option = bothMenus()
const plugin = factory(option)(host.art)
plugin.update()
const failure = new Error('Explicit formatter failure')
option.audio.getName = () => {
throw failure
}
const warnings = []
t.mock.method(console, 'warn', (...args) => warnings.push(args))
assert.throws(() => plugin.update(), error => error === failure)
assert.equal(host.listenerCount(), 0)
assert.equal(host.controls.size + host.settings.size, 0)
assert.equal(warnings.length, 0)
})
}
+2 -1
View File
@@ -11,7 +11,8 @@ import { loadPackage } from './load.js'
async function factoryFromBytes(bytes, module) {
if (module)
return (await import(`data:text/javascript;base64,${Buffer.from(bytes).toString('base64')}`)).default
const context = { module: { exports: {} } }
// VM's implicit console does not forward warnings to the test process.
const context = { module: { exports: {} }, console }
context.exports = context.module.exports
vm.runInNewContext(bytes.toString(), context, { timeout: 5000 })
assert.equal(typeof context.module.exports, 'function')