#!/usr/bin/env node /** * cli:uat-report — Render the single-file HTML report from a run's artifacts * (`api-results.json` / `ui-results.json`). At least one axis must be present; * a missing axis renders as an empty section, never an error. */ import { parseArgs } from 'node:util'; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { failGenerate, generateEnvelope, printEnvelope } from '../../../lib/output.js'; import { ApiRunFileSchema, UiRunFileSchema, type ApiRunFile, type UiRunFile } from '../lib/run-results.js'; import { runDirRelPath } from '../lib/run-id.js'; import { loadPlan } from '../lib/plan-io.js'; import { UatReportInputSchema } from './types.js'; import { buildModel, resolveThresholds } from './build-model.js'; import { renderHtml } from './render-html.js'; import { collectScreenshotEmbeds } from './screenshot-embeds.js'; const COMMAND = 'uat-report'; function readRunFile(absPath: string, schema: { safeParse: (v: unknown) => { success: boolean; data?: T } }): T | null { try { if (!existsSync(absPath)) return null; const parsed = schema.safeParse(JSON.parse(readFileSync(absPath, 'utf-8'))); return parsed.success ? (parsed.data as T) : null; } catch { return null; } } /** Latest run dir (lexicographic — run ids are sortable timestamps). */ function latestRunDir(absRunsRoot: string): string | null { try { const entries = readdirSync(absRunsRoot, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name) .sort(); return entries.length > 0 ? entries[entries.length - 1] : null; } catch { return null; } } async function main(): Promise { let values: { spec?: string }; try { values = parseArgs({ options: { spec: { type: 'string' } }, strict: true }).values; } catch (e) { printEnvelope(failGenerate(COMMAND, [`Invalid arguments: ${(e as Error).message}`])); process.exit(1); return; } if (!values.spec) { printEnvelope(failGenerate(COMMAND, ['--spec is required'])); process.exit(1); } let raw: unknown; try { raw = JSON.parse(values.spec); } catch { printEnvelope(failGenerate(COMMAND, ['Invalid JSON in --spec'])); process.exit(1); } const parsed = UatReportInputSchema.safeParse(raw); if (!parsed.success) { printEnvelope(failGenerate(COMMAND, parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`))); process.exit(1); return; } const spec = parsed.data; const projectRoot = path.resolve(spec.projectPath); // Resolve the run dir: explicit, or default layout (+ latest when runId omitted). let runDirAbs: string; if (spec.runDir) { runDirAbs = path.isAbsolute(spec.runDir) ? spec.runDir : path.join(projectRoot, spec.runDir); } else if (spec.path) { const application = spec.path.split('/').map((s) => s.trim()).filter(Boolean)[0] ?? spec.path; if (spec.runId) { runDirAbs = path.join(projectRoot, runDirRelPath(application, spec.runId)); } else { const runsRoot = path.join(projectRoot, `.application-test/uat/${application}/runs`); const latest = latestRunDir(runsRoot); if (!latest) { printEnvelope(failGenerate(COMMAND, [`No runs under ${runsRoot}. Run /uat api and/or /uat ui first.`])); process.exit(1); return; } runDirAbs = path.join(runsRoot, latest); } } else { printEnvelope(failGenerate(COMMAND, ['spec needs "runDir" or "path".'])); process.exit(1); return; } const apiFile = readRunFile(path.join(runDirAbs, 'api-results.json'), ApiRunFileSchema); const uiFile = readRunFile(path.join(runDirAbs, 'ui-results.json'), UiRunFileSchema); if (!apiFile && !uiFile) { printEnvelope( failGenerate(COMMAND, [`No run artifacts in ${runDirAbs} (expected api-results.json and/or ui-results.json).`]), ); process.exit(1); return; } // UI perf bands come from the run's PLAN (execution.perf) unless the spec // overrides them — keeps the report's bands consistent with the runner's // perfWarnings. A missing/unreadable plan degrades to defaults + a warning. const runWarnings = [...spec.runWarnings]; let planPerf: { warn_ms: number; slow_ms: number } | null = null; const planPath = (apiFile ?? uiFile)!.meta.planPath; const planAbs = path.isAbsolute(planPath) ? planPath : path.join(projectRoot, planPath); const loaded = loadPlan(planAbs); if (loaded.ok) { planPerf = loaded.plan.execution.perf; } else { runWarnings.push(`[report] plan not loadable (${planAbs}) — UI perf bands defaulted`); } const model = buildModel({ apiFile, uiFile, title: spec.title, generatedAt: spec.generatedAt ?? new Date().toISOString(), thresholds: resolveThresholds( { apiWarnMs: spec.apiWarnMs, apiSlowMs: spec.apiSlowMs, uiWarnMs: spec.uiWarnMs, uiSlowMs: spec.uiSlowMs }, planPerf, ), runWarnings, screenshots: collectScreenshotEmbeds(runDirAbs, uiFile), }); const html = renderHtml(model); const outAbs = spec.out ? path.isAbsolute(spec.out) ? spec.out : path.join(projectRoot, spec.out) : path.join(runDirAbs, 'report.html'); mkdirSync(path.dirname(outAbs), { recursive: true }); writeFileSync(outAbs, html, 'utf-8'); printEnvelope( generateEnvelope(COMMAND, { data: { report: outAbs, runDir: runDirAbs, application: model.application, runId: model.runId, verdict: model.verdict, overallPassRate: model.overallPassRate, api: apiFile ? { executed: model.api?.executed, passed: model.api?.passed, failed: model.api?.failed } : null, ui: uiFile ? { executed: model.ui?.executed, passed: model.ui?.passed, failed: model.ui?.failed } : null, }, filesCreated: [outAbs], nextSteps: [`Open the report: ${outAbs}`], }), ); } void main();