/** * cli:run-ui-test — execute.ts * * Reads manifest + users, runs each test via dev-browser-driver, returns * an aggregated report. NO persistence here — the Studio runner consumes * the envelope and calls uiTestService for DB rows. * * Auto-correction is also out of scope for this CLI — it's the Studio * runner's responsibility (Vague 7). The CLI runs each test EXACTLY ONCE. */ import path from 'node:path'; import { promises as fs } from 'node:fs'; import { runDevBrowserTest, type DriverTestSpec, type DriverTestUser, } from './lib/dev-browser-driver.js'; import type { RunUiTestInput, RunUiTestResult, RunUiTestReport } from './types.js'; interface ManifestFile { generatedAt?: string; module: string; appCode: string; tests: Array<{ id: string; scenario: DriverTestSpec['scenario']; page: string; role: string; fixture?: Record; expectations: DriverTestSpec['expectations']; }>; } interface UsersFile { generatedAt?: string; module: string; appCode: string; users: Array<{ role: string; email: string; password: string; applicationCode: string; roleName: string; }>; } /** * Reachability pre-check the SKILL promises ("pings /health first and fails * clear if either is down") — nothing implemented it (audit 2026-09-24, * D-WARN-10). Any status below 500 counts as up (a secured API answers 401). * `fetchImpl` is injectable for tests. */ export async function probeHealth( spec: Pick, fetchImpl: typeof fetch = fetch, timeoutMs = 5000, ): Promise { const probes: Array<[string, string]> = [ ['API', `${spec.apiUrl.replace(/\/$/, '')}/health`], ['frontend', spec.frontendUrl], ]; for (const [label, url] of probes) { let status: number; try { status = (await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) })).status; } catch (err) { throw new Error(`${label} not reachable at ${url} (${err instanceof Error ? err.message : String(err)}) — start the backend and the frontend (ss dev up) before running UI tests`); } if (status >= 500) throw new Error(`${label} answered HTTP ${status} at ${url} — fix the server before running UI tests`); } } export interface Verdict { success: boolean; errors: string[]; } /** A run of ZERO tests is not a pass: `--module` not matching the manifest or a * `--test-id` matching nothing used to answer `totalTests:0, failed:0` → success. */ export function verdict( report: Pick, filter: { module?: string; testId?: string } = {}, ): Verdict { if (report.totalTests === 0) { const why = filter.testId ? `--test-id "${filter.testId}" matches no manifest test` : filter.module ? `--module "${filter.module}" does not match the manifest module` : 'the manifest declares no test'; return { success: false, errors: [`no test matched — ${why}; a run of zero tests is not a pass`] }; } return report.failed === 0 ? { success: true, errors: [] } : { success: false, errors: [`${report.failed}/${report.totalTests} test(s) failed`] }; } export async function execute(spec: RunUiTestInput): Promise { const manifestAbs = path.resolve(spec.projectPath, spec.manifestPath); const usersAbs = path.resolve(spec.projectPath, spec.usersPath); const manifest: ManifestFile = JSON.parse(await fs.readFile(manifestAbs, 'utf8')); const usersFile: UsersFile = JSON.parse(await fs.readFile(usersAbs, 'utf8')); const usersByRole = new Map(); for (const u of usersFile.users) { usersByRole.set(u.role, { role: u.role, email: u.email, password: u.password }); } // Filter tests by --test-id and --module if provided. let tests = manifest.tests; if (spec.testId) tests = tests.filter((t) => t.id === spec.testId); if (spec.module && spec.module !== manifest.module) { return { module: manifest.module, appCode: manifest.appCode, totalTests: 0, passed: 0, failed: 0, durationMs: 0, results: [], }; } // Reachability first — fail clear, never 60 s per test on ECONNREFUSED. if (tests.length > 0) await probeHealth(spec); const startedAt = Date.now(); const results: RunUiTestResult[] = []; for (const t of tests) { const user = usersByRole.get(t.role); if (!user) { results.push({ testId: t.id, scenario: t.scenario, page: t.page, role: t.role, status: 'fail', reason: `driver-error: no test user seeded for role "${t.role}"`, durationMs: 0, capture: { exitCode: null }, }); continue; } const driverResult = await runDevBrowserTest( { id: t.id, scenario: t.scenario, page: t.page, role: t.role, fixture: t.fixture, expectations: t.expectations, }, user, { frontendUrl: spec.frontendUrl, apiUrl: spec.apiUrl, captureDir: spec.captureDir, timeoutMs: spec.timeoutMs, cwd: spec.projectPath, }, ); results.push({ testId: t.id, scenario: t.scenario, page: t.page, role: t.role, status: driverResult.status, reason: driverResult.reason, durationMs: driverResult.durationMs, capture: driverResult.capture, }); } const passed = results.filter((r) => r.status === 'pass').length; const failed = results.length - passed; return { module: manifest.module, appCode: manifest.appCode, totalTests: results.length, passed, failed, durationMs: Date.now() - startedAt, results, }; }