/** * cli:build-manifest — generate.ts * * For each entity × role matrix, emit one test entry per scenario. * Output: tests/ui-test/manifest.json (single file). */ import type { BuildManifestInput, ManifestEntity, ManifestTest, Manifest, GeneratedFile, } from './types.js'; export function generate(spec: BuildManifestInput): GeneratedFile[] { const tests: ManifestTest[] = []; for (const entity of spec.entities) { tests.push(...buildEntityTests(spec, entity)); } const manifest: Manifest = { generatedAt: new Date().toISOString(), module: spec.module, appCode: spec.appCode, tests, }; return [ { path: 'tests/ui-test/manifest.json', content: JSON.stringify(manifest, null, 2) + '\n', }, ]; } function buildEntityTests(spec: BuildManifestInput, entity: ManifestEntity): ManifestTest[] { const tests: ManifestTest[] = []; const plural = entity.plural ?? entity.section; const basePath = `/${spec.module}/${plural}`; const idPlaceholder = '__detail_id__'; // resolved at runtime by the runner from list.first // ─── List + smoke (one test per role with read) ──────── for (const role of entity.rolesWithRead) { tests.push({ id: `${spec.module}.${entity.section}.list.${role}`, scenario: 'list', page: basePath, role, expectations: { httpStatus: [200], noConsoleError: true, rendersTestId: `${entity.section}-list-table`, }, }); tests.push({ id: `${spec.module}.${entity.section}.detail.${role}`, scenario: 'detail', page: `${basePath}/${idPlaceholder}`, role, expectations: { httpStatus: [200], noConsoleError: true, rendersTestId: `${entity.section}-detail`, }, }); } // ─── Form-submit per role with create ────────────────── for (const role of entity.rolesWithCreate) { tests.push({ id: `${spec.module}.${entity.section}.create.${role}`, scenario: 'form-submit', page: `${basePath}/create`, role, fixture: entity.fixture, expectations: { httpStatus: [201, 200], redirectsTo: `${basePath}/:id`, }, }); } // ─── Edit per role with update ────────────────────────── for (const role of entity.rolesWithUpdate) { tests.push({ id: `${spec.module}.${entity.section}.edit.${role}`, scenario: 'edit', page: `${basePath}/${idPlaceholder}/edit`, role, fixture: entity.fixture, expectations: { httpStatus: [200, 204], }, }); } // ─── Delete per role with delete ──────────────────────── for (const role of entity.rolesWithDelete) { tests.push({ id: `${spec.module}.${entity.section}.delete.${role}`, scenario: 'delete', page: `${basePath}/${idPlaceholder}`, role, expectations: { httpStatus: [204, 200], }, }); } // ─── Permission-negative per role without access ─────── for (const role of entity.rolesWithoutAccess) { tests.push({ id: `${spec.module}.${entity.section}.list.${role}-denied`, scenario: 'permission-negative', page: basePath, role, expectations: { // Either the API rejects with 403 OR the frontend PermissionGuard // renders a denied placeholder. The template handles both. httpStatus: [403], rendersTestId: `${entity.section}-permission-denied`, }, }); } return tests; }