/** * uat-ui/validate.ts — Spec parse + resolution of plan, credentials, frontend URL * and the run layout for the UI-axis runner. Credentials are required as soon as * one selected role is authenticated; anonymous-only runs work without them. */ import path from 'node:path'; import { existsSync, statSync } from 'node:fs'; import { findSmartStackStructure } from '../../../lib/detector.js'; import { resolveFrontendUrl } from '../lib/app-config.js'; import { locatePlan, loadPlan } from '../lib/plan-io.js'; import { loadUsersFile, usersFileRelPath, type UatUsersFile } from '../lib/users-file.js'; import { defaultRunId, runDirRelPath } from '../lib/run-id.js'; import type { PlanTest } from '../lib/plantest-schema.js'; import { UatUiInputSchema, type UatUiInput } from './types.js'; export interface UiRunContext { spec: UatUiInput; projectRoot: string; application: string; plan: PlanTest; planRelPath: string; users: UatUsersFile | null; frontendUrl: string; frontendUrlSource: string; /** Roles to run, in plan order. */ roles: string[]; runDirRel: string; runId: string; readinessTimeoutMs: number; retryOnNotReady: number; } export interface UiValidation { valid: boolean; context: UiRunContext | null; errors: string[]; warnings: string[]; } const fail = (errors: string[], warnings: string[] = []): UiValidation => ({ valid: false, context: null, errors, warnings, }); export async function validate(raw: unknown): Promise { const parsed = UatUiInputSchema.safeParse(raw); if (!parsed.success) { return fail(parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`)); } const spec = parsed.data; const warnings: string[] = []; const projectRoot = path.resolve(spec.projectPath); if (!existsSync(projectRoot) || !statSync(projectRoot).isDirectory()) { return fail([`projectPath is not a directory: ${projectRoot}`]); } const located = locatePlan({ projectRoot, planFile: spec.planFile, path: spec.path }); if (!located.ok) return fail([located.error]); const loaded = loadPlan(located.location.absPath); if (!loaded.ok) return fail(loaded.errors); warnings.push(...loaded.violations); const plan = loaded.plan; const application = plan.meta.application; if (plan.routes.length === 0) { return fail(['The plan carries no routes[] — nothing to drive in a browser. Regenerate with /uat plan.']); } const roles = (spec.roles && spec.roles.length > 0 ? spec.roles : plan.roles).filter((r) => plan.roles.includes(r), ); if (roles.length === 0) return fail([`None of the requested roles exist in the plan (${plan.roles.join(', ')}).`]); const usersAbs = spec.usersFile ? path.resolve(projectRoot, spec.usersFile) : path.join(projectRoot, usersFileRelPath(application)); const users = loadUsersFile(usersAbs); const authRoles = roles.filter((r) => r !== 'anonymous'); if (authRoles.length > 0 && !users) { return fail([ `No usable uat-users.json (looked at ${usersAbs}). Run \`/uat provision\` first — the UI axis logs in as: ${authRoles.join(', ')}.`, ]); } const structure = await findSmartStackStructure(projectRoot); const webDir = structure.web ?? ''; const frontend = resolveFrontendUrl(webDir || projectRoot, spec.frontendUrl); if (!webDir && !spec.frontendUrl) { warnings.push('Web app not located — frontend URL fell back to the default; pass "frontendUrl" if it differs.'); } const runId = spec.runId ?? defaultRunId(); const runDirRel = (spec.outDir ?? runDirRelPath(application, runId)).replace(/\/+$/, ''); return { valid: true, context: { spec, projectRoot, application, plan, planRelPath: located.location.relPath, users, frontendUrl: frontend.value, frontendUrlSource: frontend.source, roles, runDirRel, runId, readinessTimeoutMs: spec.readinessTimeoutMs ?? plan.execution.readiness.timeout_ms, retryOnNotReady: plan.execution.readiness.retry_on_not_ready, }, errors: [], warnings, }; }