import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; import { spawnSync } from 'node:child_process'; import { combineEvidence } from './guard-evidence.mjs'; const output = path.resolve(process.env.HM_QUALITY_OUTPUT ?? '_local/quality-20261010'); const base = process.env.HM_QUALITY_URL ?? 'http://127.0.0.1:6321'; const playwright = process.env.HM_PLAYWRIGHT_DIR; const require = createRequire(playwright ? path.join(playwright, 'package.json') : import.meta.url); const { chromium } = require('playwright'); const browser = await chromium.launch({ headless: true, ...(process.env.HM_CHROME_PATH ? { executablePath: process.env.HM_CHROME_PATH } : {}) }); fs.mkdirSync(output, { recursive: true }); const summary = []; const wanted = process.argv.slice(2); try { for (let n = 1; n <= 10; n++) { const id = String(n).padStart(2, '0'); if (wanted.length && !wanted.includes(id)) continue; const target = path.resolve('examples/ai-quality', id); const spec = JSON.parse(fs.readFileSync(path.join(target, 'requirements.json'))); const result = spawnSync(process.execPath, ['scripts/ai-generation-guard.mjs', '--target', target, '--scope', 'FULL_SCREEN', '--mode', spec.mode, '--result', 'index.html', '--json'], { encoding: 'utf8' }); if (result.error) throw result.error; const guard = JSON.parse(result.stdout); fs.writeFileSync(path.join(output, `guard-${id}.json`), result.stdout); const runtime = { sourceFingerprint: guard.sourceFingerprint, contractFingerprint: guard.contractFingerprint, capturedAt: new Date().toISOString(), url: `${base}/${id}/index.html`, viewports: [], interactions: [] }; for (const width of [390, 768, 1280, 1440]) { const page = await browser.newPage({ viewport: { width, height: 900 } }); const errors = []; page.on('pageerror', e => errors.push(e.message)); page.on('console', e => { if (e.type() === 'error') errors.push(e.text()); }); try { await page.goto(runtime.url, { waitUntil: 'networkidle' }); await page.locator('#root [data-slot]:visible').first().waitFor(); await page.evaluate(() => document.fonts.ready); const metrics = await page.evaluate(() => { const visible = e => !!e.getClientRects().length && getComputedStyle(e).visibility !== 'hidden' && getComputedStyle(e).display !== 'none'; const ids = [...document.querySelectorAll('[id]')].map(e => e.id); const controls = [...document.querySelectorAll('input:not([type="hidden"]),textarea,select,button,[role="checkbox"],[role="switch"]')].filter(visible).filter(e => !e.closest('[aria-hidden="true"]')); const name = e => e.getAttribute('aria-label') || (e.getAttribute('aria-labelledby') ?? '').split(/\s+/).map(id => document.getElementById(id)?.textContent ?? '').join('').trim() || [...(e.labels ?? [])].map(l => l.textContent).join('').trim() || e.textContent?.trim(); const dashboard = document.querySelector('[data-slot="dashboard-scroll"]'); const frame = document.querySelector('[data-slot="dashboard-layout"]'); const header = document.querySelector('[data-slot="dashboard-header"]'); const flex = [...document.querySelectorAll('.flex')].find(visible); const grid = [...document.querySelectorAll('.grid')].find(visible); const tabs = [...document.querySelectorAll('[role="tab"][aria-selected="true"]')]; return { pageOverflow: document.documentElement.scrollWidth > innerWidth + 1, duplicateIds: ids.filter((id, i) => ids.indexOf(id) !== i), unnamedControls: controls.filter(e => !name(e)).map(e => ({ tag: e.tagName, slot: e.dataset.slot })), brokenActiveTabs: tabs.filter(e => !document.getElementById(e.getAttribute('aria-controls'))).map(e => e.id), theme: getComputedStyle(document.documentElement).getPropertyValue('--hm-sem-fg-primary').trim(), fontReady: document.fonts.check('16px "Noto Sans KR"'), fontFamily: getComputedStyle(document.body).fontFamily, utilityFlex: flex ? getComputedStyle(flex).display : null, utilityGrid: grid ? getComputedStyle(grid).display : null, dashboard: dashboard ? { headerHeight: header.getBoundingClientRect().height, frameWidth: frame.getBoundingClientRect().width, internalOverflow: dashboard.scrollWidth > dashboard.clientWidth, overflowX: getComputedStyle(dashboard).overflowX } : null, patternSlots: [...new Set([...document.querySelectorAll('[data-slot]')].filter(visible).map(e => e.dataset.slot))], filterLayout: document.querySelector('[data-slot="filter-bar"]')?.dataset.layout ?? null, actionNames: [...document.querySelectorAll('main button')].filter(visible).filter(e => e.dataset.slot === 'button').map(name), }; }); const issues = []; if (metrics.pageOverflow) issues.push('page overflow'); if (metrics.duplicateIds.length || metrics.unnamedControls.length || metrics.brokenActiveTabs.length) issues.push('accessible identity'); if (!metrics.theme || !metrics.fontReady) issues.push('theme / font not applied'); if (metrics.utilityFlex && metrics.utilityFlex !== 'flex' || metrics.utilityGrid && metrics.utilityGrid !== 'grid') issues.push('utilities not applied'); if (metrics.dashboard && (metrics.dashboard.headerHeight !== 48 || metrics.dashboard.frameWidth < 960 || width < 960 && !metrics.dashboard.internalOverflow)) issues.push('Dashboard geometry'); if (metrics.actionNames.some(name => !spec.actions.includes(name))) issues.push('unrequested main action'); if (errors.length) issues.push('runtime error'); await page.screenshot({ path: path.join(output, `${id}-${width}.png`), fullPage: true }); runtime.viewports.push({ width, status: issues.length ? 'FAIL' : 'PASS', issues, errors, metrics }); } catch (e) { runtime.viewports.push({ width, status: 'FAIL', error: e.message, errors }); } finally { await page.close(); } } const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); await page.goto(runtime.url, { waitUntil: 'networkidle' }); const check = async (name, action) => { try { await action(); runtime.interactions.push({ name, status: 'PASS' }); } catch (e) { runtime.interactions.push({ name, status: 'FAIL', error: e.message }); } }; const assert = (condition, message) => { if (!condition) throw Error(message); }; if (n <= 4) { await check('draft / apply / empty / reset', async () => { const search = page.getByRole('searchbox', { name: '이름 검색' }); const result = page.getByRole('status').filter({ hasText: '조회 결과' }); await search.fill('김'); assert((await result.innerText()).includes('3건'), 'draft applied too early'); await page.getByRole('button', { name: '조회', exact: true }).click(); assert((await result.innerText()).includes('1건'), 'query not applied'); await search.fill('없는이름'); await page.getByRole('button', { name: '조회', exact: true }).click(); assert((await result.innerText()).includes('0건'), 'empty result missing'); await page.getByRole('button', { name: '초기화', exact: true }).click(); assert((await result.innerText()).includes('3건') && await search.inputValue() === '', 'reset incomplete'); }); await check('clear restores keyboard focus', async () => { const search = page.getByRole('searchbox', { name: '이름 검색' }); await search.fill('김'); await page.getByRole('button', { name: '검색어 지우기' }).focus(); await page.keyboard.press('Enter'); assert(await search.evaluate(e => e === document.activeElement) && await search.inputValue() === '', 'focus lost after clear'); }); if (n === 3 || n === 4) await check('overlay name / Escape / focus return / confirmation', async () => { const trigger = page.getByRole('button', { name: n === 3 ? '결재 확인' : '채용 상세', exact: true }); await trigger.click(); await page.getByRole('dialog', { name: n === 3 ? '결재 확인' : '채용 상세' }).waitFor(); await page.keyboard.press('Escape'); await page.getByRole('dialog').waitFor({ state: 'hidden' }); assert(await trigger.evaluate(e => e === document.activeElement), 'overlay focus return'); await trigger.click(); await page.getByRole('button', { name: n === 3 ? '승인' : '확인', exact: true }).click(); assert((await page.getByRole('status').filter({ hasText: '조회 결과' }).innerText()).includes('처리 완료'), 'confirmation not applied'); }); } else if (n === 5) await check('invalid first focus / label focus / successful submit', async () => { await page.getByRole('button', { name: '제출', exact: true }).click(); const name = page.getByRole('textbox', { name: '요청자 이름' }); assert(await name.evaluate(e => e === document.activeElement), 'first invalid field not focused'); await name.fill('김한맥'); await page.getByText('처리에 동의합니다', { exact: true }).click(); await page.getByRole('button', { name: '제출', exact: true }).click(); assert((await page.getByRole('status').innerText()).includes('제출되었습니다'), 'submission failed'); }); else if (n === 6) await check('readOnly / checkbox / switch / save', async () => { const account = page.getByRole('textbox', { name: '계정' }); assert(await account.getAttribute('readonly') !== null, 'readOnly missing'); await page.getByRole('checkbox', { name: '편집 권한' }).click(); await page.getByRole('switch', { name: '알림 수신' }).click(); await page.getByRole('button', { name: '저장' }).click(); assert((await page.getByRole('status').innerText()).includes('편집 허용 · 알림 꺼짐'), 'settings mismatch'); }); else if (n === 7 || n === 8) await check('period keyboard / active panel / metric update', async () => { await page.getByRole('tab', { name: '이번 주' }).focus(); await page.keyboard.press('ArrowRight'); await page.getByRole('tab', { name: '이번 달' }).click(); assert(await page.getByRole('tabpanel').getByText('48건', { exact: true }).count() === 1, 'monthly metrics missing'); if (n === 8) assert(await page.getByRole('table').getByText('48', { exact: true }).count() === 1, 'monthly aggregation missing'); }); else if (n === 9) await check('Drawer Escape / focus return / apply', async () => { const trigger = page.getByRole('button', { name: '작업 변경 열기' }); await trigger.click(); await page.getByRole('dialog', { name: '작업 변경', exact: true }).waitFor(); await page.keyboard.press('Escape'); await page.getByRole('dialog').waitFor({ state: 'hidden' }); assert(await trigger.evaluate(e => e === document.activeElement), 'Drawer focus return'); await trigger.click(); await page.getByRole('button', { name: '적용', exact: true }).click(); assert((await page.getByRole('status').innerText()).includes('적용 완료'), 'apply failed'); }); else await check('loading / error / retry / empty', async () => { await page.getByRole('status', { name: '처리 중' }).waitFor(); await page.getByRole('button', { name: '오류', exact: true }).click(); await page.getByRole('alert').waitFor(); await page.getByRole('button', { name: '다시 시도' }).click(); assert((await page.getByRole('status').innerText()).includes('등록된 항목이 없습니다'), 'retry / empty missing'); }); if (spec.mode !== 'DASHBOARD_ONLY') await check('mobile menu Escape / focus return', async () => { await page.setViewportSize({ width: 390, height: 900 }); const menu = page.getByRole('button', { name: `${spec.title} 메뉴`, exact: true }); await menu.click(); await page.getByRole('dialog').waitFor(); await page.keyboard.press('Escape'); await page.getByRole('dialog').waitFor({ state: 'hidden' }); assert(await menu.evaluate(e => e === document.activeElement), 'mobile focus return'); }); await page.close(); fs.writeFileSync(path.join(output, `runtime-${id}.json`), JSON.stringify(runtime, null, 2)); const combined = combineEvidence(guard, runtime); fs.writeFileSync(path.join(output, `combined-${id}.json`), JSON.stringify(combined, null, 2)); summary.push({ id, title: spec.title, difficulty: spec.difficulty, mode: spec.mode, rawGuard: guard.status, staticStatus: guard.staticStatus, combinedStatus: combined.status, runtimeStatus: combined.runtimeStatus, staticIssues: guard.issues.filter(i => !i.code.startsWith('RENDER-')), viewports: runtime.viewports.map(({ width, status, issues, error }) => ({ width, status, issues, error })), interactions: runtime.interactions }); console.log(JSON.stringify(summary.at(-1))); } } finally { await browser.close(); } fs.writeFileSync(path.join(output, 'runtime-summary.json'), JSON.stringify(summary, null, 2)); if (summary.some(s => s.combinedStatus === 'FAIL')) process.exitCode = 1;