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