UNPKG

paqad-ai

Version:

Spec-driven development framework — AI agents that think before they type

189 lines (166 loc) • 6.04 kB
#!/usr/bin/env -S npx tsx import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; interface Args { runId: string; artifactDir: string; targetUrl: string | null; surfaceInventory: string; breakpoints: Array<{ name: string; width: number; height: number }>; } function parseArgs(): Args { const env = process.env; const runId = env.DESIGN_TEST_RUN_ID ?? 'manual'; const artifactDir = env.DESIGN_TEST_ARTIFACT_DIR ?? `.paqad/design-test/runs/${runId}/artifacts`; const targetUrl = env.DESIGN_TEST_TARGET_URL ?? null; const surfaceInventory = env.DESIGN_TEST_SURFACE_INVENTORY ?? join(artifactDir, 'surface-summary.json'); const breakpoints = env.DESIGN_TEST_BREAKPOINTS ? JSON.parse(env.DESIGN_TEST_BREAKPOINTS) : [ { name: 'sm', width: 640, height: 1024 }, { name: 'md', width: 768, height: 1024 }, { name: 'lg', width: 1024, height: 768 }, { name: 'xl', width: 1280, height: 800 }, ]; return { runId, artifactDir, targetUrl, surfaceInventory, breakpoints }; } function writeBlocked(args: Args, reason: string): void { const dir = join(args.artifactDir, 'runtime'); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, 'runtime-checks.json'), JSON.stringify( { target_url: args.targetUrl, reachable: false, blocked: reason, checks: [] }, null, 2, ) + '\n', ); console.error(`runtime-checks blocked: ${reason}`); } async function main(): Promise<void> { const args = parseArgs(); if (!args.targetUrl) { writeBlocked(args, 'no-target-url'); return; } let playwright: typeof import('playwright'); try { playwright = await import('playwright'); } catch { writeBlocked(args, 'playwright-not-installed'); return; } let axeBuilder: typeof import('@axe-core/playwright').default | null = null; try { axeBuilder = (await import('@axe-core/playwright')).default; } catch {} let routes: Array<{ path: string; source: string }> = []; if (existsSync(args.surfaceInventory)) { const raw = JSON.parse(readFileSync(args.surfaceInventory, 'utf8')); routes = raw.routes ?? []; } if (routes.length === 0) routes = [{ path: '/', source: '(default)' }]; const dir = join(args.artifactDir, 'runtime'); const screenshotsDir = join(dir, 'screenshots'); mkdirSync(screenshotsDir, { recursive: true }); const browser = await playwright.chromium.launch(); const evidence = { target_url: args.targetUrl, reachable: false, routes: [] as Array<{ path: string; breakpoints: Array<{ name: string; width: number; height: number; screenshot: string; scrollWidth: number; viewportWidth: number; horizontalScroll: boolean; computed_styles: Record<string, Record<string, string>>; axe_violations: unknown[]; }>; }>, blocked_checks: [] as string[], }; if (!axeBuilder) evidence.blocked_checks.push('axe-core-playwright-not-installed'); for (const route of routes) { const routeRecord = { path: route.path, breakpoints: [] as (typeof evidence.routes)[number]['breakpoints'], }; for (const bp of args.breakpoints) { const context = await browser.newContext({ viewport: { width: bp.width, height: bp.height }, }); const page = await context.newPage(); const url = new URL(route.path, args.targetUrl).toString(); try { const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 }); if (response && response.ok()) evidence.reachable = true; } catch { await context.close(); continue; } const screenshotName = `${route.path.replace(/[^a-z0-9]+/gi, '_') || 'root'}__${bp.name}.png`; const screenshotPath = join(screenshotsDir, screenshotName); await page.screenshot({ path: screenshotPath, fullPage: true }); const scroll = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: window.innerWidth, })); const computed_styles = await page.evaluate(() => { const targets = ['body', 'h1', 'h2', 'h3', 'p', 'button', 'a', 'input']; const out: Record<string, Record<string, string>> = {}; for (const sel of targets) { const el = document.querySelector(sel); if (!el) continue; const cs = getComputedStyle(el); out[sel] = { color: cs.color, background: cs.backgroundColor, 'font-family': cs.fontFamily, 'font-size': cs.fontSize, 'line-height': cs.lineHeight, 'border-radius': cs.borderRadius, 'box-shadow': cs.boxShadow, }; } return out; }); let axe_violations: unknown[] = []; if (axeBuilder) { try { const results = await new axeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) .analyze(); axe_violations = results.violations; } catch {} } routeRecord.breakpoints.push({ name: bp.name, width: bp.width, height: bp.height, screenshot: screenshotPath, scrollWidth: scroll.scrollWidth, viewportWidth: scroll.viewportWidth, horizontalScroll: scroll.scrollWidth > scroll.viewportWidth, computed_styles, axe_violations, }); await context.close(); } evidence.routes.push(routeRecord); } await browser.close(); mkdirSync(dirname(join(dir, 'runtime-checks.json')), { recursive: true }); writeFileSync(join(dir, 'runtime-checks.json'), JSON.stringify(evidence, null, 2) + '\n'); console.log( `runtime-checks complete: ${evidence.routes.length} routes × ${args.breakpoints.length} breakpoints`, ); } main().catch((err) => { console.error('runtime-checks failed:', err); process.exit(1); });