mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-11 13:56:15 -08:00
fix(dash): [PKG-DASH-05] synchronize SDK events and own refresh lifecycle
This commit is contained in:
1 parent
2ad00730ee
commit
a9cf50bc4e
24 files changed
+3913
-40
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user