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) }) } }