Mercurial
diff mrjunejune/test/theme_and_webp_test.js @ 242:543df0fe7168
[tools] Add full HLS player support
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 03 Aug 2026 13:14:41 -0700 |
| parents | 9c2eec61a152 |
| children | 30c2196d03d4 |
line wrap: on
line diff
--- a/mrjunejune/test/theme_and_webp_test.js Mon Aug 03 11:26:30 2026 -0700 +++ b/mrjunejune/test/theme_and_webp_test.js Mon Aug 03 13:14:41 2026 -0700 @@ -122,6 +122,228 @@ return sample; } +async function testHlsPlayer(browser, siteRoot) { + const page = await browser.newPage(); + const errors = []; + const mediaRequests = []; + let testingExpectedFailure = false; + let testingExpectedReload = false; + page.on('pageerror', error => errors.push(`pageerror: ${error.message}`)); + page.on('console', message => { + if (message.type() !== 'error') return; + if (testingExpectedFailure && + message.text().startsWith('Failed to load resource:')) return; + errors.push(`console: ${message.text()}`); + }); + page.on('requestfailed', request => { + if (testingExpectedReload && + request.failure()?.errorText === 'net::ERR_ABORTED') return; + errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); + }); + page.on('request', request => { + if (/\.(?:m3u8|m4s|mp4)(?:\?|$)/.test(request.url())) { + mediaRequests.push(request.url()); + } + }); + + const playlistResponse = await fetch( + `${baseUrl}/public/hls-sample/master.m3u8`, + ); + assert.equal(playlistResponse.status, 200); + assert.match( + playlistResponse.headers.get('content-type') || '', + /^application\/vnd\.apple\.mpegurl/, + ); + assert.match( + await playlistResponse.text(), + /#EXT-X-STREAM-INF:.*CODECS="vp09\.00\.10\.08,opus"/, + ); + + const variantResponse = await fetch( + `${baseUrl}/public/hls-sample/vp9-stream.m3u8`, + ); + assert.equal(variantResponse.status, 200); + assert.match( + await variantResponse.text(), + /#EXT-X-MAP:URI="vp9-init\.mp4"/, + ); + + const segmentResponse = await fetch( + `${baseUrl}/public/hls-sample/vp9-segment000.m4s`, + ); + assert.equal(segmentResponse.status, 200); + assert.match( + segmentResponse.headers.get('content-type') || '', + /^video\/iso\.segment/, + ); + const transportStreamResponse = await fetch( + `${baseUrl}/public/hls-sample/h264-ts-segment000.ts`, + ); + assert.equal(transportStreamResponse.status, 200); + assert.match( + transportStreamResponse.headers.get('content-type') || '', + /^video\/mp2t/, + ); + assert.ok((await transportStreamResponse.arrayBuffer()).byteLength > 0); + + await page.goto( + `${baseUrl}/tools/hls_player?url=${encodeURIComponent('/public/hls-sample/master.m3u8')}`, + { + waitUntil: 'networkidle', + }, + ); + await page.getByRole('button', { name: 'Sample', exact: true }).waitFor(); + assert.equal( + await page.locator('#hlsUrl').evaluate(input => input.defaultValue), + '/public/hls-sample/h264-ts-stream.m3u8', + ); + await page.waitForFunction(() => { + const status = document.querySelector('#hlsStatus'); + return status?.dataset.state === 'ready' || + status?.dataset.state === 'error'; + }, null, { timeout: 15000 }); + const terminalState = await page.locator('#hlsStatus').getAttribute('data-state'); + if (terminalState !== 'ready') { + throw new Error( + `HLS player failed: ${await page.locator('#hlsStatus').textContent()}\n${errors.join('\n')}`, + ); + } + assert.match(await page.locator('#hlsStatus').textContent(), /Stream ready/); + assert.equal( + await page.locator('[data-detail="mode"]').textContent(), + 'hls.js', + ); + assert.match( + await page.locator('[data-detail="segments"]').textContent(), + /Managed by hls\.js|adaptive levels/, + ); + await page.waitForFunction(() => { + const video = document.querySelector('#hlsVideo'); + return video.readyState >= HTMLMediaElement.HAVE_METADATA && + Number.isFinite(video.duration) && + video.duration >= 14; + }); + const firstFrame = await page.locator('#hlsVideo').evaluate(video => { + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const context = canvas.getContext('2d'); + context.drawImage(video, 0, 0); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + let total = 0; + for (let index = 0; index < pixels.length; index += 4) { + total += pixels[index] + pixels[index + 1] + pixels[index + 2]; + } + return total / (canvas.width * canvas.height * 3); + }); + assert.ok(firstFrame > 10, `Initial HLS frame is black: ${firstFrame}`); + await page.locator('#hlsVideo').evaluate(video => video.play()); + await page.waitForFunction(() => document.querySelector('#hlsVideo').currentTime > 0.2); + await page.locator('#hlsVideo').evaluate(video => video.pause()); + assert.ok(mediaRequests.some(url => url.endsWith('/hls-sample/master.m3u8'))); + assert.ok(mediaRequests.some(url => url.endsWith('-stream.m3u8'))); + assert.ok(mediaRequests.some(url => url.endsWith('-init.mp4'))); + assert.ok(mediaRequests.some(url => /-segment\d+\.m4s$/.test(url))); + + await page.evaluate(() => { + window.hlsPlayer.hls.trigger(window.Hls.Events.ERROR, { + fatal: true, + type: window.Hls.ErrorTypes.MEDIA_ERROR, + details: 'testRuntimeFailure', + error: new Error('runtime segment failed'), + }); + }); + await page.locator('#hlsStatus[data-state="error"]').waitFor(); + assert.match( + await page.locator('#hlsStatus').textContent(), + /runtime segment failed/, + ); + await page.locator('#hlsUrl').fill('/public/hls-sample/master.m3u8'); + await page.getByRole('button', { name: 'Load', exact: true }).click(); + await page.locator('#hlsStatus[data-state="ready"]').waitFor(); + + let delayedSegment = true; + await page.route('**/hls-sample/vp9-segment000.m4s', async route => { + if (delayedSegment) { + delayedSegment = false; + await new Promise(resolve => setTimeout(resolve, 250)); + } + await route.continue(); + }); + testingExpectedReload = true; + await page.locator('#hlsUrl').fill('/public/hls-sample/master.m3u8'); + await page.getByRole('button', { name: 'Load', exact: true }).click(); + await page.waitForTimeout(25); + await page.getByRole('button', { name: 'Load', exact: true }).click(); + await page.locator('#hlsStatus[data-state="ready"]').waitFor(); + testingExpectedReload = false; + assert.match(await page.locator('#hlsStatus').textContent(), /Stream ready/); + + await page.evaluate(() => { + window.__hlsObjectUrls = { created: [], revoked: [] }; + const createObjectURL = URL.createObjectURL.bind(URL); + const revokeObjectURL = URL.revokeObjectURL.bind(URL); + URL.createObjectURL = value => { + const url = createObjectURL(value); + window.__hlsObjectUrls.created.push(url); + return url; + }; + URL.revokeObjectURL = url => { + window.__hlsObjectUrls.revoked.push(url); + revokeObjectURL(url); + }; + }); + const localFiles = listFiles(path.join(siteRoot, 'public/hls-sample')); + await page.locator('#hlsFiles').setInputFiles(localFiles); + await page.waitForFunction(() => + document.querySelector('#hlsUrl')?.value.startsWith('Local: ') + ); + await page.locator('#hlsStatus[data-state="ready"]').waitFor(); + assert.match(await page.locator('#hlsUrl').inputValue(), /^Local: /); + assert.equal( + await page.locator('[data-detail="mode"]').textContent(), + 'hls.js', + ); + await page.locator('#hlsVideo').evaluate(video => video.play()); + await page.waitForFunction(() => document.querySelector('#hlsVideo').currentTime > 0.3); + await page.locator('#hlsVideo').evaluate(video => video.pause()); + const incompleteLocalFiles = localFiles.filter(file => + file.endsWith('master.m3u8') || + file.endsWith('vp9-stream.m3u8') + ); + await page.locator('#hlsFiles').setInputFiles(incompleteLocalFiles); + await page.locator('#hlsStatus[data-state="error"]').waitFor(); + assert.match( + await page.locator('#hlsStatus').textContent(), + /Local HLS file is missing/, + ); + const objectUrlCounts = await page.evaluate(() => ({ + created: window.__hlsObjectUrls.created.length, + revoked: window.__hlsObjectUrls.revoked.length, + })); + assert.ok(objectUrlCounts.created > 10, JSON.stringify(objectUrlCounts)); + assert.ok( + objectUrlCounts.revoked >= objectUrlCounts.created, + JSON.stringify(objectUrlCounts), + ); + + await page.route('**/invalid.m3u8', route => route.fulfill({ + status: 404, + body: 'not found', + })); + testingExpectedFailure = true; + await page.locator('#hlsUrl').fill(`${baseUrl}/invalid.m3u8`); + await page.getByRole('button', { name: 'Load', exact: true }).click(); + await page.locator('#hlsStatus[data-state="error"]').waitFor(); + assert.match( + await page.locator('#hlsStatus').textContent(), + /404|manifestLoadError|Playlist request failed/, + ); + + if (errors.length) throw new Error(errors.join('\n')); + await page.close(); +} + function findFreePort() { return new Promise((resolve, reject) => { const socket = net.createServer(); @@ -172,9 +394,14 @@ const manifest = await ( await fetch(`${baseUrl}/public/manifest.json`) ).text(); + const serviceWorker = await ( + await fetch(`${baseUrl}/public/sw.js`) + ).text(); for (const source of [home, dogGame, manifest]) { assert.doesNotMatch(source, /\.png(?:["')]|$)/i); } + assert.match(serviceWorker, /v4-hlsjs/); + assert.ok(serviceWorker.includes("startsWith('/tools/hls_player')")); for (const asset of [ 'sprite_shiba0.webp', @@ -199,6 +426,7 @@ assert.ok(dark.count > 0); assert.ok(light.luminance < 175, JSON.stringify(light)); assert.ok(dark.luminance > 200, JSON.stringify(dark)); + await testHlsPlayer(browser, siteRoot); } finally { if (browser) await browser.close(); await stopProcess(server);