paqad-ai
Version:
Spec-driven development framework — AI agents that think before they type
189 lines (166 loc) • 6.04 kB
text/typescript
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);
});