Mercurial
view mrjunejune/test/theme_and_webp_test.js @ 258:60a876c4587a
[ui] Add semantic primitive ownership
Build a layered Zenbu token and sizing system, make authored controls use native-underneath primitives, migrate mrjunejune without imposing visual surfaces, and document/enforce HTML ownership in the catalog and wiki.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Wed, 05 Aug 2026 05:25:40 -0700 |
| parents | 30c2196d03d4 |
| children | 667156fcd3e3 |
line wrap: on
line source
const assert = require('node:assert/strict'); const fs = require('node:fs'); const net = require('node:net'); const path = require('node:path'); const { spawn } = require('node:child_process'); const RUNFILES = process.env.JS_BINARY__RUNFILES; const WORKSPACE = process.env.JS_BINARY__WORKSPACE; let port; let baseUrl; const playwrightPath = path.join( RUNFILES, WORKSPACE, 'hg-web/e2e/node_modules/playwright-core', ); const { chromium } = require(playwrightPath); function stopProcess(child) { if (!child || child.exitCode !== null) return Promise.resolve(); child.kill('SIGTERM'); return new Promise(resolve => { const timer = setTimeout(() => { if (child.exitCode === null) child.kill('SIGKILL'); }, 3000); child.once('exit', () => { clearTimeout(timer); resolve(); }); }); } async function waitForServer(child, logs) { const deadline = Date.now() + 15000; while (Date.now() < deadline) { if (child.exitCode !== null) { throw new Error(`Server exited with ${child.exitCode}\n${logs.join('')}`); } try { const response = await fetch(baseUrl); if (response.ok) return; } catch { // Keep waiting for startup. } await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error(`Server startup timed out\n${logs.join('')}`); } function listFiles(root) { const files = []; for (const entry of fs.readdirSync(root, { withFileTypes: true })) { const absolute = path.join(root, entry.name); if (entry.isDirectory()) files.push(...listFiles(absolute)); else files.push(absolute); } return files; } function primitiveOwnershipViolations(source) { const violations = []; const stack = []; const voidElements = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', ]); const ownerMap = { button: new Set(['zen-button']), select: new Set(['zen-field', 'zen-native-select', 'zen-select']), textarea: new Set(['zen-field', 'zen-textarea']), }; for (const token of source.matchAll( /<!--[\s\S]*?-->|<\/?([a-zA-Z][\w-]*)\b([^>]*)>/g, )) { if (!token[1]) continue; const name = token[1].toLowerCase(); if (token[0].startsWith('</')) { while (stack.length) { if (stack.pop() === name) break; } continue; } let owners = ownerMap[name]; if (name === 'input') { const type = /\btype\s*=\s*["']?([^"'\s>]+)/i.exec(token[2])?.[1] ?.toLowerCase() || 'text'; if (type === 'checkbox') { owners = new Set(['zen-checkbox', 'zen-field', 'zen-switch']); } else if (type === 'radio') { owners = new Set(['zen-field', 'zen-radio-group']); } else if (type === 'range') { owners = new Set(['zen-field', 'zen-slider']); } else if (type === 'date') { owners = new Set(['zen-calendar', 'zen-date-picker', 'zen-field']); } else { owners = new Set([ 'zen-combobox', 'zen-command', 'zen-field', 'zen-input', 'zen-input-group', 'zen-input-otp', ]); } } if (owners) { const matchingOwners = stack.filter(ancestor => owners.has(ancestor)); if (matchingOwners.length !== 1) { violations.push({ control: name, owners: matchingOwners, source: token[0], }); } } if (!voidElements.has(name) && !token[0].endsWith('/>')) { stack.push(name); } } return violations; } async function sampleTheme(browser, theme) { const context = await browser.newContext({ colorScheme: 'dark' }); await context.addInitScript(value => { localStorage.setItem('theme-preference', value); }, theme); const page = await context.newPage(); const errors = []; page.on('pageerror', error => errors.push(`pageerror: ${error.message}`)); page.on('console', message => { if (message.type() === 'error') errors.push(`console: ${message.text()}`); }); page.on('requestfailed', request => { errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); }); page.on('response', response => { if (response.status() >= 400) { errors.push(`response: ${response.status()} ${response.url()}`); } }); await page.goto(baseUrl, { waitUntil: 'networkidle' }); await page.waitForFunction(() => { const canvas = document.querySelector('#background'); if (!canvas) return false; const data = canvas.getContext('2d').getImageData( 0, 0, canvas.width, canvas.height, ).data; for (let index = 3; index < data.length; index += 4) { if (data[index] >= 20) return true; } return false; }); const sample = await page.evaluate(() => { const canvas = document.querySelector('#background'); const data = canvas.getContext('2d').getImageData( 0, 0, canvas.width, canvas.height, ).data; let count = 0; let luminance = 0; for (let index = 0; index < data.length; index += 4) { if (data[index + 3] < 20) continue; count++; luminance += data[index] * 0.2126 + data[index + 1] * 0.7152 + data[index + 2] * 0.0722; } const channels = value => value.match(/\d+(?:\.\d+)?/g) .slice(0, 3) .map(Number); const colorLuminance = value => { const converted = channels(value).map(channel => { const normalized = channel / 255; return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; }); return converted[0] * 0.2126 + converted[1] * 0.7152 + converted[2] * 0.0722; }; const bodyStyle = getComputedStyle(document.body); const themeButtonStyle = getComputedStyle( document.querySelector('#themeToggle'), ); const foreground = colorLuminance(bodyStyle.color); const contrastProbe = document.createElement('span'); contrastProbe.style.background = 'var(--zen-color-surface)'; document.body.append(contrastProbe); const background = colorLuminance( getComputedStyle(contrastProbe).backgroundColor, ); contrastProbe.remove(); return { backgroundRepeat: bodyStyle.backgroundRepeat, backgroundSize: bodyStyle.backgroundSize, bodyCoversViewport: document.body.getBoundingClientRect().height >= innerHeight, componentReady: Boolean(customElements.get('zen-button')), count, fontFamily: getComputedStyle(document.body).fontFamily, headerPaws: document.querySelectorAll( 'header [data-zen-link-decoration]', ).length, luminance: count ? luminance / count : 0, textContrast: (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05), rootTheme: document.documentElement.dataset.zenTheme || 'auto', enhancedLinks: document.querySelectorAll( 'zen-link[effect="paw"] > a > zen-icon[name="paw"]', ).length, links: document.querySelectorAll('a').length, pawLinks: [...document.querySelectorAll('a')].filter(link => !link.closest('zen-button') && link.dataset.zenLinkEffect !== 'none' ).length, mainBackground: getComputedStyle( document.querySelector('main'), ).backgroundColor, themeButtonBackground: themeButtonStyle.backgroundColor, themeButtonBorder: themeButtonStyle.borderTopWidth, themeButtonShadow: themeButtonStyle.boxShadow, themeLabel: document.querySelector('#themeName')?.textContent, }; }); if (errors.length) throw new Error(`${theme}\n${errors.join('\n')}`); await context.close(); return sample; } async function testThemeCycle(browser) { const context = await browser.newContext({ colorScheme: 'light' }); const page = await context.newPage(); await page.goto(baseUrl, { waitUntil: 'networkidle' }); const expected = ['paper', 'ink', 'playful', 'auto']; for (const theme of expected) { await page.locator('#themeToggle').click(); assert.equal( await page.evaluate(() => document.documentElement.dataset.zenTheme || 'auto' ), theme, ); assert.equal( await page.evaluate(() => localStorage.getItem('theme-preference')), theme, ); } await context.close(); } async function testPlainField(browser) { const page = await browser.newPage(); await page.goto(`${baseUrl}/tools/markdown_to_html`, { waitUntil: 'networkidle', }); await page.waitForFunction(() => customElements.get('zen-field')); const state = await page.locator('zen-field[appearance="plain"]').evaluate( field => { const label = field.querySelector('label'); const textarea = field.querySelector('textarea'); const probe = document.createElement('span'); probe.style.border = '1px solid var(--accent)'; document.body.append(probe); const accent = getComputedStyle(probe).borderColor; probe.remove(); textarea.focus(); return { borderColor: getComputedStyle(textarea).borderColor, display: getComputedStyle(field).display, labelFor: label.htmlFor, accent, textareaId: textarea.id, }; }, ); assert.deepEqual(state, { accent: state.borderColor, borderColor: state.borderColor, display: 'contents', labelFor: 'input', textareaId: 'input', }); await page.close(); } async function testButtonScale(browser) { const page = await browser.newPage(); for (const route of [ '/talk', '/notes/login', '/tools/hls_player', '/tools/latex_editor', '/tools/markdown_to_html', '/tools/file_converter', '/offline.html', ]) { await page.goto(`${baseUrl}${route}`, { waitUntil: 'networkidle' }); await page.waitForFunction(() => customElements.get('zen-button')); const controls = await page.locator( 'zen-button[size] > :is(button, a):not(#themeToggle)', ).evaluateAll(elements => elements.map(control => { const host = control.parentElement; const style = getComputedStyle(control); const hostStyle = getComputedStyle(host); return { height: control.getBoundingClientRect().height, minimum: Number.parseFloat( hostStyle.getPropertyValue('--zenbu-control-height'), ) * Number.parseFloat(getComputedStyle(document.documentElement).fontSize), paddingBlock: style.paddingBlockStart, paddingVariable: Number.parseFloat( hostStyle.getPropertyValue('--zenbu-control-padding-block'), ) * Number.parseFloat(getComputedStyle(document.documentElement).fontSize), size: host.getAttribute('size'), }; })); assert.ok(controls.length > 0, route); for (const control of controls) { assert.match(control.size, /^(?:xs|sm|md|lg|xl)$/); assert.ok(control.height >= control.minimum - 1, JSON.stringify(control)); assert.ok( Math.abs(Number.parseFloat(control.paddingBlock) - control.paddingVariable) < 1, JSON.stringify(control), ); } } await page.close(); } async function testDynamicButtonOwnership(browser) { const page = await browser.newPage(); await page.goto(baseUrl, { waitUntil: 'networkidle' }); await page.evaluate(() => { window.dispatchEvent(new Event('beforeinstallprompt', { cancelable: true, })); }); const state = await page.locator('#pwa-install-btn').evaluate(button => ({ ownerCount: button.closest('zen-button') ? 1 : 0, appearance: button.closest('zen-button')?.getAttribute('appearance'), size: button.closest('zen-button')?.getAttribute('size'), })); assert.deepEqual(state, { appearance: 'plain', ownerCount: 1, size: 'md', }); await page.close(); } async function testResumePrint(browser) { const page = await browser.newPage(); await page.emulateMedia({ media: 'print' }); await page.goto(`${baseUrl}/resume`, { waitUntil: 'networkidle' }); assert.equal( await page.locator('.info > a[href="/public/resume.pdf"]').isVisible(), false, ); await page.close(); } 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(); socket.once('error', reject); socket.listen(0, '127.0.0.1', () => { const address = socket.address(); socket.close(error => { if (error) reject(error); else resolve(String(address.port)); }); }); }); } (async () => { assert.ok(RUNFILES); assert.ok(WORKSPACE); const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); const serverBinary = path.join( runfilesWorkspace, 'mrjunejune/mrjunejune_server', ); const siteRoot = path.join(runfilesWorkspace, 'mrjunejune/src'); const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); const serverLogs = []; let server; let browser; try { port = await findFreePort(); baseUrl = `http://127.0.0.1:${port}`; const pngFiles = listFiles(siteRoot).filter(file => file.endsWith('.png')); assert.deepEqual(pngFiles, [], `PNG files leaked into runfiles: ${pngFiles}`); for (const file of listFiles(siteRoot).filter(file => file.endsWith('.html') )) { const source = fs.readFileSync(file, 'utf8'); for (const primitive of source.matchAll(/<zen-button\b([^>]*)>/g)) { assert.match( primitive[1], /\bsize=["'](?:xs|sm|md|lg|xl)["']/, `${path.relative(siteRoot, file)} has an unsized zen-button`, ); } assert.deepEqual( primitiveOwnershipViolations(source), [], `${path.relative(siteRoot, file)} violates primitive ownership`, ); } for (const file of listFiles(siteRoot).filter(file => /\.(?:css|html|js)$/.test(file) && !file.includes(`${path.sep}public${path.sep}design-system${path.sep}`) )) { const source = fs.readFileSync(file, 'utf8'); assert.doesNotMatch( source, /var\(\s*--zen-/, `${path.relative(siteRoot, file)} consumes a deprecated token`, ); } server = spawn(serverBinary, [], { cwd: runfilesWorkspace, env: { ...process.env, MRJUNEJUNE_PORT: port }, stdio: ['ignore', 'pipe', 'pipe'], }); server.stdout.on('data', chunk => serverLogs.push(chunk.toString())); server.stderr.on('data', chunk => serverLogs.push(chunk.toString())); await waitForServer(server, serverLogs); const home = await (await fetch(baseUrl)).text(); const dogGame = await ( await fetch(`${baseUrl}/public/dog-game.js`) ).text(); const manifest = await ( await fetch(`${baseUrl}/public/manifest.json`) ).text(); const serviceWorker = await ( await fetch(`${baseUrl}/sw.js`) ).text(); for (const source of [home, dogGame, manifest]) { assert.doesNotMatch(source, /\.png(?:["')]|$)/i); } assert.match(serviceWorker, /v6-zenbu-headless/); assert.match( await ( await fetch(`${baseUrl}/public/pwa-register.js`) ).text(), /register\('\/sw\.js', \{ scope: '\/' \}\)/, ); assert.ok(serviceWorker.includes("startsWith('/tools/hls_player')")); for (const asset of [ 'sprite_shiba0.webp', 'dog-treat.webp', 'start-large.webp', 'icon-192.webp', 'vscode.webp', ]) { const response = await fetch(`${baseUrl}/public/${asset}`); assert.equal(response.status, 200, asset); assert.ok((await response.arrayBuffer()).byteLength > 0, asset); } browser = await chromium.launch({ executablePath: chromiumPath, headless: true, args: ['--no-sandbox'], }); const paper = await sampleTheme(browser, 'paper'); const ink = await sampleTheme(browser, 'ink'); const playful = await sampleTheme(browser, 'playful'); const automatic = await sampleTheme(browser, 'auto'); assert.equal(paper.rootTheme, 'paper'); assert.equal(ink.rootTheme, 'ink'); assert.equal(playful.rootTheme, 'playful'); assert.equal(automatic.rootTheme, 'auto'); assert.equal(paper.themeLabel, 'Paper'); assert.equal(ink.themeLabel, 'Ink'); assert.equal(playful.themeLabel, 'Playful'); assert.equal(automatic.themeLabel, 'Auto'); for (const sample of [paper, ink, playful, automatic]) { assert.ok(sample.count > 0); assert.equal(sample.backgroundRepeat, 'no-repeat'); assert.equal(sample.backgroundSize, 'cover'); assert.equal(sample.bodyCoversViewport, true); assert.equal(sample.componentReady, true); assert.ok(sample.links > 0); assert.equal(sample.enhancedLinks, sample.pawLinks); assert.match(sample.fontFamily, /More/); assert.equal(sample.headerPaws, 0); assert.ok(sample.textContrast >= 4.5, JSON.stringify(sample)); assert.equal(sample.mainBackground, 'rgba(0, 0, 0, 0)'); assert.equal(sample.themeButtonBackground, 'rgba(0, 0, 0, 0)'); assert.equal(sample.themeButtonBorder, '0px'); assert.equal(sample.themeButtonShadow, 'none'); } assert.ok(paper.luminance < 175, JSON.stringify(paper)); assert.ok(playful.luminance < 175, JSON.stringify(playful)); assert.ok(ink.luminance > 200, JSON.stringify(ink)); await testThemeCycle(browser); await testPlainField(browser); await testButtonScale(browser); await testDynamicButtonOwnership(browser); await testResumePrint(browser); await testHlsPlayer(browser, siteRoot); } finally { if (browser) await browser.close(); await stopProcess(server); } })().catch(error => { console.error(error.stack || error); process.exitCode = 1; });