/** * uat-ui/walker.ts — PURE journey builder: plan × role → ordered browser steps. * * Encodes the execution semantics the runner replays: * - per_role overrides (anonymous: goto + redirect_login) and the route's * ground-truth verdict pick each step's EXPECTED outcome; * - click_row_in_parent steps stay right after their parent list (plan order * already groups views per nav node) and degrade to a placeholder-id goto when * the role cannot reach the parent list anyway (denial still provable: the * route guard answers before any data loads); * - when writes are enabled, an allowed create page chains the REAL user write * journey — create_flow (fill+submit) then edit_flow / delete_flow scoped to * the run's marker — capped like everything else by actions_per_role. * * No I/O, no Date, no randomness: same plan + role + options ⇒ same journey. */ import type { PlanTest, Route } from '../lib/plantest-schema.js'; import type { UiAccessOutcome, UiStepKind } from '../lib/run-results.js'; export type StepStrategy = 'menu_click' | 'click_row_in_parent' | 'goto'; export interface JourneyStep { kind: UiStepKind; routeId: string; componentKey?: string; /** URL to reach (placeholder id already substituted when needed). */ url: string; strategy: StepStrategy; /** Parent list URL for click_row / write flows. */ parentRoute?: string; expected: UiAccessOutcome; /** base = the node's list/home page; detail/edit/create = implicit-suffix views. */ view: 'base' | 'detail' | 'edit' | 'create' | 'other'; /** Fail an allowed page that logs console errors (plan route.expect.no_console_error). */ noConsoleError: boolean; /** UAT row marker for write flows. */ marker?: string; /** Walker notes the runner surfaces as warnings (e.g. degraded navigation). */ note?: string; /** Permission the plan resolved for the route (report diagnosis). */ permission?: string; } /** Placeholder id for direct-goto denial proofs on `/:id` routes. */ export const PLACEHOLDER_ID = '00000000-0000-0000-0000-000000000000'; /** Substitute every `:param` segment with the placeholder id. PURE. */ export function substituteParams(url: string, id: string = PLACEHOLDER_ID): string { return url.replace(/:(?!\d)\w+/g, id); } /** Classify the view a plan route renders. PURE. */ export function viewOfRoute(route: Route): JourneyStep['view'] { if (/\/(create|new|import)$/.test(route.route)) return 'create'; if (/\/:(?!\d)\w+\/edit$/.test(route.route)) return 'edit'; if (route.navigation_strategy === 'click_row_in_parent') return 'detail'; if (route.navigation_strategy === 'menu_click') return 'base'; return 'other'; } /** Deterministic UAT row marker for a nav node. PURE. */ export function markerFor(routeId: string, runTag: string): string { const compact = routeId.replace(/[^A-Z0-9]+/g, '').slice(0, 12); return `UAT-${compact}-${runTag}`; } export interface BuildJourneyOptions { /** Exercise the write flows on allowed create pages. */ writes: boolean; /** Distinguishes this run's rows in markers (e.g. a runId suffix). */ runTag: string; /** Cap on steps for the role (plan execution.caps.actions_per_role). */ maxSteps: number; } export interface RoleJourney { role: string; /** True ⇒ the role browses logged OUT (no credential needed). */ anonymous: boolean; steps: JourneyStep[]; /** Steps dropped by the actions_per_role cap. */ truncated: number; } /** Expected outcome for a route × role, honoring per_role overrides. PURE. */ export function expectedFor(plan: PlanTest, route: Route, role: string): UiAccessOutcome { const override = plan.execution.per_role[role]?.expect; if (override) return override; return route.access[role] ?? 'denied'; } /** Build the ordered journey one role replays. PURE. */ export function buildRoleJourney(plan: PlanTest, role: string, opts: BuildJourneyOptions): RoleJourney { const anonymous = role === 'anonymous'; const forcedGoto = anonymous || plan.execution.per_role[role]?.mode === 'goto'; const steps: JourneyStep[] = []; // Track, per parent route, whether the role saw the parent list (click_row viability) // and the node's edit view (edit_flow gating). const allowedParents = new Set(); const editViewByParent = new Map(); for (const route of plan.routes) { if (viewOfRoute(route) === 'edit' && route.parent_route) { editViewByParent.set(route.parent_route, true); } } for (const route of plan.routes) { const expected = expectedFor(plan, route, role); const view = viewOfRoute(route); if (view === 'base' && expected === 'allowed') allowedParents.add(route.route); const base: Omit = { kind: 'page', routeId: route.id, ...(route.component_key ? { componentKey: route.component_key } : {}), expected, view, noConsoleError: route.expect.no_console_error, ...(route.parent_route ? { parentRoute: route.parent_route } : {}), ...(route.permission ? { permission: route.permission } : {}), }; if (forcedGoto) { steps.push({ ...base, url: substituteParams(route.route), strategy: 'goto' }); continue; } if (route.navigation_strategy === 'click_row_in_parent') { const parentReachable = route.parent_route ? allowedParents.has(route.parent_route) : false; if (expected === 'allowed' && parentReachable) { steps.push({ ...base, url: route.route, strategy: 'click_row_in_parent' }); } else { // Denied (or unreachable parent): prove the verdict by direct goto — the // route guard answers before any row data is needed. steps.push({ ...base, url: substituteParams(route.route), strategy: 'goto', note: expected === 'allowed' ? 'parent list not reachable for this role — degraded to direct goto with a placeholder id' : undefined, }); } continue; } const strategy: StepStrategy = route.navigation_strategy === 'menu_click' ? 'menu_click' : 'goto'; steps.push({ ...base, url: substituteParams(route.route), strategy }); // Real write journey: an allowed create page chains create → edit → delete, // scoped to this run's marker, against the parent list. if ( opts.writes && !anonymous && view === 'create' && expected === 'allowed' && base.parentRoute === undefined // create is collection-level: parent = the base route ) { const parentRoute = route.route.replace(/\/(create|new|import)$/, ''); if (allowedParents.has(parentRoute)) { const marker = markerFor(route.id, opts.runTag); steps.push({ ...base, kind: 'create_flow', url: route.route, strategy: 'goto', parentRoute, marker }); if (editViewByParent.get(parentRoute)) { steps.push({ ...base, kind: 'edit_flow', url: parentRoute, strategy: 'goto', parentRoute, marker }); } steps.push({ ...base, kind: 'delete_flow', url: parentRoute, strategy: 'goto', parentRoute, marker }); } } } const cap = Math.max(1, opts.maxSteps); const truncated = steps.length > cap ? steps.length - cap : 0; return { role, anonymous, steps: steps.slice(0, cap), truncated }; } /** Journeys for every selected role, in plan-role order. PURE. */ export function buildJourneys( plan: PlanTest, opts: BuildJourneyOptions & { roles?: readonly string[] }, ): RoleJourney[] { const selected = (opts.roles && opts.roles.length > 0 ? opts.roles : plan.roles).filter((r) => plan.roles.includes(r), ); return selected.map((role) => buildRoleJourney(plan, role, { writes: opts.writes, runTag: opts.runTag, maxSteps: opts.maxSteps }), ); }