Files
ArtPlayer/test/browser/mediabunny-hls.spec.js

209 lines
13 KiB
JavaScript

import assert from 'node:assert/strict'
import fs from 'node:fs'
import { hash } from '../../refactor/scripts/releases.mjs'
import { mbCandidate } from '../helpers/mediabunny.js'
import { expect, test } from './fixtures.js'
const implementation = await mbCandidate()
test.beforeEach(async ({ browserName }, testInfo) => {
await testInfo.attach('mediabunny-available-inputs', { contentType: 'application/json', body: JSON.stringify([implementation].map(item => ({ browserName, name: item.name, sha256: hash(item.code), provenance: item.provenance || { kind: item.name } }))) })
})
const manifest = JSON.parse(fs.readFileSync(new URL('./media/hls/manifest.json', import.meta.url)))
const media = new Map(Object.entries(manifest.files).map(([name, expected]) => {
const bytes = fs.readFileSync(new URL(`./media/hls/${name}`, import.meta.url))
assert.equal(hash(bytes), expected.sha256)
return [name, bytes]
}))
for (const core of ['published', 'candidate']) {
for (const scenario of ['menus', 'topology', 'stale-source', 'stale-destroy', 'latest']) {
test(`MediaBunny ${implementation.name}: ${core} core native HLS ${scenario}`, async ({ page, browserName }, testInfo) => {
await page.route('**/mb-ui-hls/**', async (route) => {
const name = new URL(route.request().url()).pathname.split('/').at(-1)
const body = media.get(name)
await route.fulfill({ status: body ? 200 : 404, body: body || 'Missing fixture', contentType: name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t' })
})
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports;
${implementation.code}; window.mbFactory = module.exports.default || module.exports; })();` })
await page.evaluate(() => {
window.mbEvents = []
window.mbErrors = []
window.mbWrites = []
window.mbOption = { m3u8: { quality: { control: true, setting: true }, audio: { control: true, setting: true } } }
window.mbFlush = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
window.art = new window.Artplayer({ container: '.player', url: '/mb-ui-hls/master.m3u8', muted: true, setting: true, proxy: window.mbFactory(window.mbOption) })
window.mbCanvas = window.art.video
for (const event of ['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'seeked', 'error'])
window.art.on(`video:${event}`, () => window.mbEvents.push(event))
document.querySelector('#play').onclick = () => window.art.play().catch(error => window.mbErrors.push(error.message))
})
const capabilities = await page.evaluate(() => ({ AudioContext: typeof AudioContext, webkitAudioContext: typeof window.webkitAudioContext, AudioDecoder: typeof AudioDecoder, VideoDecoder: typeof VideoDecoder, coreVersion: window.Artplayer.version }))
let outcome = 'unverified'
let result
try {
if (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') {
expect(browserName).toBe('webkit')
await expect.poll(() => page.evaluate(() => window.mbCanvas.error?.code)).toBe(4)
expect(await page.evaluate(() => window.art.isReady)).toBe(false)
expect(await page.locator('.art-control-mediabunny-quality').count()).toBe(0)
outcome = 'unsupported-capability-control'
return
}
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await expect(page.locator('.art-control-mediabunny-quality .art-selector-value')).toHaveText('180P')
await expect(page.locator('.art-control-mediabunny-audio .art-selector-value')).toHaveText('English')
await page.evaluate(() => {
for (const name of ['controls', 'setting']) {
const registry = window.art[name]
const update = registry.update.bind(registry)
registry.update = (option) => {
if (option.name?.startsWith('mediabunny-'))
window.mbWrites.push({ surface: name, name: option.name })
return update(option)
}
}
})
if (scenario === 'menus') {
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(0.2)
await page.evaluate(() => {
window.art.pause()
window.mbEvents.length = 0
})
const quality = page.locator('.art-control-mediabunny-quality')
await quality.hover()
await quality.locator('.art-selector-item').filter({ hasText: /^90P$/ }).click()
await expect.poll(() => page.evaluate(() => window.mbCanvas.getM3u8State().then(state => state?.currentLevel?.height))).toBe(90)
await expect(page.locator('.art-control-mediabunny-quality .art-selector-value')).toHaveText('90P')
await expect.poll(() => page.evaluate(() => Object.fromEntries(['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'seeked'].map(name => [name, window.mbEvents.filter(event => event === name).length])))).toEqual({ loadedmetadata: 1, loadeddata: 1, canplay: 1, canplaythrough: 1, seeked: 1 })
await page.evaluate(() => {
const menu = window.art.setting.find('mediabunny-audio')
window.art.setting.show = true
window.art.setting.render(menu.selector)
menu.selector.find(item => item.html === 'French').$item.click()
})
await expect.poll(() => page.evaluate(() => window.mbCanvas.getM3u8State().then(state => state?.currentAudio?.lang))).toBe('fr')
await expect(page.locator('.art-control-mediabunny-audio .art-selector-value')).toHaveText('French')
await expect.poll(() => page.evaluate(() => window.mbEvents.filter(event => event === 'seeked').length)).toBe(2)
await page.evaluate(() => {
window.art.setting.show = false
})
await quality.hover()
await quality.locator('.art-selector-item').filter({ hasText: /^Auto$/ }).click()
await expect.poll(() => page.evaluate(() => window.mbCanvas.getM3u8State().then(state => state?.videoMode))).toBe('auto')
await expect(page.locator('.art-control-mediabunny-quality .art-selector-value')).toHaveText('180P')
await expect.poll(() => page.evaluate(() => Object.fromEntries(['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'seeked'].map(name => [name, window.mbEvents.filter(event => event === name).length])))).toEqual({ loadedmetadata: 3, loadeddata: 3, canplay: 3, canplaythrough: 3, seeked: 3 })
result = await page.evaluate(async () => {
const state = await window.mbCanvas.getM3u8State()
const quality = window.art.controls.cache.get('mediabunny-quality').option
const audio = window.art.setting.find('mediabunny-audio')
return { currentLevel: state.currentLevel.id, qualitySelected: quality.selector.filter(item => item.default).map(item => item.value), currentAudio: state.currentAudio.id, audioSelected: audio.selector.filter(item => item.default).map(item => item.value), paired: state.currentLevel.track.canBePairedWith(state.currentAudio.track), errors: window.mbErrors }
})
expect(result.qualitySelected).toEqual([result.currentLevel])
expect(result.audioSelected).toEqual([result.currentAudio])
expect(result.paired).toBe(true)
expect(result.errors).toEqual([])
}
else if (scenario === 'topology') {
await page.evaluate(() => {
window.mbOption.m3u8.quality.control = false
window.mbOption.m3u8.audio.control = false
window.art.emit('restart')
})
await expect(page.locator('.art-control-mediabunny-quality')).toHaveCount(0)
await expect(page.locator('.art-control-mediabunny-audio')).toHaveCount(0)
expect(await page.evaluate(() => Boolean(window.art.setting.find('mediabunny-quality') && window.art.setting.find('mediabunny-audio')))).toBe(true)
await page.evaluate(async () => {
window.mbOption.m3u8.quality.control = true
window.mbOption.m3u8.audio.control = true
await window.mbCanvas.engine.load('/mb-ui-hls/video-only.m3u8')
})
await expect(page.locator('.art-control-mediabunny-quality')).toHaveCount(1)
await expect(page.locator('.art-control-mediabunny-audio')).toHaveCount(0)
expect(await page.evaluate(() => Boolean(window.art.setting.find('mediabunny-audio')))).toBe(false)
await page.evaluate(() => window.mbCanvas.engine.load('/test/pattern.mp4'))
await expect(page.locator('[class*="art-control-mediabunny-"]')).toHaveCount(0)
expect(await page.evaluate(() => Boolean(window.art.setting.find('mediabunny-quality') || window.art.setting.find('mediabunny-audio')))).toBe(false)
result = { settingsSurviveControlDisable: true, audioRemovedForVideoOnly: true, allRemovedForMp4: true }
}
else if (scenario.startsWith('stale-')) {
await page.evaluate(() => {
const engine = window.mbCanvas.engine
const getState = engine.getHlsState.bind(engine)
let first = true
engine.getHlsState = async () => {
const state = await getState()
if (first) {
first = false
return new Promise((resolve) => {
window.mbRelease = () => resolve(state)
})
}
return state
}
window.art.emit('restart')
})
await expect.poll(() => page.evaluate(() => typeof window.mbRelease)).toBe('function')
result = await page.evaluate(async (scenario) => {
if (scenario === 'stale-source')
await window.mbCanvas.engine.load('/test/pattern.mp4')
else window.art.destroy(false)
const before = window.mbWrites.length
window.mbRelease()
await window.mbFlush()
return { before, after: window.mbWrites.length, menus: document.querySelectorAll('[class*="art-control-mediabunny-"]').length }
}, scenario)
expect(result.after).toBe(result.before)
expect(result.menus).toBe(0)
}
else {
await page.evaluate(async () => {
const engine = window.mbCanvas.engine
const input = engine.input
const getTracks = input.getVideoTracks.bind(input)
const tracks = await getTracks()
window.mbDesired = tracks.find(track => track.id !== engine.media.videoTrack.id).id
let calls = 0
input.getVideoTracks = () => {
calls++
if (calls === 1)
return new Promise((resolve) => { window.mbReleaseOlder = () => resolve(tracks) })
if (calls === 2)
return new Promise((resolve) => { window.mbReleaseNewer = () => resolve(tracks) })
return getTracks()
}
window.mbOlder = window.mbCanvas.switchM3u8Quality(engine.media.videoTrack.id)
window.mbNewer = window.mbCanvas.switchM3u8Quality(window.mbDesired)
window.mbReleaseOlder()
await window.mbOlder
window.mbReleaseNewer()
await window.mbNewer
})
result = await page.evaluate(async () => {
const state = await window.mbCanvas.getM3u8State()
return { desired: window.mbDesired, selected: state.currentLevel.id, ready: window.mbCanvas.readyState, events: window.mbEvents }
})
expect(result.selected).toBe(result.desired)
expect(result.ready).toBe(4)
}
outcome = 'native-hls-ui-and-selection-observed'
}
finally {
const state = await page.evaluate(() => {
window.mbRelease?.()
window.mbReleaseOlder?.()
window.mbReleaseNewer?.()
window.mbContext = window.mbCanvas.engine.audio.audioContext
window.art.destroy(false)
return { events: window.mbEvents, errors: window.mbErrors, writes: window.mbWrites, error: window.mbCanvas.error, destroyed: window.mbCanvas.engine.destroyed }
})
await expect.poll(() => page.evaluate(() => window.mbContext?.state || 'not-created')).toMatch(/^(?:closed|not-created)$/)
await testInfo.attach('mediabunny-hls-ui', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, provenance: implementation.provenance, sha256: hash(implementation.code), core, scenario, capabilities, outcome, result, state, scope: 'Actual old/candidate core, SDK HLS parsing and native decoding; rendered control/settings clicks, topology and controlled state/query delays. Input provenance identifies source or installed bundle; not long-run/device acceptance.' }) })
}
})
}
}