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