/** * plantest-schema — pure-layer tests. * * Drives the real parsePlan()/validateInvariants() against a known-good plan and * targeted mutations, so the `.plantest.yml` contract + the 8 structural * invariants are checked against the actual parser a developer would run. */ import { describe, it, expect } from 'vitest'; import { parsePlan, validateInvariants, PlanTestSchema, type PlanTest } from '../plantest-schema.js'; /** Minimal schema-valid plan (input shape — defaults fill the rest). */ function validInput(): Record { return { meta: { application: 'administration', path: 'administration/users' }, roles: ['super-admin', 'admin', 'manager', 'anonymous'], execution: { screenshots: { out_dir: '.application-test/runs/x/screenshots' } }, routes: [ { id: 'USERS_LIST', component_key: 'administration.users', route: '/administration/users', permission: 'administration.users.read', access: { 'super-admin': 'allowed', admin: 'allowed', manager: 'allowed', anonymous: 'redirect_login' }, subsets: [ { id: 'btn_create', type: 'create', kind: 'button', action_kind: 'open_modal', selector: 'button:has-text("Create")', modal_indicator: '[role="dialog"]', visible_for: ['super-admin', 'admin'], expect: 'modal_opens_then_closes', }, ], }, ], endpoints: [ { id: 'post:administration/users', method: 'POST', route: '/api/administration/users', permission: 'administration.users.create', expected_by_role: { 'super-admin': 200, admin: 200, manager: 403, anonymous: 401 }, }, ], }; } function parsedValid(): PlanTest { const r = parsePlan(validInput()); if (!r.ok) throw new Error(`fixture should parse: ${r.errors.join(', ')}`); return r.plan; } describe('parsePlan — happy path + defaults', () => { it('parses a valid plan with zero invariant violations', () => { const r = parsePlan(validInput()); expect(r.ok).toBe(true); if (r.ok) expect(r.violations).toEqual([]); }); it('applies schema defaults (schema_version, caps, readiness, perf, subset action defaults)', () => { const plan = parsedValid(); expect(plan.schema_version).toBe('1.0.0'); expect(plan.execution.caps.tabs).toBe(8); expect(plan.execution.caps.actions_per_role).toBe(500); expect(plan.execution.readiness.strategy).toBe('fully_ready'); expect(plan.execution.perf.warn_ms).toBe(3000); expect(plan.execution.perf.slow_ms).toBe(8000); expect(plan.execution.modes).toEqual(['bfs', 'goto']); // drift defaults re-parsed (fail_run filled in). expect(plan.drift_policy.overexposed_subset).toEqual({ severity: 'BLOCKER', fail_run: false }); expect(plan.drift_policy.signature_mismatch.fail_run).toBe(true); }); it('rejects unknown access verdicts — `partial` is NEVER stored in the plan', () => { const bad = validInput(); (bad.routes as Array>)[0].access = { 'super-admin': 'partial' }; const r = parsePlan(bad); expect(r.ok).toBe(false); }); it('requires execution.screenshots.out_dir', () => { const bad = validInput(); (bad.execution as Record).screenshots = {}; const r = parsePlan(bad); expect(r.ok).toBe(false); }); }); describe('role_catalog — optional identities block (schema 1.1.0)', () => { const CATALOG = { 'super-admin': { id: '00000000-0000-4000-8000-00000000000a', code: 'super-admin' }, admin: { id: '00000000-0000-4000-8000-00000000000b' }, manager: { id: '00000000-0000-4000-8000-00000000000c' }, }; it('parses a complete catalog with zero violations (anonymous needs NO entry)', () => { const input = validInput(); input.role_catalog = CATALOG; const r = parsePlan(input); expect(r.ok).toBe(true); if (r.ok) { expect(r.violations).toEqual([]); expect(r.plan.role_catalog?.admin).toEqual({ id: '00000000-0000-4000-8000-00000000000b' }); } }); it('a pre-1.1.0 plan WITHOUT the block still parses clean (backward compat)', () => { const r = parsePlan(validInput()); expect(r.ok).toBe(true); if (r.ok) { expect(r.plan.role_catalog).toBeUndefined(); expect(r.violations).toEqual([]); } }); it('flags a plan role missing from the catalog (the provisioning join would break)', () => { const input = validInput(); input.role_catalog = { ...CATALOG }; delete (input.role_catalog as Record).admin; const r = parsePlan(input); expect(r.ok).toBe(true); if (r.ok) { expect(r.violations).toEqual([ { invariant: 3, where: 'admin', message: 'role_catalog missing role "admin"' }, ]); } }); it('flags a stale catalog entry that no plan role references', () => { const input = validInput(); input.role_catalog = { ...CATALOG, ghost: { id: '00000000-0000-4000-8000-0000000000ff' } }; const r = parsePlan(input); expect(r.ok).toBe(true); if (r.ok) { expect(r.violations).toEqual([ { invariant: 3, where: 'ghost', message: 'role_catalog entry "ghost" is not in plan.roles' }, ]); } }); it('rejects an entry without an id at the schema level', () => { const input = validInput(); input.role_catalog = { admin: { code: 'admin' } }; const r = parsePlan(input); expect(r.ok).toBe(false); }); }); describe('validateInvariants — each structural invariant fires', () => { function fires(invariant: number, mutate: (p: PlanTest) => void): void { const plan = parsedValid(); mutate(plan); const nums = validateInvariants(plan).map((v) => v.invariant); expect(nums).toContain(invariant); } it('(1) route id must be UPPER_SNAKE', () => { fires(1, (p) => { p.routes[0].id = 'users_list'; }); }); it('(2) subset ids unique within a route', () => { fires(2, (p) => { const s = p.routes[0].subsets[0]; p.routes[0].subsets.push({ ...s }); }); }); it('(2) endpoint ids unique', () => { fires(2, (p) => { p.endpoints.push({ ...p.endpoints[0] }); }); }); it('(2) route ids unique', () => { fires(2, (p) => { p.routes.push({ ...p.routes[0] }); }); }); it('(3) access must cover every role', () => { fires(3, (p) => { delete (p.routes[0].access as Record)['manager']; }); }); it('(3) expected_by_role must cover every role', () => { fires(3, (p) => { delete (p.endpoints[0].expected_by_role as Record)['manager']; }); }); it('(4) visible_for must be a subset of plan.roles', () => { fires(4, (p) => { p.routes[0].subsets[0].visible_for.push('ghost-role'); }); }); it('(5) destructive subset must be action_kind log_only', () => { fires(5, (p) => { p.routes[0].subsets[0].is_destructive = true; p.routes[0].subsets[0].action_kind = 'click'; }); }); it('(6) form subset requires non-empty fields[]', () => { fires(6, (p) => { p.routes[0].subsets[0].kind = 'form'; p.routes[0].subsets[0].fields = undefined; }); }); it('(7) open_modal requires modal_indicator', () => { fires(7, (p) => { p.routes[0].subsets[0].action_kind = 'open_modal'; p.routes[0].subsets[0].modal_indicator = undefined; }); }); it('(8) navigate requires expect_url or navigates_to', () => { fires(8, (p) => { p.routes[0].subsets[0].action_kind = 'navigate'; p.routes[0].subsets[0].modal_indicator = undefined; p.routes[0].subsets[0].expect_url = undefined; p.routes[0].subsets[0].navigates_to = undefined; }); }); it('(8) click_row_in_parent requires parent_route', () => { fires(8, (p) => { p.routes[0].navigation_strategy = 'click_row_in_parent'; p.routes[0].parent_route = undefined; }); }); }); describe('PlanTestSchema — direct', () => { it('round-trips a valid plan via .parse', () => { expect(() => PlanTestSchema.parse(validInput())).not.toThrow(); }); });