comparison 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
comparison
equal deleted inserted replaced
241:9c2eec61a152 242:543df0fe7168
120 if (errors.length) throw new Error(`${theme}\n${errors.join('\n')}`); 120 if (errors.length) throw new Error(`${theme}\n${errors.join('\n')}`);
121 await context.close(); 121 await context.close();
122 return sample; 122 return sample;
123 } 123 }
124 124
125 async function testHlsPlayer(browser, siteRoot) {
126 const page = await browser.newPage();
127 const errors = [];
128 const mediaRequests = [];
129 let testingExpectedFailure = false;
130 let testingExpectedReload = false;
131 page.on('pageerror', error => errors.push(`pageerror: ${error.message}`));
132 page.on('console', message => {
133 if (message.type() !== 'error') return;
134 if (testingExpectedFailure &&
135 message.text().startsWith('Failed to load resource:')) return;
136 errors.push(`console: ${message.text()}`);
137 });
138 page.on('requestfailed', request => {
139 if (testingExpectedReload &&
140 request.failure()?.errorText === 'net::ERR_ABORTED') return;
141 errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`);
142 });
143 page.on('request', request => {
144 if (/\.(?:m3u8|m4s|mp4)(?:\?|$)/.test(request.url())) {
145 mediaRequests.push(request.url());
146 }
147 });
148
149 const playlistResponse = await fetch(
150 `${baseUrl}/public/hls-sample/master.m3u8`,
151 );
152 assert.equal(playlistResponse.status, 200);
153 assert.match(
154 playlistResponse.headers.get('content-type') || '',
155 /^application\/vnd\.apple\.mpegurl/,
156 );
157 assert.match(
158 await playlistResponse.text(),
159 /#EXT-X-STREAM-INF:.*CODECS="vp09\.00\.10\.08,opus"/,
160 );
161
162 const variantResponse = await fetch(
163 `${baseUrl}/public/hls-sample/vp9-stream.m3u8`,
164 );
165 assert.equal(variantResponse.status, 200);
166 assert.match(
167 await variantResponse.text(),
168 /#EXT-X-MAP:URI="vp9-init\.mp4"/,
169 );
170
171 const segmentResponse = await fetch(
172 `${baseUrl}/public/hls-sample/vp9-segment000.m4s`,
173 );
174 assert.equal(segmentResponse.status, 200);
175 assert.match(
176 segmentResponse.headers.get('content-type') || '',
177 /^video\/iso\.segment/,
178 );
179 const transportStreamResponse = await fetch(
180 `${baseUrl}/public/hls-sample/h264-ts-segment000.ts`,
181 );
182 assert.equal(transportStreamResponse.status, 200);
183 assert.match(
184 transportStreamResponse.headers.get('content-type') || '',
185 /^video\/mp2t/,
186 );
187 assert.ok((await transportStreamResponse.arrayBuffer()).byteLength > 0);
188
189 await page.goto(
190 `${baseUrl}/tools/hls_player?url=${encodeURIComponent('/public/hls-sample/master.m3u8')}`,
191 {
192 waitUntil: 'networkidle',
193 },
194 );
195 await page.getByRole('button', { name: 'Sample', exact: true }).waitFor();
196 assert.equal(
197 await page.locator('#hlsUrl').evaluate(input => input.defaultValue),
198 '/public/hls-sample/h264-ts-stream.m3u8',
199 );
200 await page.waitForFunction(() => {
201 const status = document.querySelector('#hlsStatus');
202 return status?.dataset.state === 'ready' ||
203 status?.dataset.state === 'error';
204 }, null, { timeout: 15000 });
205 const terminalState = await page.locator('#hlsStatus').getAttribute('data-state');
206 if (terminalState !== 'ready') {
207 throw new Error(
208 `HLS player failed: ${await page.locator('#hlsStatus').textContent()}\n${errors.join('\n')}`,
209 );
210 }
211 assert.match(await page.locator('#hlsStatus').textContent(), /Stream ready/);
212 assert.equal(
213 await page.locator('[data-detail="mode"]').textContent(),
214 'hls.js',
215 );
216 assert.match(
217 await page.locator('[data-detail="segments"]').textContent(),
218 /Managed by hls\.js|adaptive levels/,
219 );
220 await page.waitForFunction(() => {
221 const video = document.querySelector('#hlsVideo');
222 return video.readyState >= HTMLMediaElement.HAVE_METADATA &&
223 Number.isFinite(video.duration) &&
224 video.duration >= 14;
225 });
226 const firstFrame = await page.locator('#hlsVideo').evaluate(video => {
227 const canvas = document.createElement('canvas');
228 canvas.width = video.videoWidth;
229 canvas.height = video.videoHeight;
230 const context = canvas.getContext('2d');
231 context.drawImage(video, 0, 0);
232 const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
233 let total = 0;
234 for (let index = 0; index < pixels.length; index += 4) {
235 total += pixels[index] + pixels[index + 1] + pixels[index + 2];
236 }
237 return total / (canvas.width * canvas.height * 3);
238 });
239 assert.ok(firstFrame > 10, `Initial HLS frame is black: ${firstFrame}`);
240 await page.locator('#hlsVideo').evaluate(video => video.play());
241 await page.waitForFunction(() => document.querySelector('#hlsVideo').currentTime > 0.2);
242 await page.locator('#hlsVideo').evaluate(video => video.pause());
243 assert.ok(mediaRequests.some(url => url.endsWith('/hls-sample/master.m3u8')));
244 assert.ok(mediaRequests.some(url => url.endsWith('-stream.m3u8')));
245 assert.ok(mediaRequests.some(url => url.endsWith('-init.mp4')));
246 assert.ok(mediaRequests.some(url => /-segment\d+\.m4s$/.test(url)));
247
248 await page.evaluate(() => {
249 window.hlsPlayer.hls.trigger(window.Hls.Events.ERROR, {
250 fatal: true,
251 type: window.Hls.ErrorTypes.MEDIA_ERROR,
252 details: 'testRuntimeFailure',
253 error: new Error('runtime segment failed'),
254 });
255 });
256 await page.locator('#hlsStatus[data-state="error"]').waitFor();
257 assert.match(
258 await page.locator('#hlsStatus').textContent(),
259 /runtime segment failed/,
260 );
261 await page.locator('#hlsUrl').fill('/public/hls-sample/master.m3u8');
262 await page.getByRole('button', { name: 'Load', exact: true }).click();
263 await page.locator('#hlsStatus[data-state="ready"]').waitFor();
264
265 let delayedSegment = true;
266 await page.route('**/hls-sample/vp9-segment000.m4s', async route => {
267 if (delayedSegment) {
268 delayedSegment = false;
269 await new Promise(resolve => setTimeout(resolve, 250));
270 }
271 await route.continue();
272 });
273 testingExpectedReload = true;
274 await page.locator('#hlsUrl').fill('/public/hls-sample/master.m3u8');
275 await page.getByRole('button', { name: 'Load', exact: true }).click();
276 await page.waitForTimeout(25);
277 await page.getByRole('button', { name: 'Load', exact: true }).click();
278 await page.locator('#hlsStatus[data-state="ready"]').waitFor();
279 testingExpectedReload = false;
280 assert.match(await page.locator('#hlsStatus').textContent(), /Stream ready/);
281
282 await page.evaluate(() => {
283 window.__hlsObjectUrls = { created: [], revoked: [] };
284 const createObjectURL = URL.createObjectURL.bind(URL);
285 const revokeObjectURL = URL.revokeObjectURL.bind(URL);
286 URL.createObjectURL = value => {
287 const url = createObjectURL(value);
288 window.__hlsObjectUrls.created.push(url);
289 return url;
290 };
291 URL.revokeObjectURL = url => {
292 window.__hlsObjectUrls.revoked.push(url);
293 revokeObjectURL(url);
294 };
295 });
296 const localFiles = listFiles(path.join(siteRoot, 'public/hls-sample'));
297 await page.locator('#hlsFiles').setInputFiles(localFiles);
298 await page.waitForFunction(() =>
299 document.querySelector('#hlsUrl')?.value.startsWith('Local: ')
300 );
301 await page.locator('#hlsStatus[data-state="ready"]').waitFor();
302 assert.match(await page.locator('#hlsUrl').inputValue(), /^Local: /);
303 assert.equal(
304 await page.locator('[data-detail="mode"]').textContent(),
305 'hls.js',
306 );
307 await page.locator('#hlsVideo').evaluate(video => video.play());
308 await page.waitForFunction(() => document.querySelector('#hlsVideo').currentTime > 0.3);
309 await page.locator('#hlsVideo').evaluate(video => video.pause());
310 const incompleteLocalFiles = localFiles.filter(file =>
311 file.endsWith('master.m3u8') ||
312 file.endsWith('vp9-stream.m3u8')
313 );
314 await page.locator('#hlsFiles').setInputFiles(incompleteLocalFiles);
315 await page.locator('#hlsStatus[data-state="error"]').waitFor();
316 assert.match(
317 await page.locator('#hlsStatus').textContent(),
318 /Local HLS file is missing/,
319 );
320 const objectUrlCounts = await page.evaluate(() => ({
321 created: window.__hlsObjectUrls.created.length,
322 revoked: window.__hlsObjectUrls.revoked.length,
323 }));
324 assert.ok(objectUrlCounts.created > 10, JSON.stringify(objectUrlCounts));
325 assert.ok(
326 objectUrlCounts.revoked >= objectUrlCounts.created,
327 JSON.stringify(objectUrlCounts),
328 );
329
330 await page.route('**/invalid.m3u8', route => route.fulfill({
331 status: 404,
332 body: 'not found',
333 }));
334 testingExpectedFailure = true;
335 await page.locator('#hlsUrl').fill(`${baseUrl}/invalid.m3u8`);
336 await page.getByRole('button', { name: 'Load', exact: true }).click();
337 await page.locator('#hlsStatus[data-state="error"]').waitFor();
338 assert.match(
339 await page.locator('#hlsStatus').textContent(),
340 /404|manifestLoadError|Playlist request failed/,
341 );
342
343 if (errors.length) throw new Error(errors.join('\n'));
344 await page.close();
345 }
346
125 function findFreePort() { 347 function findFreePort() {
126 return new Promise((resolve, reject) => { 348 return new Promise((resolve, reject) => {
127 const socket = net.createServer(); 349 const socket = net.createServer();
128 socket.once('error', reject); 350 socket.once('error', reject);
129 socket.listen(0, '127.0.0.1', () => { 351 socket.listen(0, '127.0.0.1', () => {
170 await fetch(`${baseUrl}/public/dog-game.js`) 392 await fetch(`${baseUrl}/public/dog-game.js`)
171 ).text(); 393 ).text();
172 const manifest = await ( 394 const manifest = await (
173 await fetch(`${baseUrl}/public/manifest.json`) 395 await fetch(`${baseUrl}/public/manifest.json`)
174 ).text(); 396 ).text();
397 const serviceWorker = await (
398 await fetch(`${baseUrl}/public/sw.js`)
399 ).text();
175 for (const source of [home, dogGame, manifest]) { 400 for (const source of [home, dogGame, manifest]) {
176 assert.doesNotMatch(source, /\.png(?:["')]|$)/i); 401 assert.doesNotMatch(source, /\.png(?:["')]|$)/i);
177 } 402 }
403 assert.match(serviceWorker, /v4-hlsjs/);
404 assert.ok(serviceWorker.includes("startsWith('/tools/hls_player')"));
178 405
179 for (const asset of [ 406 for (const asset of [
180 'sprite_shiba0.webp', 407 'sprite_shiba0.webp',
181 'dog-treat.webp', 408 'dog-treat.webp',
182 'start-large.webp', 409 'start-large.webp',
197 const dark = await sampleTheme(browser, 'dark'); 424 const dark = await sampleTheme(browser, 'dark');
198 assert.ok(light.count > 0); 425 assert.ok(light.count > 0);
199 assert.ok(dark.count > 0); 426 assert.ok(dark.count > 0);
200 assert.ok(light.luminance < 175, JSON.stringify(light)); 427 assert.ok(light.luminance < 175, JSON.stringify(light));
201 assert.ok(dark.luminance > 200, JSON.stringify(dark)); 428 assert.ok(dark.luminance > 200, JSON.stringify(dark));
429 await testHlsPlayer(browser, siteRoot);
202 } finally { 430 } finally {
203 if (browser) await browser.close(); 431 if (browser) await browser.close();
204 await stopProcess(server); 432 await stopProcess(server);
205 } 433 }
206 })().catch(error => { 434 })().catch(error => {