Files
ArtPlayer/test/browser/dash-sdk.spec.js
T

528 lines
33 KiB
JavaScript

import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { browserCandidate } from '../helpers/browser-candidate.js'
import { observeDashBuffers } from './dash-buffer-observer.js'
import { probeBufferGetter, probeBufferMetric } from './dash-seek-diagnostics.js'
import { expect, test } from './fixtures.js'
const sdks = new Map()
const media = new Map()
let candidate
let candidateProvenance
let published
let mediaManifest
let pluginRelease
const diagnosticSDK = process.env.ARTPLAYER_DASH_DIAGNOSTIC_SDK || 'none'
assert(['none', 'upstream4', 'bufferlevel4'].includes(diagnosticSDK), 'Unknown diagnostic SDK mode')
const recoveryModes = ['ARTPLAYER_DASH_DIAGNOSE_STALL', 'ARTPLAYER_DASH_DIAGNOSE_GETTER', 'ARTPLAYER_DASH_DIAGNOSE_METRICS'].filter(name => process.env[name] === '1')
assert(recoveryModes.length <= 1, 'Choose only one DASH recovery diagnostic')
assert(!recoveryModes.length || diagnosticSDK === 'none', 'Recovery diagnostics require the unchanged published SDK')
assert(!process.env.ARTPLAYER_BROWSER_ARTIFACTS || (diagnosticSDK === 'none' && recoveryModes.length === 0), 'Installed DASH checks cannot use SDK or recovery diagnostics')
test.beforeAll(async () => {
const baseline = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/dash-sdk.json', import.meta.url)))
for (const sdk of baseline.sdks) {
const archive = await ensureArchive(sdk.release)
const bytes = readMember(archive, sdk.codeMember)
assert.equal(hash(bytes), sdk.release.files[sdk.codeMember])
let code = bytes.toString()
let diagnostic
if (sdk.release.version === '4.5.2' && diagnosticSDK !== 'none') {
const member = 'package/dist/dash.all.debug.js'
code = readMember(archive, member).toString()
const upstreamSHA256 = hash(code)
if (diagnosticSDK === 'bufferlevel4') {
const before = 'function clearBuffers(ranges) {\n return new Promise(function (resolve, reject) {\n if (!ranges || !sourceBufferSink || ranges.length === 0) {\n resolve();'
assert.equal(code.split(before).length, 2, 'Diagnostic patch must match exactly one upstream branch')
code = code.replace(before, before.replace(' resolve();', ' _updateBufferLevel();\n resolve();'))
}
diagnostic = { mode: diagnosticSDK, member, upstreamSHA256, effectiveSHA256: hash(code), acceptance: false }
}
sdks.set(sdk.release.version, { ...sdk, code, diagnostic })
}
pluginRelease = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/dash-control-release.json', import.meta.url))).release
const bytes = readMember(await ensureArchive(pluginRelease), 'package/dist/artplayer-plugin-dash-control.js')
assert.equal(hash(bytes), pluginRelease.files['package/dist/artplayer-plugin-dash-control.js'])
published = bytes.toString()
const input = await browserCandidate('artplayer-plugin-dash-control', process.env.ARTPLAYER_DASH_ARTIFACT)
candidate = input.code
candidateProvenance = input.provenance
mediaManifest = JSON.parse(fs.readFileSync(new URL('./media/dash/manifest.json', import.meta.url)))
for (const [name, expected] of Object.entries(mediaManifest.files)) {
assert(/^[\w.-]+$/.test(name))
const bytes = fs.readFileSync(new URL(`./media/dash/${name}`, import.meta.url))
assert.equal(hash(bytes), expected.sha256)
assert.equal(bytes.length, expected.bytes)
media.set(name, bytes)
}
})
test.afterEach(async ({ page }, testInfo) => {
const state = await page.evaluate(() => {
const art = window.art
const dash = art?.dash || window.nativeDash
let tracks
let levels
try {
tracks = dash?.getTracksFor('audio').map(track => ({ id: track.id, index: track.index, lang: track.lang }))
levels = (dash?.getRepresentationsByType?.('video') || dash?.getBitrateInfoListFor?.('video'))?.map(level => ({ id: level.id, qualityIndex: level.qualityIndex, height: level.height }))
}
catch (error) {
levels = { error: error.message }
}
const video = art?.video || window.nativeVideo
const buffered = video ? Array.from({ length: video.buffered.length }, (_, index) => [video.buffered.start(index), video.buffered.end(index)]) : []
return { errors: window.sdkErrors, events: window.sdkEvents, mediaEvents: window.mediaEvents, nativePlay: window.nativePlay, tracks, levels, buffered, paused: video?.paused, seeking: video?.seeking, time: video?.currentTime, ready: art?.isReady, width: video?.videoWidth, height: video?.videoHeight, error: video?.error?.code, readyState: video?.readyState, destroyed: window.sdkDestroyed, bufferState: window.dashBuffers?.snapshot(), bufferEvents: window.dashBuffers?.events }
}).catch(error => ({ error: error.message }))
await testInfo.attach('dash-sdk-state', { contentType: 'application/json', body: JSON.stringify(state) })
// Opt-in counterfactual after a failed assertion; it never turns a failure green.
if (process.env.ARTPLAYER_DASH_DIAGNOSE_STALL === '1' && testInfo.status !== testInfo.expectedStatus && state.seeking && state.time === 6) {
const recovery = await page.evaluate(() => {
const before = window.dashBuffers.snapshot()
const video = window.art?.video || window.nativeVideo
video.dispatchEvent(new Event('timeupdate'))
return { before, afterDispatch: window.dashBuffers.snapshot() }
})
recovery.advanced = await page.waitForFunction(() => (window.art?.video || window.nativeVideo).currentTime > 6.2, null, { timeout: 3000 }).then(() => true, () => false)
recovery.final = await page.evaluate(() => window.dashBuffers.snapshot())
await testInfo.attach('diagnostic-synthetic-timeupdate', { contentType: 'application/json', body: JSON.stringify(recovery) })
}
if (process.env.ARTPLAYER_DASH_DIAGNOSE_GETTER === '1' && testInfo.status !== testInfo.expectedStatus && state.seeking && state.time === 6) {
const recovery = await page.evaluate(probeBufferGetter)
recovery.advanced = await page.waitForFunction(() => (window.art?.video || window.nativeVideo).currentTime > 6.2, null, { timeout: 3000 }).then(() => true, () => false)
recovery.final = await page.evaluate(() => {
for (const restore of window.dashGetterRestores)
restore()
return { state: window.dashBuffers.snapshot(), records: window.dashGetterRecords }
})
await testInfo.attach('diagnostic-buffer-getter', { contentType: 'application/json', body: JSON.stringify(recovery) })
}
if (process.env.ARTPLAYER_DASH_DIAGNOSE_METRICS === '1' && testInfo.status !== testInfo.expectedStatus && state.seeking && state.time === 6) {
const recovery = await page.evaluate(probeBufferMetric)
recovery.advanced = await page.waitForFunction(() => (window.art?.video || window.nativeVideo).currentTime > 6.2, null, { timeout: 3000 }).then(() => true, () => false)
recovery.final = await page.evaluate(() => window.dashBuffers.snapshot())
await testInfo.attach('diagnostic-buffer-metric', { contentType: 'application/json', body: JSON.stringify(recovery) })
}
if (!page.isClosed()) {
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy()
window.nativeDash?.destroy()
})
}
})
async function loadSDK(page, version, core, testInfo) {
const sdk = sdks.get(version)
await page.goto(`/test/player.html?core=${core}`)
await page.evaluate(observeDashBuffers)
await page.addScriptTag({ content: sdk.code })
const capability = await page.evaluate(() => ({ sdk: window.dashjs.supportsMediaSource(), mse: typeof window.MediaSource, managed: typeof window.ManagedMediaSource, avc: Boolean(window.MediaSource?.isTypeSupported('video/mp4; codecs="avc1.42c01e"')), aac: Boolean(window.MediaSource?.isTypeSupported('audio/mp4; codecs="mp4a.40.2"')) }))
await testInfo.attach('dash-sdk-inputs', { contentType: 'application/json', body: JSON.stringify({ sdk: sdk.release, codeMember: sdk.diagnostic?.member || sdk.codeMember, diagnostic: sdk.diagnostic, pluginRelease, candidateSHA256: hash(candidate), availableCandidate: candidateProvenance, pluginLoaded: false, media: mediaManifest, capability }) })
return capability
}
async function openDash(page, version, core, plugin, testInfo) {
const capability = await loadSDK(page, version, core, testInfo)
test.skip(!capability.sdk, 'Actual SDK reports no MediaSource support; this engine has no DASH playback acceptance')
await routeMedia(page)
await testInfo.attach('dash-plugin-inputs', { contentType: 'application/json', body: JSON.stringify({ version, core, plugin, selected: plugin === 'published' ? { kind: 'published', sha256: hash(published) } : candidateProvenance }) })
await page.addScriptTag({ content: plugin === 'published' ? published : candidate })
await page.evaluate(() => {
window.sdkErrors = []
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(window.dashOption)],
customType: { mpd(video, url, art) {
if (art.dash) {
art.dash.destroy()
window.sdkDestroyed++
}
const dash = window.dashjs.MediaPlayer().create()
art.dash = dash
dash.updateSettings({ debug: { logLevel: 0 }, streaming: { abr: { initialBitrate: { video: 150 } }, buffer: { fastSwitchEnabled: true, bufferTimeDefault: 2, bufferTimeAtTopQuality: 2 } } })
const events = window.dashjs.MediaPlayer.events
dash.on(events.ERROR, event => window.sdkErrors.push({ code: event.error?.code, message: event.error?.message, event: event.type }))
for (const name of ['STREAM_INITIALIZED', 'QUALITY_CHANGE_REQUESTED', 'QUALITY_CHANGE_RENDERED', 'TRACK_CHANGE_RENDERED', 'STREAM_TEARDOWN_COMPLETE', 'BUFFER_LEVEL_STATE_CHANGED', 'BUFFER_LEVEL_UPDATED', 'FRAGMENT_LOADING_COMPLETED', 'PLAYBACK_SEEKING', 'PLAYBACK_SEEKED']) {
if (events[name])
dash.on(events[name], event => window.sdkEvents.push({ name, at: performance.now(), mediaType: event.mediaType, oldQuality: event.oldQuality, newQuality: event.newQuality, state: event.state, bufferLevel: event.bufferLevel, url: event.request?.url, time: video.currentTime, seekTime: event.seekTime }))
}
dash.initialize(video, url, false)
} },
})
const art = window.art
for (const name of ['play', 'playing', 'pause', 'seeking', 'seeked', 'waiting', 'timeupdate', 'emptied', 'loadedmetadata', 'loadeddata', 'canplay', 'error']) {
art.video.addEventListener(name, () => {
window.mediaEvents.push({ name, at: performance.now(), time: art.video.currentTime, paused: art.video.paused, readyState: art.video.readyState, buffered: Array.from({ length: art.video.buffered.length }, (_, index) => [art.video.buffered.start(index), art.video.buffered.end(index)]) })
window.dashBuffers.mark(`media:${name}`)
})
}
art.on('destroy', () => {
art.dash.destroy()
window.sdkDestroyed++
})
document.querySelector('#play').onclick = () => art.play()
document.querySelector('#pause').onclick = () => art.pause()
})
}
async function routeMedia(page) {
await page.route('**/dash-fixture/**', async (route) => {
const name = new URL(route.request().url()).pathname.split('/').at(-1)
const bytes = media.get(name)
await route.fulfill({ status: bytes ? 200 : 503, body: bytes || 'Intentional DASH fixture failure', contentType: name.endsWith('.mpd') ? 'application/dash+xml' : 'video/mp4' })
})
}
for (const version of ['4.5.2', '5.2.1']) {
test(`dash.js ${version}: actual SDK reports MSE capabilities and version`, async ({ page }, testInfo) => {
const capability = await loadSDK(page, version, 'candidate', testInfo)
// This capability probe creates no media resources; destroy requires initialize.
const actual = await page.evaluate(() => window.dashjs.MediaPlayer().create().getVersion())
expect(actual).toBe(version)
if (capability.mse === 'undefined' && capability.managed === 'undefined')
expect(capability.sdk).toBe(false)
else
expect(capability.sdk).toBe(true)
})
}
for (const core of ['published', 'candidate']) {
for (const [plugin, version] of [['published', '4.5.2'], ['candidate', '4.5.2'], ['candidate', '5.2.1']]) {
test(`${core} core / ${plugin} DASH / ${version}: real MPD decoding and UI selection`, async ({ page }, testInfo) => {
await openDash(page, version, core, plugin, 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)
expect(await page.evaluate(() => window.art.dash.getTracksFor('audio').map(track => track.lang))).toEqual(['en', 'fr'])
expect(await page.evaluate(() => window.art.video.getVideoPlaybackQuality().totalVideoFrames)).toBeGreaterThan(0)
const control = page.locator('.art-control-dash-quality')
await control.hover()
await control.locator('.art-selector-item').filter({ hasText: /^180p$/ }).click()
await expect.poll(() => page.evaluate(() => ({ auto: window.art.dash.getSettings().streaming.abr.autoSwitchBitrate.video, height: window.art.video.videoHeight }))).toEqual({ auto: false, height: 180 })
await control.hover()
await control.locator('.art-selector-item').filter({ hasText: /^Auto$/ }).click()
expect(await page.evaluate(() => window.art.dash.getSettings().streaming.abr.autoSwitchBitrate.video)).toBe(true)
const audio = page.locator('.art-control-dash-audio')
await audio.hover()
await audio.locator('.art-selector-item').filter({ hasText: /^fr$/ }).click()
await expect.poll(() => page.evaluate(() => window.art.dash.getCurrentTrackFor('audio').lang)).toBe('fr')
await expect(audio.locator('.art-selector-value')).toHaveText('fr')
expect(await page.evaluate(() => window.art.setting.find('dash-audio').tooltip)).toBe('fr')
await page.locator('#pause').click()
expect(await page.evaluate(() => window.art.video.paused)).toBe(true)
await page.evaluate(() => {
window.dashBuffers.mark('before-seek')
window.art.seek = 6
window.dashBuffers.mark('assigned-seek')
})
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(6.2)
expect(await page.evaluate(() => window.sdkErrors)).toEqual([])
await testInfo.attach('dash-sdk-playing', { contentType: 'image/png', body: await page.screenshot() })
})
}
}
async function seekAtEmptyBoundary(page) {
await expect.poll(() => page.evaluate(() => (window.art?.video || window.nativeVideo).readyState)).toBeGreaterThanOrEqual(2)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => (window.art?.video || window.nativeVideo).currentTime)).toBeGreaterThan(0.3)
await page.evaluate(() => {
const dash = (window.art?.dash || window.nativeDash)
dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: false } } } })
if (dash.setQualityFor)
dash.setQualityFor('video', 1)
else
dash.setRepresentationForTypeById('video', '1')
})
await expect.poll(() => page.evaluate(() => (window.art?.video || window.nativeVideo).videoHeight)).toBe(180)
// Match the failed integrated run: video data ends at the next seek target.
await expect.poll(() => page.evaluate(() => {
const video = (window.art?.video || window.nativeVideo)
return video.currentTime >= 2.8 && Math.abs(video.buffered.end(video.buffered.length - 1) - 6) < 0.0001
})).toBe(true)
await page.evaluate(() => {
const dash = (window.art?.dash || window.nativeDash)
dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: true } } } })
dash.setCurrentTrack(dash.getTracksFor('audio').find(track => track.lang === 'fr'))
})
await expect.poll(() => page.evaluate(() => (window.art?.dash || window.nativeDash).getCurrentTrackFor('audio').lang)).toBe('fr')
// The recorded integrated stall happened after the audio switch was rendered.
await expect.poll(() => page.evaluate(() => window.sdkEvents.some(event => event.name === 'TRACK_CHANGE_RENDERED' && event.mediaType === 'audio'))).toBe(true)
await page.locator('#pause').click()
// Observe a genuinely held pause: no clock/append events for half a second.
// This separates the seek from progress events still queued by the track switch.
await page.waitForFunction(() => (window.art?.video || window.nativeVideo).paused && performance.now() - window.dashBuffers.events.at(-1).at >= 500)
expect(await page.evaluate(() => (window.art?.video || window.nativeVideo).paused)).toBe(true)
await page.evaluate(() => {
window.dashBuffers.mark('before-seek')
if (window.art)
window.art.seek = 6
else
window.nativeVideo.currentTime = 6
window.dashBuffers.mark('assigned-seek')
})
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => (window.art?.video || window.nativeVideo).currentTime)).toBeGreaterThan(6.2)
}
for (const core of ['published', 'candidate']) {
for (const version of ['4.5.2', '5.2.1']) {
test(`${core} core / candidate DASH ${version}: stable paused boundary seek preserves media and SDK ownership`, async ({ page }, testInfo) => {
await openDash(page, version, core, 'candidate', testInfo)
await seekAtEmptyBoundary(page)
expect(await page.evaluate(() => window.sdkErrors)).toEqual([])
expect(await page.evaluate(() => window.art.dash.getVideoElement() === window.art.video && window.sdkDestroyed === 0)).toBe(true)
})
}
}
for (const version of ['4.5.2', '5.2.1']) {
test(`dash.js ${version}: native SDK track switch and paused seek without ArtPlayer`, async ({ page }, testInfo) => {
const capability = await loadSDK(page, version, 'candidate', testInfo)
test.skip(!capability.sdk, 'Actual SDK reports no MediaSource support; no native DASH playback acceptance')
await routeMedia(page)
await page.evaluate(() => {
const video = document.createElement('video')
window.nativeVideo = video
video.muted = true
document.querySelector('.player').append(video)
const dash = window.dashjs.MediaPlayer().create()
window.nativeDash = dash
window.sdkEvents = []
window.sdkErrors = []
window.mediaEvents = []
dash.updateSettings({ debug: { logLevel: 0 }, streaming: { abr: { initialBitrate: { video: 150 } }, buffer: { fastSwitchEnabled: true, bufferTimeDefault: 2, bufferTimeAtTopQuality: 2 } } })
const events = window.dashjs.MediaPlayer.events
dash.on(events.ERROR, event => window.sdkErrors.push({ code: event.error?.code, message: event.error?.message }))
for (const name of ['FRAGMENT_LOADING_COMPLETED', 'TRACK_CHANGE_RENDERED', 'QUALITY_CHANGE_RENDERED', 'BUFFER_LEVEL_STATE_CHANGED', 'BUFFER_LEVEL_UPDATED', 'PLAYBACK_SEEKING', 'PLAYBACK_SEEKED']) {
dash.on(events[name], event => window.sdkEvents.push({ name, at: performance.now(), mediaType: event.mediaType, state: event.state, bufferLevel: event.bufferLevel, url: event.request?.url, time: video.currentTime, seekTime: event.seekTime }))
}
for (const name of ['play', 'playing', 'pause', 'seeking', 'seeked', 'waiting', 'timeupdate']) {
video.addEventListener(name, () => {
window.mediaEvents.push({ name, at: performance.now(), time: video.currentTime, paused: video.paused, readyState: video.readyState, buffered: Array.from({ length: video.buffered.length }, (_, index) => [video.buffered.start(index), video.buffered.end(index)]) })
window.dashBuffers.mark(`media:${name}`)
})
}
dash.initialize(video, '/dash-fixture/master.mpd', false)
document.querySelector('#play').onclick = () => {
window.nativePlay = 'pending'
video.play().then(() => {
window.nativePlay = 'fulfilled'
}, (error) => {
window.nativePlay = `${error.name}: ${error.message}`
})
}
document.querySelector('#pause').onclick = () => video.pause()
})
await seekAtEmptyBoundary(page)
expect(await page.evaluate(() => window.nativePlay)).toBe('fulfilled')
expect(await page.evaluate(() => window.sdkErrors)).toEqual([])
expect(await page.evaluate(() => window.Artplayer.instances.length)).toBe(0)
})
}
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}: SDK refresh preserves the open ${type} settings panel`, 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.locator('.art-control-setting').click()
await page.locator(`[data-name="dash-${type}"]`).click()
await page.evaluate((type) => {
const { dash } = window.art
if (type === 'quality') {
dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: false } } } })
if (dash.setQualityFor)
dash.setQualityFor('video', 1)
else
dash.setRepresentationForTypeById('video', '1')
}
else {
dash.setCurrentTrack(dash.getTracksFor('audio').find(track => track.lang === 'fr'))
}
}, type)
const changed = type === 'quality' ? '180p' : 'fr'
await expect(page.locator(`.art-control-dash-${type} .art-selector-value`)).toHaveText(changed)
await expect.poll(() => page.evaluate(type => window.art.setting.active === window.art.setting.find(`dash-${type}`).selector, type)).toBe(true)
const label = type === 'quality' ? '90p' : 'en'
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(() => window.art.video.videoHeight)).toBe(90)
else
await expect.poll(() => page.evaluate(() => window.art.dash.getCurrentTrackFor('audio').lang)).toBe('en')
await expect(page.locator(`.art-control-dash-${type} .art-selector-value`)).toHaveText(label)
expect(await page.evaluate(() => window.sdkErrors)).toEqual([])
})
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)
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 page.evaluate(() => window.art.plugins.artplayerPluginDashControl.update())
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 } } } })
window.art.plugins.artplayerPluginDashControl.update()
})
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}: actual source topology replacement and owned cleanup`, 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(() => {
const quality = window.art.controls.cache.get('dash-quality').option
const audio = window.art.setting.find('dash-audio')
// Registry update mutates the option object; retain the actual old callback.
window.retainedQuality = { onSelect: quality.onSelect, item: quality.selector[0] }
window.retainedAudio = { onSelect: audio.onSelect, item: audio.selector[1] }
})
await page.evaluate(() => window.art.switchUrl('/dash-fixture/video-only.mpd'))
await expect(page.locator('.art-control-dash-audio')).toHaveCount(0)
expect(await page.evaluate(() => Boolean(window.art.setting.find('dash-audio')))).toBe(false)
expect(await page.evaluate(() => window.art.dash.getTracksFor('audio'))).toEqual([])
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
await page.evaluate(() => {
window.retainedQuality.onSelect(window.retainedQuality.item)
window.retainedAudio.onSelect(window.retainedAudio.item)
})
expect(await page.evaluate(() => window.art.dash.getSettings().streaming.abr.autoSwitchBitrate.video)).toBe(true)
await page.evaluate(() => window.art.switchUrl('/dash-fixture/single.mpd'))
await expect(page.locator('.art-control-dash-quality .art-selector-item')).toHaveText(['90p', 'Auto'])
await expect.poll(() => page.evaluate(() => window.art.video.videoHeight)).toBe(90)
await page.evaluate(() => window.art.switchUrl('/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(3)
expect(await page.evaluate(() => window.sdkErrors)).toEqual([])
await page.evaluate(() => window.art.destroy(false))
expect(await page.evaluate(() => window.sdkDestroyed)).toBe(4)
await expect(page.locator('.art-control-dash-quality, .art-control-dash-audio')).toHaveCount(0)
})
}
}