462 lines
26 KiB
JavaScript
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;
|
|
});
|