Files
company-haness/hyeonworks/app/scripts/verify_flow.cjs
T

462 lines
26 KiB
JavaScript

const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createStaticServer } = require('./serve.cjs');
let puppeteer;
for (const candidate of [
'puppeteer-core',
process.env.PUPPETEER_CORE_PATH,
'/home/donghyeon/.bun/install/global/node_modules/puppeteer-core',
].filter(Boolean)) {
try {
puppeteer = require(candidate);
break;
} catch (_) {
// Try the next local harness installation. The product itself has no browser-test runtime dependency.
}
}
if (!puppeteer) throw new Error('puppeteer-core is required to run the local harness E2E');
const root = path.resolve(__dirname, '..', 'dist');
const stateShotDir = path.resolve(__dirname, '..', 'verification', 'screenshots');
async function selectedAndFocused(page, selector) {
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => resolve())));
await page.focus(selector);
await page.keyboard.press('Space');
await page.waitForFunction(target => {
const element = document.querySelector(target);
return Boolean(element?.checked && document.activeElement === element);
}, {}, selector);
}
async function clickAndWait(page, selector, condition) {
await page.click(selector);
await page.waitForFunction(condition);
}
async function assertNoDocumentOverflow(page, label) {
const widths = await page.evaluate(() => ({
client: document.documentElement.clientWidth,
scroll: document.documentElement.scrollWidth,
}));
assert.equal(widths.scroll, widths.client, `${label}: document overflow ${widths.client} -> ${widths.scroll}`);
}
async function assertEntryShortcut(page, activation) {
if (activation === 'keyboard') {
await page.focus('[data-entry-jump]');
await page.keyboard.press('Enter');
} else {
await page.click('[data-entry-jump]');
}
await page.waitForFunction(() => {
const target = document.querySelector('#entry-title');
const top = target?.getBoundingClientRect().top;
return document.activeElement === target && top >= 0 && top <= 80;
});
const result = await page.evaluate(() => ({
activeId: document.activeElement?.id,
top: document.querySelector('#entry-title')?.getBoundingClientRect().top,
hash: location.hash,
}));
assert.equal(result.activeId, 'entry-title');
assert.ok(result.top >= 0 && result.top <= 80, `entry shortcut target top=${result.top}`);
assert.notEqual(result.hash, '#entry-title', 'local entry shortcut must not invoke the route renderer');
}
async function assertSkipLinkPreservesState(page, label) {
const main = await page.$('#main');
assert.ok(main, `${label}: current main must exist`);
const readState = element => ({
hash: location.hash,
scenarioId: element.dataset.scenarioId || null,
phase: element.dataset.phase || null,
cursor: element.dataset.cursor || null,
heading: element.querySelector('h1, h2')?.textContent.trim() || null,
trace: document.querySelector('.trace-note')?.textContent.trim() || null,
clues: document.querySelector('.clues')?.textContent.trim() || null,
});
const before = await page.evaluate(readState, main);
await page.focus('.skip-link');
await page.keyboard.press('Enter');
await page.waitForFunction(() => document.activeElement === document.querySelector('#main'));
const after = await page.evaluate((element, readerSource) => {
const reader = new Function(`return (${readerSource})`)();
return {
state: reader(element),
sameNode: element === document.querySelector('#main'),
active: document.activeElement === element,
tabIndex: element.tabIndex,
top: element.getBoundingClientRect().top,
firstHeadingTop: element.querySelector('h1, h2')?.getBoundingClientRect().top ?? null,
viewportHeight: innerHeight,
};
}, main, readState.toString());
assert.deepEqual(after.state, before, `${label}: skip link must preserve route and learning state`);
assert.equal(after.sameNode, true, `${label}: skip link must not rerender the route`);
assert.equal(after.active, true, `${label}: current main must receive focus`);
assert.equal(after.tabIndex, -1, `${label}: current main must be programmatically focusable`);
assert.ok(after.top >= -32 && after.top < after.viewportHeight, `${label}: current main start must be in view (top=${after.top})`);
assert.ok(after.firstHeadingTop !== null && after.firstHeadingTop >= 0 && after.firstHeadingTop < after.viewportHeight,
`${label}: first main heading must be visible (top=${after.firstHeadingTop})`);
await assertNoDocumentOverflow(page, `${label}:skip-link`);
await main.dispose();
}
async function assertSkipLinkAcrossViewports(page, label) {
const original = page.viewport();
for (const width of [360, 768, 1280]) {
await page.setViewport({ width, height: width === 360 ? 800 : 900, deviceScaleFactor: 1 });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await assertSkipLinkPreservesState(page, `${label}@${width}`);
}
await page.setViewport(original);
}
async function assertObserveContainment(page, label) {
const geometry = await page.evaluate(() => {
const workbench = document.querySelector('.workbench');
const trace = document.querySelector('.trace-scroll');
const action = document.querySelector('[data-action="advance"], [data-action="to-compare"]');
trace.scrollLeft = trace.scrollWidth;
const result = {
viewport: document.documentElement.clientWidth,
documentScroll: document.documentElement.scrollWidth,
workbenchRight: workbench.getBoundingClientRect().right,
actionRight: action.getBoundingClientRect().right,
traceClient: trace.clientWidth,
traceScroll: trace.scrollWidth,
traceScrollLeft: trace.scrollLeft,
};
trace.scrollLeft = 0;
return result;
});
assert.equal(geometry.documentScroll, geometry.viewport, `${label}: Observe must not widen the document`);
assert.ok(geometry.workbenchRight <= geometry.viewport + .5, `${label}: workbench right=${geometry.workbenchRight}`);
assert.ok(geometry.actionRight <= geometry.viewport + .5, `${label}: action right=${geometry.actionRight}`);
assert.ok(geometry.traceScroll > geometry.traceClient, `${label}: table overflow must remain inside trace-scroll`);
assert.ok(geometry.traceScrollLeft > 0, `${label}: trace-scroll must be horizontally operable`);
}
async function assertKoreanWordIntegrity(page, label) {
const result = await page.evaluate(() => {
const selectors = '.lab-brief > p, .workbench .lead, .choice span, .boundary p, .principle p, .feedback';
const elements = [...document.querySelectorAll(selectors)];
const broken = [];
const segmenter = new Intl.Segmenter('ko', { granularity: 'word' });
for (const element of elements) {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
for (const segment of segmenter.segment(node.data)) {
if (!segment.isWordLike || !/[가-힣]/.test(segment.segment)) continue;
const range = document.createRange();
range.setStart(node, segment.index);
range.setEnd(node, segment.index + segment.segment.length);
const tops = [...range.getClientRects()].map(rect => Math.round(rect.top));
if (new Set(tops).size > 1) broken.push(segment.segment);
}
}
}
const sample = document.querySelector('.workbench .lead') || document.querySelector('.lab-brief > p');
const style = sample ? getComputedStyle(sample) : null;
return { broken, wordBreak: style?.wordBreak, overflowWrap: style?.overflowWrap };
});
assert.deepEqual(result.broken, [], `${label}: Korean words split across lines: ${result.broken.join(', ')}`);
assert.equal(result.wordBreak, 'keep-all', `${label}: Lab body must use keep-all`);
assert.equal(result.overflowWrap, 'anywhere', `${label}: long Latin tokens need a safe fallback`);
}
async function captureState(page, name) {
fs.mkdirSync(stateShotDir, { recursive: true });
for (const width of [360, 1280]) {
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
if (width === 360) {
await assertNoDocumentOverflow(page, `${name}@360`);
await assertKoreanWordIntegrity(page, `${name}@360`);
}
await page.screenshot({ path: path.join(stateShotDir, `${name}.w${width}.png`), fullPage: true });
}
await page.setViewport({ width: 360, height: 800, deviceScaleFactor: 1 });
}
async function captureResponsiveRoute(page, name, widths = [360, 768, 1280]) {
const original = page.viewport();
fs.mkdirSync(stateShotDir, { recursive: true });
for (const width of widths) {
await page.setViewport({ width, height: width === 360 ? 800 : 900, deviceScaleFactor: 1 });
await assertNoDocumentOverflow(page, `${name}@${width}`);
await page.screenshot({ path: path.join(stateShotDir, `${name}.w${width}.png`), fullPage: true });
}
await page.setViewport(original);
}
function contrastRatio(foreground, background) {
const rgb = value => value.match(/[\d.]+/g).slice(0, 3).map(Number);
const luminance = value => {
const [red, green, blue] = rgb(value).map(channel => {
const normalized = channel / 255;
return normalized <= .03928 ? normalized / 12.92 : ((normalized + .055) / 1.055) ** 2.4;
});
return .2126 * red + .7152 * green + .0722 * blue;
};
const first = luminance(foreground);
const second = luminance(background);
return (Math.max(first, second) + .05) / (Math.min(first, second) + .05);
}
async function assertContrastContract(page, label, expected = {}) {
const colors = await page.evaluate(() => {
const effectiveBackground = element => {
for (let current = element; current; current = current.parentElement) {
const background = getComputedStyle(current).backgroundColor;
if (background && background !== 'rgba(0, 0, 0, 0)') return background;
}
return getComputedStyle(document.body).backgroundColor;
};
const textSample = element => ({
foreground: getComputedStyle(element).color,
background: effectiveBackground(element),
});
const borderSample = element => ({
foreground: getComputedStyle(element).borderTopColor,
background: effectiveBackground(element),
});
return {
labId: textSample(document.querySelector('.lab-brief .lab-id')),
boundaryLabel: textSample(document.querySelector('.lab-brief .boundary b')),
pending: [...document.querySelectorAll('.trace-event.pending')].map(textSample),
choices: [...document.querySelectorAll('.choice')].map(borderSample),
textareas: [...document.querySelectorAll('textarea')].map(borderSample),
};
});
const ratio = sample => contrastRatio(sample.foreground, sample.background);
const metrics = {
labId: ratio(colors.labId),
boundaryLabel: ratio(colors.boundaryLabel),
pendingMin: colors.pending.length ? Math.min(...colors.pending.map(ratio)) : null,
choiceMin: colors.choices.length ? Math.min(...colors.choices.map(ratio)) : null,
textareaMin: colors.textareas.length ? Math.min(...colors.textareas.map(ratio)) : null,
};
assert.ok(metrics.labId >= 4.5, `${label}: Lab id contrast ${metrics.labId.toFixed(2)} < 4.5`);
assert.ok(metrics.boundaryLabel >= 4.5, `${label}: dark boundary label contrast ${metrics.boundaryLabel.toFixed(2)} < 4.5`);
if (expected.pending) {
assert.ok(metrics.pendingMin !== null && metrics.pendingMin >= 4.5, `${label}: pending text contrast ${metrics.pendingMin?.toFixed(2)} < 4.5`);
}
if (expected.choices) {
assert.ok(metrics.choiceMin !== null && metrics.choiceMin >= 3, `${label}: choice border contrast ${metrics.choiceMin?.toFixed(2)} < 3`);
}
if (expected.textarea) {
assert.ok(metrics.textareaMin !== null && metrics.textareaMin >= 3, `${label}: textarea border contrast ${metrics.textareaMin?.toFixed(2)} < 3`);
}
return metrics;
}
(async () => {
const server = createStaticServer({ root });
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const port = server.address().port;
const browser = await puppeteer.launch({
executablePath: process.env.CHROME_BIN || '/usr/bin/google-chrome',
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
const runtimeErrors = [];
const consoleErrors = [];
const requestFailures = [];
page.on('pageerror', error => runtimeErrors.push(error.message));
page.on('console', message => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
page.on('requestfailed', request => requestFailures.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`));
try {
const contrastEvidence = {};
await page.setViewport({ width: 360, height: 800, deviceScaleFactor: 1 });
const homeResponse = await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle0' });
assert.equal(homeResponse.status(), 200, 'production entry must return HTTP 200');
const responseHeaders = homeResponse.headers();
assert.match(responseHeaders['content-security-policy'], /default-src 'self'/);
assert.equal(responseHeaders['x-content-type-options'], 'nosniff');
assert.equal(responseHeaders['x-frame-options'], 'DENY');
await captureResponsiveRoute(page, 'home');
const mobileHealth = await page.evaluate(() => ({
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
jumpTop: document.querySelector('.entry-jump').getBoundingClientRect().top,
jumpVisible: getComputedStyle(document.querySelector('.entry-jump')).display !== 'none',
}));
assert.ok(mobileHealth.overflow <= 0, `mobile horizontal overflow=${mobileHealth.overflow}`);
assert.equal(mobileHealth.jumpVisible, true);
assert.ok(mobileHealth.jumpTop < 800, `entry jump is below first viewport: ${mobileHealth.jumpTop}`);
await assertEntryShortcut(page, 'keyboard');
await page.setViewport({ width: 768, height: 900, deviceScaleFactor: 1 });
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle0' });
const tabletColumns = await page.$eval('.hero', element => getComputedStyle(element).gridTemplateColumns.split(' ').length);
assert.equal(tabletColumns, 2, 'tablet hero must retain two-column editorial density');
await assertEntryShortcut(page, 'pointer');
await assertSkipLinkAcrossViewports(page, 'home');
await page.setViewport({ width: 360, height: 800, deviceScaleFactor: 1 });
await page.goto(`http://127.0.0.1:${port}/#/orient/concept`, { waitUntil: 'networkidle0' });
await captureResponsiveRoute(page, 'concept', [360, 1280]);
await assertSkipLinkAcrossViewports(page, 'concept');
await page.goto(`http://127.0.0.1:${port}/#/orient/symptom`, { waitUntil: 'networkidle0' });
await page.click('[data-reveal]');
await page.waitForFunction(() => /2 \/ 4/.test(document.querySelector('.folio-head')?.textContent || ''));
await captureResponsiveRoute(page, 'symptom', [360, 1280]);
await assertSkipLinkAcrossViewports(page, 'symptom-with-revealed-clue');
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle0' });
await page.click('a[href="#/orient/concept"]');
await page.waitForSelector('a[data-start-lab]');
await page.click('a[data-start-lab]');
await page.waitForSelector('input[name="prediction"]');
assert.equal(await page.$$eval('.progress-item', items => items.length), 5);
contrastEvidence.predict = await assertContrastContract(page, 'default-predict', { choices: true });
const hypothesis = 'input[name="prediction"][value="shared-stale-read"]';
await selectedAndFocused(page, hypothesis);
assert.equal(await page.$eval('[data-action="commit-prediction"]', button => button.disabled), false);
await clickAndWait(page, '[data-action="commit-prediction"]', () => document.querySelector('[data-action="advance"]'));
await assertObserveContainment(page, 'default@360');
await page.click('[data-action="advance"]');
await page.waitForFunction(() => document.activeElement?.dataset?.action === 'advance' && /실행 1\/6/.test(document.querySelector('#announcer').textContent));
await assertSkipLinkAcrossViewports(page, 'default-observe-cursor-1');
contrastEvidence.observe = await assertContrastContract(page, 'default-observe-cursor-1', { pending: true });
await captureState(page, 'observe');
for (let step = 1; step < 6; step += 1) {
await page.click('[data-action="advance"]');
const expectedAction = step === 5 ? 'to-compare' : 'advance';
await page.waitForFunction(action => document.activeElement?.dataset?.action === action, {}, expectedAction);
}
assert.equal(await page.$$eval('.trace-table thead th[scope="col"]', headers => headers.length), 4);
await clickAndWait(page, '[data-action="to-compare"]', () => document.querySelector('[data-action="to-explain"]'));
await captureState(page, 'compare');
await clickAndWait(page, '[data-action="to-explain"]', () => document.querySelector('[data-action="submit-explanation"]'));
await captureState(page, 'explain');
contrastEvidence.explain = await assertContrastContract(page, 'default-explain', { choices: true, textarea: true });
await selectedAndFocused(page, 'input[name="explanation-readBasis"][value="same-100"]');
await selectedAndFocused(page, 'input[name="explanation-finalWrite"][value="a-150"]');
await selectedAndFocused(page, 'input[name="explanation-lostChange"][value="a-plus-50"]');
await page.click('[data-action="submit-explanation"]');
await page.waitForFunction(() => document.activeElement?.id === 'explanation-feedback');
assert.match(await page.$eval('#explanation-feedback', element => element.textContent), /맞지 않습니다/);
assert.equal(await page.$('[data-action="to-transfer"]'), null);
await selectedAndFocused(page, 'input[name="explanation-finalWrite"][value="b-70"]');
assert.equal(await page.$('#explanation-feedback'), null, 'changed answer must reset submitted feedback');
await page.click('[data-action="submit-explanation"]');
await page.waitForFunction(() => document.activeElement?.id === 'explanation-feedback');
assert.ok(await page.$('[data-action="to-transfer"]'));
await clickAndWait(page, '[data-action="to-transfer"]', () => document.querySelector('[data-action="submit-transfer"]'));
await captureState(page, 'transfer');
await selectedAndFocused(page, 'input[name="transfer"][value="lost-restock"]');
await page.click('[data-action="submit-transfer"]');
await page.waitForFunction(() => document.activeElement?.id === 'transfer-feedback');
assert.match(await page.$eval('#transfer-feedback', element => element.textContent), /다시 비교하세요/);
assert.equal(await page.$('[data-action="complete"]'), null);
await selectedAndFocused(page, 'input[name="transfer"][value="lost-reservation"]');
assert.equal(await page.$('#transfer-feedback'), null, 'changed transfer must reset submitted feedback');
await page.click('[data-action="submit-transfer"]');
await page.waitForFunction(() => document.activeElement?.id === 'transfer-feedback');
await clickAndWait(page, '[data-action="complete"]', () => document.querySelector('.completion'));
await captureState(page, 'complete');
contrastEvidence.complete = await assertContrastContract(page, 'default-complete');
assert.match(await page.$eval('.completion h2', element => element.textContent), /세 인과 요소를 연결/);
assert.match(await page.$eval('.completion .lead', element => element.textContent), /자유 서술 능력이나 실제 장애 진단을 증명하지 않습니다/);
await clickAndWait(page, '[data-action="reset"]', () => document.querySelector('input[name="prediction"]'));
await captureState(page, 'predict');
assert.equal(await page.$eval('.lab-brief .value.observed b', element => element.textContent), '100');
const boundaryColors = await page.$eval('.lab-brief .boundary p', element => {
const style = getComputedStyle(element);
const background = getComputedStyle(element.closest('.boundary')).backgroundColor;
return { foreground: style.color, background };
});
assert.ok(contrastRatio(boundaryColors.foreground, boundaryColors.background) >= 4.5, 'Lab boundary contrast must be AA');
await page.goto(`http://127.0.0.1:${port}/#/orient/symptom`, { waitUntil: 'networkidle0' });
for (let clue = 1; clue < 4; clue += 1) await page.click('[data-reveal]');
await page.waitForSelector('a[data-start-lab]');
await page.click('a[data-start-lab]');
await page.waitForSelector('input[name="prediction"]');
assert.equal(await page.$eval('main[data-scenario-id]', element => element.dataset.scenarioId), 'tx-lost-update-01');
await page.setViewport({ width: 360, height: 800, deviceScaleFactor: 1 });
await page.goto(`http://127.0.0.1:${port}/#/lab/tx-lost-update-inventory-fixture`, { waitUntil: 'networkidle0' });
await page.waitForSelector('input[name="prediction"]');
assert.equal(await page.$eval('main[data-scenario-id]', element => element.dataset.scenarioId), 'tx-lost-update-inventory-fixture');
assert.match(await page.$eval('.lab-top .lab-id', element => element.textContent), /Inventory fixture/);
assert.deepEqual(await page.$$eval('.lab-brief .value b', elements => elements.map(element => element.textContent)), ['50', '60', '50']);
assert.match(await page.$eval('.choice span', element => element.textContent), /같은 50/);
const fixtureTokenWrap = await page.$eval('.lab-brief > p', element => ({ client: element.clientWidth, scroll: element.scrollWidth }));
assert.ok(fixtureTokenWrap.scroll <= fixtureTokenWrap.client, `fixture Latin token overflow ${fixtureTokenWrap.client} -> ${fixtureTokenWrap.scroll}`);
await assertNoDocumentOverflow(page, 'fixture-predict@360');
await assertKoreanWordIntegrity(page, 'fixture-predict@360');
await selectedAndFocused(page, 'input[name="prediction"][value="fixture-shared-50"]');
await clickAndWait(page, '[data-action="commit-prediction"]', () => document.querySelector('[data-action="advance"]'));
assert.equal(await page.$$eval('.trace-table tbody tr', rows => rows.length), 4, 'fixture schedule must not inherit the default six rows');
assert.match(await page.$eval('.trace-scroll', element => element.getAttribute('aria-label')), /Inventory fixture/);
await assertObserveContainment(page, 'fixture@360');
await page.click('[data-action="advance"]');
await page.waitForFunction(() => document.activeElement?.dataset?.action === 'advance' && /실행 1\/4/.test(document.querySelector('#announcer').textContent));
assert.match(await page.$eval('#announcer', element => element.textContent), /A가 재고 50/);
await assertSkipLinkAcrossViewports(page, 'fixture-observe-cursor-1');
contrastEvidence.fixtureObserve = await assertContrastContract(page, 'fixture-observe-cursor-1', { pending: true });
for (let step = 1; step < 4; step += 1) {
await page.click('[data-action="advance"]');
const expectedAction = step === 3 ? 'to-compare' : 'advance';
await page.waitForFunction(action => document.activeElement?.dataset?.action === action, {}, expectedAction);
}
await clickAndWait(page, '[data-action="to-compare"]', () => document.querySelector('[data-action="to-explain"]'));
const fixtureEvidence = await page.$eval('.causal-ledger', element => element.textContent);
assert.match(fixtureEvidence, /A=70 다음 B=40/);
assert.doesNotMatch(fixtureEvidence, /A=150/);
await clickAndWait(page, '[data-action="to-explain"]', () => document.querySelector('[data-action="submit-explanation"]'));
await selectedAndFocused(page, 'input[name="explanation-readBasis"][value="same-50"]');
await selectedAndFocused(page, 'input[name="explanation-finalWrite"][value="b-40"]');
await selectedAndFocused(page, 'input[name="explanation-lostChange"][value="a-plus-20"]');
await page.click('[data-action="submit-explanation"]');
await page.waitForFunction(() => document.activeElement?.id === 'explanation-feedback');
assert.match(await page.$eval('#explanation-feedback', element => element.textContent), /Fixture 인과/);
await clickAndWait(page, '[data-action="to-transfer"]', () => document.querySelector('[data-action="submit-transfer"]'));
assert.deepEqual(await page.$$eval('.value-comparison b', elements => elements.map(element => element.textContent)), ['100', '110', '115']);
await selectedAndFocused(page, 'input[name="transfer"][value="lost-use"]');
await page.click('[data-action="submit-transfer"]');
await page.waitForFunction(() => document.activeElement?.id === 'transfer-feedback');
assert.match(await page.$eval('#transfer-feedback', element => element.textContent), /Fixture 전이 성공/);
await clickAndWait(page, '[data-action="complete"]', () => document.querySelector('.completion'));
assert.match(await page.$eval('.completion h2', element => element.textContent), /Fixture의 세 인과 요소/);
assert.match(await page.$eval('.principle b', element => element.textContent), /원자적 재고 변경/);
await clickAndWait(page, '[data-action="reset"]', () => document.querySelector('input[name="prediction"]'));
assert.equal(await page.$eval('.lab-brief .value.observed b', element => element.textContent), '50');
assert.equal(await page.$eval('main[data-scenario-id]', element => element.dataset.scenarioId), 'tx-lost-update-inventory-fixture');
assert.deepEqual(runtimeErrors, []);
assert.deepEqual(consoleErrors, []);
assert.deepEqual(requestFailures, []);
console.log(`PASS contrast metrics ${JSON.stringify(contrastEvidence)}`);
console.log('PASS production E2E: secure static delivery, dual-entry, contained Observe, entry focus, route-safe skip link, Korean type, scenario binding, causal loop, semantics and contrast');
} finally {
await browser.close();
await new Promise(resolve => server.close(resolve));
}
})().catch(error => {
console.error(error.stack || error);
process.exitCode = 1;
});