/** * uat/cli/lib/plantest-schema.ts — The `.plantest.yml` contract (Zod) + invariants. * * `.plantest.yml` is the route-major, agent-legible test plan that `/uat` * generates (uat-plan) and the UI run agent consumes. This module is the single * source of truth for its shape and the structural invariants a plan MUST hold * before any run. PURE: no I/O — parse a candidate object, get back either a * typed plan or human-readable errors + invariant violations. * * Ported & generalised from SmartStack.app's `test-app-plan` plan-schema.md (the * IA-runner contract): route-major + per-role visibility, plus two /uat additions * — `endpoints[]` (deterministic API axis) and `execution.perf` (per-page timings). */ import { z } from 'zod'; import { DEFAULT_THRESHOLDS } from './run-results.js'; /** * Page/endpoint access verdict for a role. `partial` is NEVER stored — it is a * DERIVED display label (page allowed + >=1 subset legitimately hidden), computed * in access-classify.ts. The plan only encodes the three ground-truth verdicts. */ export const AccessVerdictSchema = z.enum(['allowed', 'denied', 'redirect_login']); export type AccessVerdict = z.infer; /** Semantic taxonomy from the brief (onglet/edit/create/list/detail/action). */ export const SubsetTypeSchema = z.enum(['onglet', 'list', 'detail', 'create', 'edit', 'action']); /** Mechanical nature of the subset node in the DOM. */ export const SubsetKindSchema = z.enum(['tab', 'button', 'form', 'list_item']); /** What the runner DOES with the subset when it is visible for a role. */ export const ActionKindSchema = z.enum([ 'navigate', 'click', 'open_modal', 'fill', 'fill_dryrun', 'select', 'assert', 'screenshot', 'log_only', ]); export const NavigationStrategySchema = z.enum(['menu_click', 'goto', 'click_row_in_parent']); export const ExecutionModeSchema = z.enum(['bfs', 'goto']); const RoleNameSchema = z.string().min(1); /** * One `role_catalog` entry: the role's STABLE identity behind its display name. * `id` (auth_Roles GUID) is the provisioning join key — names are localized by * the API (Accept-Language, fallback fr) and can never re-match the SQL vocabulary; * `code` exists only on generated/extension roles (NULL on most platform roles). */ export const RoleCatalogEntrySchema = z.object({ id: z.string().min(1), code: z.string().min(1).optional(), }); export type RoleCatalogEntry = z.infer; const FormFieldSchema = z.object({ name: z.string().min(1), type: z.string().min(1), // text | select | number | date | ... }); const SubsetSchema = z.object({ id: z.string().min(1), type: SubsetTypeSchema, kind: SubsetKindSchema, /** Action performed when the subset IS visible for the role under test. */ action_kind: ActionKindSchema.default('click'), selector: z.string().min(1), /** Selector for the HIDDEN-assertion when a role is NOT in visible_for. * Falls back to `selector` when absent. */ assert_selector: z.string().optional(), /** Required when action_kind === 'open_modal'. */ modal_indicator: z.string().optional(), /** Destructive subsets are PROVEN-visible but NEVER clicked (action_kind log_only). */ is_destructive: z.boolean().default(false), /** Required when kind === 'form'. */ fields: z.array(FormFieldSchema).optional(), /** For action_kind === 'navigate': where it should land. */ navigates_to: z.string().optional(), expect_url: z.string().optional(), /** Roles for which this subset MUST be visible. Must be a subset of plan.roles. */ visible_for: z.array(RoleNameSchema), /** Explicit expected result for this testable unit (D1 — every unit carries an expect). */ expect: z.string().min(1), }); export type Subset = z.infer; const RouteExpectSchema = z.object({ access_by_role: z.boolean().default(true), title: z.string().optional(), no_console_error: z.boolean().default(true), }); const RouteSchema = z.object({ id: z.string().min(1), component_key: z.string().optional(), route: z.string().min(1), permission: z.string().optional(), navigation_strategy: NavigationStrategySchema.default('menu_click'), /** Required when navigation_strategy === 'click_row_in_parent'. */ parent_route: z.string().optional(), /** click_row_in_parent on an empty list yields INDETERMINATE, never `denied`. */ on_empty_list: z.literal('INDETERMINATE').optional(), expect: RouteExpectSchema.default({ access_by_role: true, no_console_error: true }), /** Ground-truth verdict per role (every role present — invariant 3). */ access: z.record(RoleNameSchema, AccessVerdictSchema), subsets: z.array(SubsetSchema).default([]), }); export type Route = z.infer; const EndpointSchema = z.object({ id: z.string().min(1), method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), route: z.string().min(1), controller: z.string().optional(), permission: z.string().optional(), /** How the permission was obtained — `declared` (the action's own * [RequirePermission], resolved live), `declared-unseeded` (declared but in * no live permission row — every role denied), `inferred` (legacy guess). */ permission_source: z.enum(['declared', 'declared-unseeded', 'inferred']).optional(), /** The action carries NO permission gate and no [AllowAnonymous]: every * authenticated role passes. The plan expects the deployed behaviour but * the row is a DEFECT surface (DEV-API-033), reported loudly — never a * silent certification. */ ungated: z.boolean().optional(), /** Expected HTTP status per role (every role present — invariant 3). */ expected_by_role: z.record(RoleNameSchema, z.number().int()), }); export type Endpoint = z.infer; const SourceSignatureSchema = z.object({ nav_sha: z.string().optional(), rbac_sha: z.string().optional(), registry_sha: z.string().optional(), }); const PerRoleSchema = z.object({ mode: ExecutionModeSchema.optional(), expect: AccessVerdictSchema.optional(), }); const ExecutionSchema = z.object({ modes: z.array(ExecutionModeSchema).default(['bfs', 'goto']), caps: z .object({ tabs: z.number().int().default(8), buttons: z.number().int().default(10), forms: z.number().int().default(3), depth: z.number().int().default(3), actions_per_role: z.number().int().default(500), }) // Full literal (not `{}`): zod v4's `.default()` does NOT re-parse the default, // so an empty object would leave every field undefined at runtime. A complete // literal is correct under BOTH zod v3 (re-parsed) and v4 (returned as-is). .default({ tabs: 8, buttons: 10, forms: 3, depth: 3, actions_per_role: 500 }), /** ANTIDOTE to false positives (D-exec): wait for full readiness before asserting. */ readiness: z .object({ strategy: z.literal('fully_ready').default('fully_ready'), timeout_ms: z.number().int().default(12000), retry_on_not_ready: z.number().int().default(2), }) .default({ strategy: 'fully_ready', timeout_ms: 12000, retry_on_not_ready: 2 }), /** Per-page display timings, measured AFTER fully_ready. Thresholds are WARNING. */ perf: z .object({ capture: z.array(z.enum(['ttfp_ms', 'fully_ready_ms', 'nav_ms'])).default(['ttfp_ms', 'fully_ready_ms', 'nav_ms']), warn_ms: z.number().int().default(DEFAULT_THRESHOLDS.uiWarnMs), slow_ms: z.number().int().default(DEFAULT_THRESHOLDS.uiSlowMs), }) .default({ capture: ['ttfp_ms', 'fully_ready_ms', 'nav_ms'], warn_ms: DEFAULT_THRESHOLDS.uiWarnMs, slow_ms: DEFAULT_THRESHOLDS.uiSlowMs, }), screenshots: z.object({ /** Project dir the screenshots are written to (relative to projectPath). */ out_dir: z.string().min(1), }), per_role: z.record(RoleNameSchema, PerRoleSchema).default({}), }); const DriftRuleSchema = z.object({ severity: z.enum(['WARN', 'FAIL', 'BLOCKER']), fail_run: z.boolean().default(false), suggest: z.string().optional(), }); const DriftPolicySchema = z .object({ extra_subset_in_dom: DriftRuleSchema.default({ severity: 'WARN', fail_run: false }), missing_subset_in_dom: DriftRuleSchema.default({ severity: 'FAIL', fail_run: false }), overexposed_subset: DriftRuleSchema.default({ severity: 'BLOCKER', fail_run: false }), signature_mismatch: DriftRuleSchema.default({ severity: 'BLOCKER', fail_run: true }), }) .default({ extra_subset_in_dom: { severity: 'WARN', fail_run: false }, missing_subset_in_dom: { severity: 'FAIL', fail_run: false }, overexposed_subset: { severity: 'BLOCKER', fail_run: false }, signature_mismatch: { severity: 'BLOCKER', fail_run: true }, }); export const PlanTestSchema = z.object({ schema_version: z.string().default('1.0.0'), meta: z.object({ application: z.string().min(1), path: z.string().min(1), generated_at: z.string().optional(), source_signature: SourceSignatureSchema.default({}), }), roles: z.array(RoleNameSchema).min(1), /** * Role identities keyed by the SAME names `roles` / `access` / `expected_by_role` * use. Optional so pre-1.1.0 plans still parse (uat-api/ui/report read old * artifacts) — but uat-provision REFUSES a plan without it, and uat-run treats * such a plan as never fresh (auto-regenerated under refreshPlan auto/always). */ role_catalog: z.record(RoleNameSchema, RoleCatalogEntrySchema).optional(), execution: ExecutionSchema, routes: z.array(RouteSchema).default([]), endpoints: z.array(EndpointSchema).default([]), drift_policy: DriftPolicySchema, }); export type PlanTest = z.infer; export interface InvariantViolation { /** Invariant number (1..8) — see validateInvariants(). */ invariant: number; /** Locator, e.g. "USERS_LIST.btn_create_user". */ where: string; message: string; } const UPPER_SNAKE = /^[A-Z0-9_]+$/; /** * The structural invariants a `.plantest.yml` MUST hold before any run. * * Enforced here (1..8, structural): * 1. route ids are UPPER_SNAKE * 2. route.id, subset.id and endpoint.id are unique within their scope * 3. `access` / `expected_by_role` covers EVERY role * 4. subset.visible_for is a subset of plan.roles * 5. destructive subset => action_kind 'log_only' * 6. kind 'form' => non-empty fields[] * 7. action_kind 'open_modal' => modal_indicator set * 8. action_kind 'navigate' => expect_url OR navigates_to * * Invariants 9 (screenshot naming) and 10 (signature freshness) are RUNTIME * checks the run agent performs against live SHAs/output — not structural. */ export function validateInvariants(plan: PlanTest): InvariantViolation[] { const v: InvariantViolation[] = []; const roles = new Set(plan.roles); const routeIds = new Set(); // When a role_catalog is carried it must be a bijection with plan.roles (minus // the synthetic `anonymous`): a missing entry breaks the provisioning join, a // surplus entry is a stale name nothing references. Absent catalog → no violation // (pre-1.1.0 plans stay parseable; uat-provision enforces presence itself). if (plan.role_catalog) { for (const role of plan.roles) { if (role !== 'anonymous' && !(role in plan.role_catalog)) { v.push({ invariant: 3, where: role, message: `role_catalog missing role "${role}"` }); } } for (const key of Object.keys(plan.role_catalog)) { if (!roles.has(key)) { v.push({ invariant: 3, where: key, message: `role_catalog entry "${key}" is not in plan.roles` }); } } } for (const r of plan.routes) { if (!UPPER_SNAKE.test(r.id)) { v.push({ invariant: 1, where: r.id, message: `route id must be UPPER_SNAKE: "${r.id}"` }); } if (routeIds.has(r.id)) { v.push({ invariant: 2, where: r.id, message: `duplicate route id "${r.id}"` }); } routeIds.add(r.id); for (const role of plan.roles) { if (!(role in r.access)) { v.push({ invariant: 3, where: r.id, message: `access missing role "${role}"` }); } } if (r.navigation_strategy === 'click_row_in_parent' && !r.parent_route) { v.push({ invariant: 8, where: r.id, message: `click_row_in_parent requires parent_route` }); } const seen = new Set(); for (const s of r.subsets) { if (seen.has(s.id)) { v.push({ invariant: 2, where: `${r.id}.${s.id}`, message: `duplicate subset id "${s.id}"` }); } seen.add(s.id); for (const role of s.visible_for) { if (!roles.has(role)) { v.push({ invariant: 4, where: `${r.id}.${s.id}`, message: `visible_for role "${role}" not in plan.roles` }); } } if (s.is_destructive && s.action_kind !== 'log_only') { v.push({ invariant: 5, where: `${r.id}.${s.id}`, message: `destructive subset must be action_kind 'log_only' (got "${s.action_kind}")`, }); } if (s.kind === 'form' && (!s.fields || s.fields.length === 0)) { v.push({ invariant: 6, where: `${r.id}.${s.id}`, message: `form subset requires non-empty fields[]` }); } if (s.action_kind === 'open_modal' && !s.modal_indicator) { v.push({ invariant: 7, where: `${r.id}.${s.id}`, message: `open_modal requires modal_indicator` }); } if (s.action_kind === 'navigate' && !s.expect_url && !s.navigates_to) { v.push({ invariant: 8, where: `${r.id}.${s.id}`, message: `navigate requires expect_url or navigates_to` }); } } } const endpointIds = new Set(); for (const e of plan.endpoints) { if (endpointIds.has(e.id)) { v.push({ invariant: 2, where: e.id, message: `duplicate endpoint id "${e.id}"` }); } endpointIds.add(e.id); for (const role of plan.roles) { if (!(role in e.expected_by_role)) { v.push({ invariant: 3, where: e.id, message: `expected_by_role missing role "${role}"` }); } } } return v; } export type ParsePlanResult = | { ok: true; plan: PlanTest; violations: InvariantViolation[] } | { ok: false; errors: string[] }; /** * Parse + structurally validate a candidate plan object. Schema errors return * `ok:false` with mapped messages; a schema-valid plan returns the typed plan plus * any invariant violations (empty array == fully valid). */ export function parsePlan(raw: unknown): ParsePlanResult { const parsed = PlanTestSchema.safeParse(raw); if (!parsed.success) { return { ok: false, errors: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`) }; } return { ok: true, plan: parsed.data, violations: validateInvariants(parsed.data) }; }