test(hls): [PKG-HLS-02] cover released contracts and real SDK playback

This commit is contained in:
Harvey Zhao committed 2026-09-11 23:53:16 +08:00
1 parent cbbdc03a7c
commit 33f6d19b77
55 files changed
+8678 -14

No files matched your search

+7
View File
@@ -14,6 +14,13 @@ Use the pinned Node/Yarn toolchain from `../refactor/toolchain-setup.md`.
`helpers/playback.js` creates the controlled media facade used by the original playback regressions. It models only the exercised methods and events. It is not HTMLMediaElement validation; actual playback remains in browser tests.
`node --test test/hls-control.test.js` runs shared HLS control contracts against workspace source
and integrity-checked published main/legacy/ESM. It is included in `yarn test:unit`.
`helpers/hls-control.js` owns the controlled SDK/registry host; it does not simulate decoding or ABR.
Published-only tests preserve historical bug observations without requiring candidate code to keep them.
Real Hls.js 1.5.17 playback is in `test/browser/hls-control.spec.js`; see its environment limits in
`refactor/hls-validation.md`. Test SDK download is pinned and verified, not an installed runtime dependency.
`contracts/emitter.js` owns public assertions, independent of module layout. `public-behavior.test.js` runs them against the integrity-checked published core and current bundled source. The combined chain/context contract corresponds to BASE-03 EVENT.chain and EVENT.context-arguments; other EVENT IDs retain their baseline names. Exceptions, callback identity, mutation during dispatch and once reentry must not be weakened during migration.
Set `ARTPLAYER_TEST_CORE` to a built `.js`, `.legacy.js` or `.mjs` core file to add a candidate to the same public-contract run, then execute `node --test test/public-behavior.test.js`. An invalid path/export fails the run. This is a narrow event contract check, not isolated package installation or complete API compatibility; ENG-07 owns tarball consumption.
+6
View File
@@ -15,6 +15,12 @@ no retries, a loopback server on 8084, and no existing dev server. Set `ARTPLAYE
to use another free port. The server builds core and chapter from current JS/TS entries in memory;
it does not modify dist or docs outputs. Restart the test command after source changes.
`hls-control.spec.js` separately builds HLS control and verifies a pinned Hls.js 1.5.17 archive.
Its local playlists/segments are generated fixtures with checked hashes. Windows WebKit lacks MSE:
one explicit capability/error-cleanup test covers that limitation and its 14 playback cases are
skipped with a reason. Chromium/Firefox run the full four-combination HLS matrix. This is not
Safari playback approval; see [HLS validation](../../refactor/hls-validation.md) for evidence and open gates.
`playback.spec.js` tests published/current core with current chapter, real decoded frame colors,
media time advancing, pause, seek, source change to the existing docs video, destroy and real
HTTP failure. These are initial smoke contracts, not full chapter or lifecycle coverage.
+236
View File
@@ -0,0 +1,236 @@
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 { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
let sdkCode
let publishedCode
let sourceCode
let evidence
let media
test.afterEach(async ({ page }, testInfo) => {
const state = await page.evaluate(() => {
const art = window.art
return {
supported: window.Hls?.isSupported(),
mediaSource: typeof window.MediaSource,
managedMediaSource: typeof window.ManagedMediaSource,
video: art?.video.outerHTML,
readyState: art?.video.readyState,
networkState: art?.video.networkState,
sdkErrors: window.sdkErrors,
sdkEvents: window.sdkEvents,
levels: art?.hls.levels.map(level => ({ height: level.height, codec: level.videoCodec })),
tracks: art?.hls.audioTracks.map(track => ({ id: track.id, name: track.name })),
}
}).catch(error => ({ diagnosticError: error.message }))
await testInfo.attach('hls-state', { contentType: 'application/json', body: JSON.stringify(state, null, 2) })
})
test.beforeAll(async () => {
const sdk = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/hls-sdk.json', import.meta.url)))
const plugin = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/hls-control-release.json', import.meta.url)))
async function code(release, member) {
const bytes = readMember(await ensureArchive(release), member)
assert.equal(hash(bytes), release.files[member])
return bytes.toString()
}
sdkCode = await code(sdk.release, 'package/dist/hls.min.js')
publishedCode = await code(plugin.release, 'package/dist/artplayer-plugin-hls-control.js')
// This task exercises source builds. Installed artifacts are a separate HLS-06 gate.
assert(!process.env.ARTPLAYER_BROWSER_ARTIFACTS, 'HLS source fixture cannot masquerade as an installed plugin')
sourceCode = await compilePackage('artplayer-plugin-hls-control', 'umd')
const manifest = JSON.parse(fs.readFileSync(new URL('./media/hls/manifest.json', import.meta.url)))
media = new Map()
for (const [name, expected] of Object.entries(manifest.files)) {
assert(/^[\w.-]+$/.test(name))
const bytes = fs.readFileSync(new URL(`./media/hls/${name}`, import.meta.url))
assert.equal(hash(bytes), expected.sha256)
assert.equal(bytes.length, expected.bytes)
media.set(name, bytes)
}
evidence = { sdk: sdk.release, plugin: plugin.release, sourceSHA256: hash(sourceCode), media: manifest, limitations: ['SDK worker disabled', 'local deterministic media', 'source candidate, not installed package', 'no physical device certification'] }
})
async function openHls(page, core, plugin, testInfo, manifest = 'master.m3u8') {
await testInfo.attach('hls-inputs', { contentType: 'application/json', body: JSON.stringify(evidence) })
await page.route('**/hls-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 HLS failure', contentType: name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t' })
})
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: sdkCode })
await page.addScriptTag({ content: plugin === 'published' ? publishedCode : sourceCode })
await page.evaluate((manifest) => {
window.sdkErrors = []
window.sdkEvents = []
window.destroyedEngines = 0
window.art = new window.Artplayer({
container: '.player',
url: `/hls-fixture/${manifest}`,
muted: true,
setting: true,
plugins: [window.artplayerPluginHlsControl({ quality: { control: true, setting: true }, audio: { control: true, setting: true } })],
customType: { m3u8(video, url, art) {
if (art.hls)
art.hls.destroy()
const hls = new window.Hls({ enableWorker: false, manifestLoadingMaxRetry: 0 })
art.hls = hls
hls.on(window.Hls.Events.ERROR, (_, data) => window.sdkErrors.push({ type: data.type, details: data.details, fatal: data.fatal }))
for (const name of ['LEVEL_SWITCHED', 'AUDIO_TRACK_SWITCHED', 'MANIFEST_PARSED'])
hls.on(window.Hls.Events[name], (_, data) => window.sdkEvents.push({ name, level: data.level, id: data.id }))
hls.on(window.Hls.Events.DESTROYING, () => window.destroyedEngines++)
hls.loadSource(url)
hls.attachMedia(video)
} },
})
const art = window.art
art.on('destroy', () => art.hls.destroy())
document.querySelector('#play').onclick = () => art.play()
document.querySelector('#pause').onclick = () => art.pause()
}, manifest)
}
test('SDK MSE availability is explicit; unavailable hosts report failure and clean up', async ({ page, browserName }, testInfo) => {
await openHls(page, 'candidate', 'published', testInfo)
const capability = await page.evaluate(() => ({ supported: window.Hls.isSupported(), mse: typeof window.MediaSource, managed: typeof window.ManagedMediaSource }))
if (process.platform === 'win32' && browserName === 'webkit') {
expect(capability).toEqual({ supported: false, mse: 'undefined', managed: 'undefined' })
await expect.poll(() => page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([{ type: 'mediaError', details: 'manifestIncompatibleCodecsError', fatal: true }])
expect(await page.evaluate(() => window.art.isReady)).toBe(false)
}
else {
expect(capability.supported).toBe(true)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
expect(await page.evaluate(() => {
const sdk = window.art.hls
window.art.destroy()
return { media: sdk.media, destroyed: window.destroyedEngines }
})).toEqual({ media: null, destroyed: 1 })
})
test.describe('MSE playback matrix', () => {
// Verified by the separate capability test above. Safari/MSE coverage remains HLS-05 work.
test.skip(({ browserName }) => process.platform === 'win32' && browserName === 'webkit', 'Bundled Windows WebKit exposes neither MediaSource nor ManagedMediaSource; this is an unverified playback environment, not compatibility approval')
for (const core of ['published', 'candidate']) {
for (const plugin of ['published', 'candidate']) {
test(`${core} core / ${plugin} HLS: real decoding, manual quality, Auto and audio`, async ({ page }, testInfo) => {
await openHls(page, core, plugin, testInfo)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(0.3)
const initial = await page.evaluate(() => ({
supported: window.Hls.isSupported(),
version: window.Hls.version,
levels: window.art.hls.levels.map(level => level.height),
tracks: window.art.hls.audioTracks.map(track => ({ id: track.id, name: track.name })),
frames: window.art.video.getVideoPlaybackQuality().totalVideoFrames,
}))
expect(initial).toMatchObject({ supported: true, version: '1.5.17', levels: [90, 180], tracks: [{ id: 0, name: 'English' }, { id: 1, name: 'French' }] })
expect(initial.frames).toBeGreaterThan(0)
await page.evaluate(() => {
window.art.controls.show = true
})
const quality = page.locator('.art-control-hls-quality')
await quality.hover()
await quality.locator('.art-selector-item').filter({ hasText: /^90P$/ }).click()
await expect.poll(() => page.evaluate(() => ({ automatic: window.art.hls.autoLevelEnabled, level: window.art.hls.currentLevel, height: window.art.video.videoHeight }))).toEqual({ automatic: false, level: 0, height: 90 })
await quality.hover()
await quality.locator('.art-selector-item').filter({ hasText: /^180P$/ }).click()
await expect.poll(() => page.evaluate(() => ({ manual: window.art.hls.autoLevelEnabled, level: window.art.hls.currentLevel, height: window.art.video.videoHeight }))).toEqual({ manual: false, level: 1, height: 180 })
await quality.hover()
await quality.locator('.art-selector-item').filter({ hasText: /^Auto$/ }).click()
await expect.poll(() => page.evaluate(() => window.art.hls.autoLevelEnabled)).toBe(true)
await expect.poll(() => page.evaluate(() => window.art.hls.currentLevel)).toBeGreaterThanOrEqual(0)
await page.evaluate(() => {
window.art.controls.show = true
})
const audio = page.locator('.art-control-hls-audio')
await audio.hover()
await audio.locator('.art-selector-item').filter({ hasText: /^French$/ }).click()
await expect.poll(() => page.evaluate(() => window.sdkEvents.some(event => event.name === 'AUDIO_TRACK_SWITCHED' && event.id === 1))).toBe(true)
expect(await page.evaluate(() => window.art.hls.audioTrack)).toBe(1)
await page.click('#pause')
await expect.poll(() => page.evaluate(() => window.art.video.paused)).toBe(true)
await page.evaluate(() => {
window.art.seek = 5
})
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeCloseTo(5, 1)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(5.2)
expect(await page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([])
const result = await page.evaluate(() => {
const video = window.art.video
const canvas = document.createElement('canvas')
canvas.width = 16
canvas.height = 16
const ctx = canvas.getContext('2d')
ctx.drawImage(video, 0, 0, 16, 16)
const pixels = ctx.getImageData(0, 0, 16, 16).data
const colors = new Set()
for (let index = 0; index < pixels.length; index += 4)
colors.add(`${pixels[index]},${pixels[index + 1]},${pixels[index + 2]}`)
const sdk = window.art.hls
window.art.destroy()
return { colors: colors.size, media: sdk.media, destroyed: window.destroyedEngines, children: document.querySelector('.player').childElementCount }
})
expect(result.colors).toBeGreaterThan(8)
expect(result).toMatchObject({ media: null, destroyed: 1, children: 0 })
})
test(`${core} core / ${plugin} HLS: failed manifest and actual SDK teardown`, async ({ page }, testInfo) => {
await openHls(page, core, plugin, testInfo, 'missing.m3u8')
await expect.poll(() => page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([{ type: 'networkError', details: 'manifestLoadError', fatal: true }])
expect(await page.locator('.art-control-hls-quality').count()).toBe(0)
await page.evaluate(() => window.art.switchUrl('/hls-fixture/master.m3u8'))
await expect.poll(() => page.evaluate(() => window.art.hls.levels.length)).toBe(2)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(0.2)
expect(await page.evaluate(() => {
const sdk = window.art.hls
window.art.destroy()
return { destroyed: window.destroyedEngines, media: sdk.media }
})).toEqual({ destroyed: 2, media: null })
})
test(`${core} core / ${plugin} HLS: source switch drops SDK audio topology`, async ({ page }, testInfo) => {
await openHls(page, core, plugin, testInfo)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(0.2)
await page.evaluate(() => {
window.previousHls = window.art.hls
return window.art.switchUrl('/hls-fixture/video-only.m3u8')
})
await expect.poll(() => page.evaluate(() => ({ levels: window.art.hls.levels.length, tracks: window.art.hls.audioTracks.length, height: window.art.video.videoHeight }))).toEqual({ levels: 1, tracks: 0, height: 90 })
expect(await page.evaluate(() => ({ oldMedia: window.previousHls.media, replaced: window.previousHls !== window.art.hls, destroyed: window.destroyedEngines }))).toEqual({ oldMedia: null, replaced: true, destroyed: 1 })
if (plugin === 'published') {
// Real SDK corroboration of the frozen historical empty-topology defect.
await expect(page.locator('.art-control-hls-audio')).toHaveCount(1)
}
expect(await page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([])
await page.evaluate(() => window.art.destroy())
expect(await page.evaluate(() => window.destroyedEngines)).toBe(2)
})
}
test(`${core} core / published HLS: real Auto playback loses Auto label on update`, async ({ page }, testInfo) => {
await openHls(page, core, 'published', testInfo)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(0.2)
expect(await page.evaluate(() => window.art.hls.autoLevelEnabled)).toBe(true)
const height = await page.evaluate(() => {
const art = window.art
art.plugins.artplayerPluginHlsControl.update()
return art.hls.levels[art.hls.currentLevel].height
})
await expect(page.locator('.art-control-hls-quality .art-selector-value')).toHaveText(`${height}P`)
expect(await page.evaluate(() => window.art.hls.autoLevelEnabled)).toBe(true)
await page.evaluate(() => window.art.destroy())
})
}
})
+16
View File
@@ -0,0 +1,16 @@
# Deterministic HLS fixtures
Generated by `node scripts/generate-hls-fixture.mjs`, using FFmpeg recorded in manifest.json.
No downloaded film or audio: testsrc2 supplies moving patterns, sine supplies 440/880 Hz audio.
The 12-second master offers 160x90 and 320x180 AVC video, English/French AAC audio, and a
separate video-only master for topology changes. Each media playlist is VOD with 2-second segments.
Segments use `.mpegts` so source tools cannot mistake MPEG transport streams for TypeScript.
The HLS browser fixture serves them as video/mp2t. File contents and byte counts are checked
against manifest.json before browser use. Tests need neither FFmpeg nor a remote media service.
To change the media, generate into a new directory, for example
`node scripts/generate-hls-fixture.mjs refactor/.cache/hls-media-next`; inspect the output and
update the media, manifest and browser evidence together. The script refuses existing directories.
Set ARTPLAYER_FFMPEG to a particular FFmpeg executable if it is not on PATH. Re-encoding with
another FFmpeg version is not expected to preserve bytes; do not overwrite hashes to silence drift.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:2.005333,
english-00.mpegts
#EXTINF:2.005333,
english-01.mpegts
#EXTINF:2.005333,
english-02.mpegts
#EXTINF:1.984000,
english-03.mpegts
#EXTINF:2.005333,
english-04.mpegts
#EXTINF:2.005333,
english-05.mpegts
#EXTINF:0.021333,
english-06.mpegts
#EXT-X-ENDLIST
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:2.005333,
french-00.mpegts
#EXTINF:2.005333,
french-01.mpegts
#EXTINF:2.005333,
french-02.mpegts
#EXTINF:1.984000,
french-03.mpegts
#EXTINF:2.005333,
french-04.mpegts
#EXTINF:2.005333,
french-05.mpegts
#EXTINF:0.021333,
french-06.mpegts
#EXT-X-ENDLIST
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:2.000000,
high-00.mpegts
#EXTINF:2.000000,
high-01.mpegts
#EXTINF:2.000000,
high-02.mpegts
#EXTINF:2.000000,
high-03.mpegts
#EXTINF:2.000000,
high-04.mpegts
#EXTINF:2.000000,
high-05.mpegts
#EXT-X-ENDLIST
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:2.000000,
low-00.mpegts
#EXTINF:2.000000,
low-01.mpegts
#EXTINF:2.000000,
low-02.mpegts
#EXTINF:2.000000,
low-03.mpegts
#EXTINF:2.000000,
low-04.mpegts
#EXTINF:2.000000,
low-05.mpegts
#EXT-X-ENDLIST
+269
View File
@@ -0,0 +1,269 @@
{
"kind": "generated-hls-media",
"version": "ffmpeg version 2025-11-12-git-6cdd2cbe32-full_build-www.gyan.dev Copyright (c) 2000-2025 the FFmpeg developers",
"duration": 12,
"commands": [
[
"-hide_banner",
"-loglevel",
"error",
"-n",
"-f",
"lavfi",
"-i",
"testsrc2=size=160x90:rate=24",
"-t",
"12",
"-an",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-profile:v",
"baseline",
"-level:v",
"3.0",
"-g",
"48",
"-keyint_min",
"48",
"-sc_threshold",
"0",
"-crf",
"28",
"-f",
"hls",
"-hls_time",
"2",
"-hls_playlist_type",
"vod",
"-hls_segment_filename",
"low-%02d.mpegts",
"low.m3u8"
],
[
"-hide_banner",
"-loglevel",
"error",
"-n",
"-f",
"lavfi",
"-i",
"testsrc2=size=320x180:rate=24",
"-t",
"12",
"-an",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-profile:v",
"baseline",
"-level:v",
"3.0",
"-g",
"48",
"-keyint_min",
"48",
"-sc_threshold",
"0",
"-crf",
"28",
"-f",
"hls",
"-hls_time",
"2",
"-hls_playlist_type",
"vod",
"-hls_segment_filename",
"high-%02d.mpegts",
"high.m3u8"
],
[
"-hide_banner",
"-loglevel",
"error",
"-n",
"-f",
"lavfi",
"-i",
"sine=frequency=440:sample_rate=48000",
"-t",
"12",
"-vn",
"-c:a",
"aac",
"-b:a",
"48k",
"-ac",
"2",
"-f",
"hls",
"-hls_time",
"2",
"-hls_playlist_type",
"vod",
"-hls_segment_filename",
"english-%02d.mpegts",
"english.m3u8"
],
[
"-hide_banner",
"-loglevel",
"error",
"-n",
"-f",
"lavfi",
"-i",
"sine=frequency=880:sample_rate=48000",
"-t",
"12",
"-vn",
"-c:a",
"aac",
"-b:a",
"48k",
"-ac",
"2",
"-f",
"hls",
"-hls_time",
"2",
"-hls_playlist_type",
"vod",
"-hls_segment_filename",
"french-%02d.mpegts",
"french.m3u8"
]
],
"files": {
"english-00.mpegts": {
"sha256": "cc599f7c270385e85c28f710a34c5bd90b09d4d27adb3b1e6af3de65584b8207",
"bytes": 13912
},
"english-01.mpegts": {
"sha256": "01b17c28defc8a8ebf4978c5236eb759d80521a1cb840898095c35dac420cec5",
"bytes": 14100
},
"english-02.mpegts": {
"sha256": "90751b8b5abedd6b2f2e4eb83ccd609098ec7f2901163aaebf977cb9a11c46eb",
"bytes": 14100
},
"english-03.mpegts": {
"sha256": "9b1d86b7b261df66dbdace4ef1e846796cd710ffb72b22fc69532c1fe7d1c55e",
"bytes": 13912
},
"english-04.mpegts": {
"sha256": "dbfddd4f997942dd0b912f4c8bdbc5c11ee2a3fc51ac67df48d3aed2756715b9",
"bytes": 14100
},
"english-05.mpegts": {
"sha256": "9b53790d4f50f172852b7d4ff51bdd4bedd39e4c7a1efbfe734edf7b9a68a533",
"bytes": 14100
},
"english-06.mpegts": {
"sha256": "50baaa5e9fe7ca49b22c4d0817c44103b3edc763aee7f1b17d22f796d4189d4a",
"bytes": 752
},
"english.m3u8": {
"sha256": "5aae9d117d458f65f7febae68555ea4e48b2055b4cfb123fb919e385a1edda21",
"bytes": 365
},
"french-00.mpegts": {
"sha256": "7278acd094bc7caeeeea424a2c11ac4285fc45a8becb6c8ec55068f295ae35cd",
"bytes": 13912
},
"french-01.mpegts": {
"sha256": "e9464d109e36a53d7f151442b544d47ce79324bbaa805e0a98f171621c9c7a13",
"bytes": 14100
},
"french-02.mpegts": {
"sha256": "3ac48cab6aac942950b59f79ac6bd950dcae425f14821b83b1b5bb72d6815eb0",
"bytes": 14100
},
"french-03.mpegts": {
"sha256": "906f2d8b1f29bb7d57240b145563bab975d8f7dfff78cebd1505b6d2a3feb43b",
"bytes": 13912
},
"french-04.mpegts": {
"sha256": "cbf82876118d41af254bdef51f6cd08d842e8c6edf53250667fad2b8088e3601",
"bytes": 14100
},
"french-05.mpegts": {
"sha256": "a91e564ec54d845837217c45b8e25b08fe208f2b5f97562f513baabd515427d9",
"bytes": 14100
},
"french-06.mpegts": {
"sha256": "2c8edc11b06bacf1e62c4db67594ce187aa93e0241012011ebcf587b64ccff46",
"bytes": 752
},
"french.m3u8": {
"sha256": "6f7fb3eacb944597fa3105cb6d708eac3a8dc413e1706a11b6fb0e631214634e",
"bytes": 358
},
"high-00.mpegts": {
"sha256": "c5a8faac85b0594818255117696dce43fda6b363df40e2f3ffc453bd236cc833",
"bytes": 45308
},
"high-01.mpegts": {
"sha256": "5714640c3f17d86c56036453dc17d45f871af63b461318c43527af3482f8661c",
"bytes": 52264
},
"high-02.mpegts": {
"sha256": "fea1cbfb199bc867a322c7a646d43594a1d0d1d23fa0bcb6e770f2669b489440",
"bytes": 48128
},
"high-03.mpegts": {
"sha256": "4a74a28e7ad6bc626358e32365a0b07df1da82c92753e28032983e22404958b5",
"bytes": 54144
},
"high-04.mpegts": {
"sha256": "d04889b3b996d3e79c12bc297f9577690d3ae4f4172cdc0484b584954dab789c",
"bytes": 47376
},
"high-05.mpegts": {
"sha256": "36ee0c64b8c3637d0e2282c611e9250650ac493440095fc2efbe9de1c54add15",
"bytes": 48128
},
"high.m3u8": {
"sha256": "672c0e8b99242be05b16fc747a00715b2c25579b1ecc1623c0579fa34f0cb11d",
"bytes": 311
},
"low-00.mpegts": {
"sha256": "cbd62f5f4641cbe188a15640139bea393b5927fce600ff7513ef9ade9b33fb62",
"bytes": 28952
},
"low-01.mpegts": {
"sha256": "f7821fba38c63f5d80daef21cdd3430880bdc9fd5f5adfa939ed6481106c2507",
"bytes": 32900
},
"low-02.mpegts": {
"sha256": "3dc93f9f0fd065678b01fc6c568c12350e9ab0b324d1e0fa0e9f77787da7e7d2",
"bytes": 30268
},
"low-03.mpegts": {
"sha256": "d94c8913f7fde98161550041287529b90a58d537d6083d4382540c5fce0b430b",
"bytes": 32336
},
"low-04.mpegts": {
"sha256": "11596c59b547c2ff23bbba1f70e15f061a3e87bbdc1f4f59daa9b5279a6003af",
"bytes": 29704
},
"low-05.mpegts": {
"sha256": "5c6ec25a69fdad73a23cfbc34f0f3c6d8abe44592650c92fa9110a38d6bd9e45",
"bytes": 29516
},
"low.m3u8": {
"sha256": "404a8d868ed2af3598269aee89caba0563c2805dac223fe4c00c9a9eda73554f",
"bytes": 305
},
"master.m3u8": {
"sha256": "f226bd38f3c76714ada2b5a6866ad1cb39b041023e4a18d971d175f26f917e38",
"bytes": 470
},
"video-only.m3u8": {
"sha256": "3d7bb9069f6218191dc65745389bf1ab0ba6d5ed66cbd14e2cf30211913da3fc",
"bytes": 108
}
}
}
+8
View File
@@ -0,0 +1,8 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,URI="english.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="French",LANGUAGE="fr",DEFAULT=NO,AUTOSELECT=YES,URI="french.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=200000,RESOLUTION=160x90,CODECS="avc1.42c01e,mp4a.40.2",AUDIO="audio"
low.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=320x180,CODECS="avc1.42c01e,mp4a.40.2",AUDIO="audio"
high.m3u8
+4
View File
@@ -0,0 +1,4 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=200000,RESOLUTION=160x90,CODECS="avc1.42c01e"
low.m3u8
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import vm from 'node:vm'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { loadPackage } from './load.js'
export async function hlsImplementations() {
const { release } = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/hls-control-release.json', import.meta.url)))
const archive = await ensureArchive(release)
const implementations = [{ name: 'source', factory: (await loadPackage(release.name)).default }]
for (const format of ['main', 'legacy', 'module']) {
const member = `package/${release.manifest[format].replace(/^\.\//, '')}`
const bytes = readMember(archive, member)
assert.equal(hash(bytes), release.files[member])
let factory
if (format === 'module') {
factory = (await import(`data:text/javascript;base64,${Buffer.from(bytes).toString('base64')}`)).default
}
else {
const context = { module: { exports: {} } }
context.exports = context.module.exports
vm.runInNewContext(bytes.toString(), context, { timeout: 5000 })
factory = context.module.exports
}
assert.equal(typeof factory, 'function')
implementations.push({ name: `published-${format}`, factory })
}
return implementations
}
// Controlled SDK and registry host: no DOM rendering, decoding or ABR simulation.
export function hlsHost() {
const listeners = new Map()
const calls = []
const controls = new Map()
const settings = new Map()
const video = {}
const art = {
template: { $video: video },
constructor: { utils: { errorHandle: (condition, message) => {
if (!condition)
throw new Error(message)
} } },
on(event, callback) {
if (!listeners.has(event))
listeners.set(event, new Set())
listeners.get(event).add(callback)
return art
},
off(event, callback) {
listeners.get(event)?.delete(callback)
return art
},
emit(event, ...args) {
for (const callback of [...(listeners.get(event) || [])])
callback(...args)
return art
},
notice: {},
}
let notice = ''
Object.defineProperty(art.notice, 'show', {
get: () => notice,
set: (value) => {
calls.push(['notice', value])
notice = value
},
})
for (const [name, entries] of [['controls', controls], ['setting', settings]]) {
art[name] = {
update(item) {
calls.push([`${name}.update`, item.name])
entries.set(item.name, item)
},
remove(nameToRemove) {
calls.push([`${name}.remove`, nameToRemove])
entries.delete(nameToRemove)
},
check(item) { calls.push([`${name}.check`, item]) },
}
}
const hls = {
media: video,
levels: [{ height: 360 }, { height: 720 }, { height: 1080 }],
audioTracks: [{ id: 0, name: 'English' }, { id: 1, lang: 'fr' }],
}
for (const [key, initial] of [['currentLevel', -1], ['audioTrack', 0]]) {
let value = initial
Object.defineProperty(hls, key, {
configurable: true,
get: () => value,
set: (next) => {
calls.push([key, next])
value = next
},
})
}
art.hls = hls
return { art, hls, calls, controls, settings, listeners }
}
export const bothMenus = () => ({ quality: { control: true, setting: true }, audio: { control: true, setting: true } })
export const selections = menu => Array.from(menu.selector, item => [item.html, item.value, item.default])
+221
View File
@@ -0,0 +1,221 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- The repo uses Node's runner for controlled contracts.
import { test } from 'node:test'
import { bothMenus, hlsHost, hlsImplementations, selections } from './helpers/hls-control.js'
const implementations = await hlsImplementations()
for (const { name, factory } of implementations) {
test(`${name}: optional factory, synchronous result and deferred core events`, () => {
const { art, controls, calls, listeners } = hlsHost()
const empty = factory()(art)
assert.deepEqual(Object.keys(empty).sort(), ['name', 'update'])
assert.equal(empty.name, 'artplayerPluginHlsControl')
assert.equal(empty.update(), undefined)
assert.equal(calls.length, 0)
assert.equal(listeners.get('ready').has(empty.update), true)
assert.equal(listeners.get('restart').has(empty.update), true)
const plugin = factory(bothMenus())(art)
assert.equal(controls.size, 0)
art.emit('ready')
assert.equal(controls.size, 2)
calls.length = 0
art.emit('restart', 'next.m3u8')
assert.deepEqual(calls.map(call => call[0]), ['controls.update', 'setting.update', 'controls.update', 'setting.update'])
assert.equal(plugin.update(), undefined)
})
test(`${name}: missing or foreign Hls fails before any menu changes`, () => {
const { art, calls, hls } = hlsHost()
const plugin = factory(bothMenus())(art)
for (const invalid of [undefined, null, {}, { media: {} }]) {
art.hls = invalid
assert.throws(() => plugin.update(), { message: 'Cannot find instance of HLS from "art.hls"' })
assert.equal(calls.length, 0)
}
art.hls = hls
plugin.update()
assert.equal(calls.length, 4)
})
test(`${name}: quality and audio retain names, ordering, defaults and UI fields`, () => {
const { art, hls, controls, settings } = hlsHost()
hls.levels = [{ name: 'SD', height: 360 }, { height: 720 }, { name: 'SD', height: 480 }, { height: 1080 }]
hls.audioTracks.push({ id: 2, language: 'de' }, { id: 3, name: 'English' })
const plugin = factory(bothMenus())(art)
plugin.update()
assert.deepEqual(selections(controls.get('hls-quality')), [['1080P', 3, false], ['720P', 1, false], ['SD', 0, false], ['Auto', -1, true]])
assert.deepEqual(selections(controls.get('hls-audio')), [['English', 0, true], ['fr', 1, false], ['de', 2, false]])
for (const [key, title] of [['hls-quality', 'Quality'], ['hls-audio', 'Audio']]) {
const control = controls.get(key)
const setting = settings.get(key)
assert.equal(control.position, 'right')
assert.equal(control.style.padding, '0 10px')
assert.equal(setting.width, 200)
assert.match(setting.icon, /<svg/)
assert.equal(setting.html, title)
assert.equal(setting.tooltip, control.html)
assert.equal(setting.selector, control.selector)
assert.equal(setting.onSelect, control.onSelect)
}
})
test(`${name}: selection writes SDK then notice then checks the same item`, () => {
for (const [key, field, title] of [['hls-quality', 'currentLevel', 'Quality'], ['hls-audio', 'audioTrack', 'Audio']]) {
const { art, calls, controls, hls } = hlsHost()
factory(bothMenus())(art).update()
const menu = controls.get(key)
const item = menu.selector[1]
calls.length = 0
assert.equal(menu.onSelect(item), item.html)
assert.equal(hls[field], item.value)
assert.deepEqual(calls, [[field, item.value], ['notice', `${title}: ${item.html}`], ['controls.check', item], ['setting.check', item]])
if (key === 'hls-quality') {
assert.equal(menu.onSelect(menu.selector.at(-1)), 'Auto')
assert.equal(hls.currentLevel, -1)
}
}
})
test(`${name}: getName receives original SDK item and optional map index without binding`, () => {
const { art, hls, controls } = hlsHost()
hls.currentLevel = 1
const invocations = []
const option = bothMenus()
option.quality.getName = function (...args) {
invocations.push({ context: this, args })
return `Level ${args[0].height}`
}
factory(option)(art).update()
assert.equal(invocations.length, 4)
assert.equal(invocations[0].args.length, 1)
assert.equal(invocations[0].args[0], hls.levels[1])
for (let index = 0; index < 3; index++) {
assert.equal(invocations[index + 1].context, undefined)
assert.equal(invocations[index + 1].args[0], hls.levels[index])
assert.equal(invocations[index + 1].args[1], index)
}
assert.equal(controls.get('hls-quality').html, 'Level 720')
})
test(`${name}: callback failure is synchronous and later update can recover`, () => {
const { art, calls, controls } = hlsHost()
const failure = new Error('consumer callback failure')
const option = bothMenus()
option.audio.getName = () => {
throw failure
}
const plugin = factory(option)(art)
assert.throws(() => plugin.update(), error => error === failure)
assert.deepEqual(calls.map(call => call[1]), ['hls-quality', 'hls-quality'])
assert.equal(controls.has('hls-audio'), false)
delete option.audio.getName
assert.equal(plugin.update(), undefined)
assert.equal(controls.has('hls-audio'), true)
})
test(`${name}: rejected SDK selection propagates before notice or checks`, () => {
for (const [key, field] of [['hls-quality', 'currentLevel'], ['hls-audio', 'audioTrack']]) {
const { art, hls, controls, calls } = hlsHost()
factory(bothMenus())(art).update()
const failure = new Error('SDK selection rejected')
const selected = hls[field]
Object.defineProperty(hls, field, {
get: () => selected,
set() {
throw failure
},
})
calls.length = 0
const menu = controls.get(key)
assert.throws(() => menu.onSelect(menu.selector[0]), error => error === failure)
assert.equal(calls.length, 0)
}
})
test(`${name}: individual surfaces, translated labels and falsy defaults`, () => {
for (const surface of ['control', 'setting']) {
const { art, controls, settings, calls } = hlsHost()
const options = { quality: { [surface]: true, title: 'Definition', auto: 'Automatic' }, audio: { [surface]: true, title: '', auto: '' } }
factory(options)(art).update()
const active = surface === 'control' ? controls : settings
assert.equal((surface === 'control' ? settings : controls).size, 0)
const menu = active.get('hls-quality')
assert.equal(menu.selector.at(-1).html, 'Automatic')
calls.length = 0
menu.onSelect(menu.selector[0])
assert.equal(art.notice.show, 'Definition: 1080P')
assert.deepEqual(calls.map(call => call[0]), ['currentLevel', 'notice', `${surface === 'control' ? 'controls' : 'setting'}.check`])
active.get('hls-audio').onSelect(active.get('hls-audio').selector[0])
assert.equal(art.notice.show, 'Audio: English')
}
})
test(`${name}: update reads replacement engine and new list indexes`, () => {
const host = hlsHost()
const plugin = factory(bothMenus())(host.art)
plugin.update()
const replacement = hlsHost().hls
replacement.media = host.art.template.$video
replacement.levels = [{ height: 144 }, { height: 240 }]
replacement.currentLevel = 1
host.art.hls = replacement
host.art.emit('restart')
const menu = host.controls.get('hls-quality')
assert.deepEqual(selections(menu), [['240P', 1, true], ['144P', 0, false], ['Auto', -1, false]])
menu.onSelect(menu.selector[1])
assert.equal(replacement.currentLevel, 0)
assert.equal(host.hls.currentLevel, -1)
})
}
// Published-only observations freeze bugs, not requirements for the new implementation.
for (const { name, factory } of implementations.filter(item => item.name.startsWith('published-'))) {
test(`${name}: historical empty topology and disabled flags retain stale menus`, () => {
const { art, hls, controls, settings } = hlsHost()
const option = bothMenus()
const plugin = factory(option)(art)
plugin.update()
const old = controls.get('hls-quality')
hls.levels = []
hls.audioTracks = []
plugin.update()
assert.equal(controls.get('hls-quality'), old)
assert.equal(settings.size, 2)
hls.levels = [{ height: 240 }]
option.quality.control = false
option.quality.setting = false
plugin.update()
assert.equal(controls.get('hls-quality'), old)
})
test(`${name}: historical callbacks still write old engines after replacement and destroy`, () => {
const { art, hls, controls, calls } = hlsHost()
const plugin = factory(bothMenus())(art)
plugin.update()
const old = controls.get('hls-quality')
const replacement = hlsHost().hls
replacement.media = art.template.$video
art.hls = replacement
plugin.update()
old.onSelect(old.selector[0])
assert.equal(hls.currentLevel, 2)
assert.equal(replacement.currentLevel, -1)
art.emit('destroy')
calls.length = 0
plugin.update()
assert.equal(calls.length, 4, 'Retained update remains active after core destroy event')
})
test(`${name}: historical Auto label follows playing level, duplicate default is lost`, () => {
const { art, hls, controls } = hlsHost()
hls.autoLevelEnabled = true
hls.currentLevel = 1
const plugin = factory(bothMenus())(art)
plugin.update()
assert.equal(controls.get('hls-quality').html, '720P')
assert.equal(controls.get('hls-quality').selector.at(-1).default, false)
hls.levels = [{ height: 720 }, { height: 720 }]
plugin.update()
assert.equal(controls.get('hls-quality').selector.some(item => item.default), false)
})
}