/** * uat-plan/generate — assembly + serialization + the BYTE-STABLE golden plan. * * The golden test pins `generatePlan(personas.json)` to `expected.plantest.yml`. * To refresh after an intentional schema/format change: run vitest with * UPDATE_GOLDEN=1 (rewrites the fixture), eyeball the diff, then re-run clean. */ import { describe, it, expect } from 'vitest'; import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { load } from 'js-yaml'; import { assemblePlan, serializePlan, computeSignature, emitArtifacts, generatePlan, } from '../generate.js'; import type { Route } from '../../lib/plantest-schema.js'; import type { DiscoveryResult } from '../types.js'; const here = dirname(fileURLToPath(import.meta.url)); const fixtures = join(here, 'fixtures'); const SIG = { nav_sha: 'n', rbac_sha: 'r', registry_sha: 'g' }; function loadDiscovery(): DiscoveryResult { return JSON.parse(readFileSync(join(fixtures, 'personas.json'), 'utf8')) as DiscoveryResult; } describe('assemblePlan', () => { it('fills schema defaults and validates clean', () => { const { plan, violations } = assemblePlan({ application: 'a', path: 'a', roles: ['admin', 'anonymous'], routes: [], endpoints: [], signature: SIG, }); expect(violations).toEqual([]); expect(plan.execution.caps.tabs).toBe(8); expect(plan.execution.readiness.strategy).toBe('fully_ready'); expect(plan.execution.perf.slow_ms).toBe(8000); expect(plan.drift_policy.signature_mismatch.fail_run).toBe(true); expect(plan.meta.generated_at).toBeUndefined(); // anonymous present => per_role redirect rule added expect(plan.execution.per_role.anonymous).toEqual({ mode: 'goto', expect: 'redirect_login' }); }); it('a partial caps override keeps the other caps at their defaults', () => { const { plan } = assemblePlan({ application: 'a', path: 'a', roles: ['admin'], routes: [], endpoints: [], signature: SIG, caps: { tabs: 2 }, }); expect(plan.execution.caps.tabs).toBe(2); expect(plan.execution.caps.buttons).toBe(10); // no anonymous => no per_role entry expect(plan.execution.per_role).toEqual({}); }); it('includes generated_at only when provided', () => { const { plan } = assemblePlan({ application: 'a', path: 'a', roles: ['admin'], routes: [], endpoints: [], signature: SIG, generatedAt: '2026-06-04T00:00:00.000Z', }); expect(plan.meta.generated_at).toBe('2026-06-04T00:00:00.000Z'); }); it('returns an invariant violation (not a throw) for an access map missing a role', () => { const route: Route = { id: 'R', route: '/r', navigation_strategy: 'menu_click', expect: { access_by_role: true, no_console_error: true }, access: { admin: 'allowed' }, subsets: [], }; const { violations } = assemblePlan({ application: 'a', path: 'a', roles: ['admin', 'viewer'], routes: [route], endpoints: [], signature: SIG, }); expect(violations.some((v) => v.invariant === 3)).toBe(true); }); it('carries role_catalog (anonymous filtered OUT) and stamps schema 1.1.0', () => { const { plan, violations } = assemblePlan({ application: 'a', path: 'a', roles: ['admin', 'anonymous'], roleCatalog: { admin: { id: 'g-admin', code: 'admin' }, anonymous: { id: 'never-me' }, }, routes: [], endpoints: [], signature: SIG, }); expect(violations).toEqual([]); expect(plan.schema_version).toBe('1.1.0'); expect(plan.role_catalog).toEqual({ admin: { id: 'g-admin', code: 'admin' } }); }); it('omits role_catalog entirely when the caller has none (still schema-valid)', () => { const { plan, violations } = assemblePlan({ application: 'a', path: 'a', roles: ['admin'], routes: [], endpoints: [], signature: SIG, }); expect(violations).toEqual([]); expect(plan.role_catalog).toBeUndefined(); }); }); describe('serializePlan', () => { it('is idempotent and round-trips through YAML', () => { const { plan } = assemblePlan({ application: 'a', path: 'a', roles: ['admin', 'anonymous'], routes: [], endpoints: [], signature: SIG, }); const y1 = serializePlan(plan); expect(serializePlan(plan)).toBe(y1); expect(load(y1)).toEqual(plan); }); }); describe('computeSignature', () => { it('is stable and changes when any source part changes', () => { const a = computeSignature({ nav: 'x', rbac: 'y', registry: 'z' }); expect(computeSignature({ nav: 'x', rbac: 'y', registry: 'z' })).toEqual(a); expect(a.nav_sha).toHaveLength(64); expect(computeSignature({ nav: 'x2', rbac: 'y', registry: 'z' }).nav_sha).not.toBe(a.nav_sha); }); }); describe('emitArtifacts', () => { it('emits the plan + signature sidecar with correct names and a trimmed outDir', () => { const files = emitArtifacts('administration', '.application-test/uat/administration/', 'yaml-here', SIG); expect(files.map((f) => f.path)).toEqual([ '.application-test/uat/administration/administration.plantest.yml', '.application-test/uat/administration/administration.plantest.signature', ]); expect(files[0].content).toBe('yaml-here'); expect(JSON.parse(files[1].content)).toEqual(SIG); }); }); describe('generatePlan — byte-stable golden + scope', () => { it('matches the golden plan (personas.json → expected.plantest.yml)', () => { const r = generatePlan(loadDiscovery(), { name: 'administration', outDir: '.application-test/uat/administration', modes: ['bfs', 'goto'], includeApi: true, generatedAt: '2026-06-04T00:00:00.000Z', }); expect(r.violations).toEqual([]); const goldenPath = join(fixtures, 'expected.plantest.yml'); if (process.env.UPDATE_GOLDEN) writeFileSync(goldenPath, r.yaml, 'utf8'); // Normalise CRLF → LF so a git autocrlf checkout of the fixture can't break the // comparison; the generator only ever emits LF. const golden = readFileSync(goldenPath, 'utf8').replace(/\r\n/g, '\n'); expect(r.yaml).toBe(golden); }); it('expands the path sub-tree into the expected route ids', () => { const r = generatePlan(loadDiscovery(), { name: 'x', outDir: 'd', includeApi: true }); expect(r.plan.routes.map((x) => x.id)).toEqual([ 'ADMINISTRATION_USERS', 'ADMINISTRATION_USERS_DETAIL', 'ADMINISTRATION_USERS_CREATE', 'ADMINISTRATION_ROLES', ]); }); it('canonicalises the scrambled fixture roles', () => { const r = generatePlan(loadDiscovery(), { name: 'x', outDir: 'd', includeApi: true }); expect(r.plan.roles).toEqual(['super-admin', 'admin', 'manager', 'contributor', 'viewer', 'anonymous']); }); it('includeApi:false omits the endpoints axis', () => { const r = generatePlan(loadDiscovery(), { name: 'x', outDir: 'd', includeApi: false }); expect(r.plan.endpoints).toEqual([]); }); });