Files
HMDesign/scripts/verify-quality-runtime.mjs
T

141 lines
13 KiB
JavaScript

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;