Mercurial
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 => { |