feat: integrate latest HMDesign design system updates
This commit is contained in:
1 parent
99faec189e
commit
1c4e0bb9a1
209 files changed
+8845
-552
No files matched your search
@@ -0,0 +1,140 @@
|
||||
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;
|
||||
Reference in new issue
Block a user