diff --git a/README.md b/README.md index b07ce02..63abb80 100644 --- a/README.md +++ b/README.md @@ -360,7 +360,7 @@ emits a self-contained, runnable Playwright project under runtime helpers (`support/`), fixtures, and a README. Template-derived suites (`#268` Phase 2 / `#270`) land under -`generated//playwright/edges/.lifecycle.spec.ts` — +`generated//playwright/templates/EdgeLifecycle/.lifecycle.spec.ts` — one per ABox-declared edge. Each suite runs the full lifecycle (establish → present-observe → revoke → absent-observe) and is sourced from `generated//scenarios/templates/EdgeLifecycle/.json`, diff --git a/configs/camunda-oca/regression-invariants.test.ts b/configs/camunda-oca/regression-invariants.test.ts index 8e76f75..7ef1a45 100644 --- a/configs/camunda-oca/regression-invariants.test.ts +++ b/configs/camunda-oca/regression-invariants.test.ts @@ -2327,7 +2327,8 @@ describeForThisConfig('bundled-spec invariants: fixture selection by required st // is unchanged, only the spec file moved. const spec = join( GENERATED_TESTS_DIR, - 'state-transitions', + 'templates', + 'StateTransitionVisibleAfterAction', 'Incident.resolveIncident.lifecycle.spec.ts', ); if (!existsSync(spec)) { @@ -7880,7 +7881,7 @@ describeForThisConfig('bundled-spec invariants: ontology visualisation emitter', // every (template × edge) pair declared in the ABoxes into a TemplateScenario // JSON file under generated//scenarios/templates//.json, // and the Playwright emitter materialises one -// generated//playwright/edges/.lifecycle.spec.ts per edge. +// generated//playwright/templates/EdgeLifecycle/.lifecycle.spec.ts per edge. // // These invariants pin the structural contract of that output — coverage // (every edge has a JSON + a .spec.ts), shape (5 steps in the established → @@ -7896,7 +7897,7 @@ describeForThisConfig( () => { const TEMPLATES_ROOT = join(SCENARIOS_DIR, 'templates'); const EDGE_LIFECYCLE_DIR = join(TEMPLATES_ROOT, 'EdgeLifecycle'); - const EDGES_SUITE_DIR = join(GENERATED_TESTS_DIR, 'edges'); + const EDGES_SUITE_DIR = join(GENERATED_TESTS_DIR, 'templates', 'EdgeLifecycle'); interface TemplateScenarioFile { templateName: string; @@ -8107,7 +8108,7 @@ describeForThisConfig( } }); - it('every edge has a generated Playwright lifecycle suite under generated//playwright/edges/', async () => { + it('every edge has a generated Playwright lifecycle suite under generated//playwright/templates/EdgeLifecycle/', async () => { const { loadEdgesAbox } = await import('../../path-analyser/src/ontology/loader.js'); const edges = loadEdgesAbox(REPO_ROOT); if (!edges) throw new Error('edges ABox missing'); @@ -8705,7 +8706,8 @@ describeForThisConfig( it('Incident.resolveIncident slice: emitted Playwright spec asserts state === RESOLVED via getIncident read-back', () => { const specPath = join( GENERATED_TESTS_DIR, - 'state-transitions', + 'templates', + 'StateTransitionVisibleAfterAction', 'Incident.resolveIncident.lifecycle.spec.ts', ); if (!existsSync(specPath)) { @@ -8726,7 +8728,8 @@ describeForThisConfig( it('UserTask.completeUserTask slice (#305 Phase 5d-2): emitted Playwright spec asserts state === COMPLETED via getUserTask read-back', () => { const specPath = join( GENERATED_TESTS_DIR, - 'state-transitions', + 'templates', + 'StateTransitionVisibleAfterAction', 'UserTask.completeUserTask.lifecycle.spec.ts', ); if (!existsSync(specPath)) { @@ -8747,7 +8750,8 @@ describeForThisConfig( it('ProcessInstance.cancelProcessInstance slice (#305 Phase 5d-4): emitted Playwright spec asserts state === CANCELED via getProcessInstance read-back, using cancellable-blocked.bpmn fixture', () => { const specPath = join( GENERATED_TESTS_DIR, - 'state-transitions', + 'templates', + 'StateTransitionVisibleAfterAction', 'ProcessInstance.cancelProcessInstance.lifecycle.spec.ts', ); if (!existsSync(specPath)) { diff --git a/materializer/src/coverage.ts b/materializer/src/coverage.ts index 8c42d6d..ca36565 100644 --- a/materializer/src/coverage.ts +++ b/materializer/src/coverage.ts @@ -1,4 +1,4 @@ -// Scenario-template coverage extractor (#331). +// Scenario-template coverage extractor (#331, #335). // // Walks the per-template scenario JSON files emitted by the planner // (`generated//scenarios/templates//*.json`) and @@ -13,9 +13,15 @@ // ScenarioTemplate added to `configs//ontology/scenario-templates.json` // (and emitted by the planner under a new subdirectory of // `scenarios/templates/`) extends suppression automatically as soon -// as it is included in the `templates` option passed to `buildCoverage` -// (in production, that's `TEMPLATE_REGISTRY` — see -// `materializer/src/templateRegistry.ts`). +// as its name appears in `templateNames`. In production the caller +// passes the ABox's template names directly — there is no separate +// materializer-side registry to keep in sync (#335). +// +// The on-disk output dir for each template is derived purely from the +// template name: `playwright/templates//`. This mirrors +// the planner's `scenarios/templates//` layout so the +// scenario JSON → emitted spec relationship is visible from the +// directory structure alone. // // `PrereqChain` steps are intentionally *excluded* from coverage — // they are scaffolding, not units-under-test. The Invoke/Observe vs @@ -24,7 +30,16 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { TemplateBinding } from './templateRegistry.js'; + +/** + * Subdirectory under the Playwright suite root where the materializer + * writes per-template suites. Derived purely from the template name — + * no taxonomy translation. Mirrors the planner's + * `scenarios/templates//` layout (#335). + */ +export function templateOutputDir(templateName: string): string { + return path.join('templates', templateName); +} export interface CoverageEntry { operationId: string; @@ -47,16 +62,16 @@ export interface BuildCoverageOptions { * to treat every template as suppressing (the default). */ templatesAboxPath: string | undefined; /** - * The materializer's scenario-template registry — the single source - * of truth for which templates emit a spec and where. For each - * scenario file under - * `//.json` the - * recorded `emittedSpec` becomes - * `/.lifecycle.spec.ts`. Templates - * not present in this registry are skipped (no spec is emitted on + * The set of scenario-template names the materializer recognises + * (in production, the names declared by the active config's + * scenario-templates ABox). For each scenario file under + * `//.json` + * the recorded `emittedSpec` becomes + * `templates//.lifecycle.spec.ts`. + * Templates not in this list are skipped (no spec is emitted on * disk, so coverage cannot legitimately claim the op). */ - templates: readonly TemplateBinding[]; + templateNames: readonly string[]; } interface ScenarioStep { @@ -95,10 +110,11 @@ export async function buildCoverage(opts: BuildCoverageOptions): Promise [t.name, t.outputDir])); + // Build a name → outputDir lookup. Templates not in `templateNames` + // are skipped (no spec is emitted on disk, so coverage cannot + // legitimately claim the op). The output dir is derived purely from + // the name (`templates//`) — no taxonomy table. + const outputDirByName = new Map(opts.templateNames.map((n) => [n, templateOutputDir(n)])); for (const templateName of templateDirs) { const templateDir = path.join(opts.templateScenariosRootDir, templateName); diff --git a/materializer/src/index.ts b/materializer/src/index.ts index 3be8762..930d6a4 100644 --- a/materializer/src/index.ts +++ b/materializer/src/index.ts @@ -25,11 +25,12 @@ import { assertSafeGlobalContextSeeds, deriveArtifactKindsViews, deriveGlobalContextSeedsViews, + loadScenarioTemplatesAbox, } from 'path-analyser/ontology/loader'; import { getEmitterRoleForOperation } from 'path-analyser/ontology/operationRoles'; import type { EndpointScenarioCollection, GlobalContextSeed } from 'path-analyser/types'; import { parseCliArgs } from './cli-args.js'; -import { buildCoverage, type CoverageResult } from './coverage.js'; +import { buildCoverage, type CoverageResult, templateOutputDir } from './coverage.js'; import { writeEmitted, writeScaffolded } from './orchestrator.js'; import { PlaywrightEmitter } from './playwright/emitter.js'; import { @@ -40,7 +41,6 @@ import { } from './playwright/materialize-support.js'; import { loadRoleBundlesForActiveConfig } from './playwright/roleRenderer.js'; import { emitTemplateSuites } from './playwright/templateEmitter.js'; -import { TEMPLATE_REGISTRY } from './templateRegistry.js'; // Built-in emitter registrations. RoleHookProviders are no longer // registered statically here: every provider lives next to its role @@ -469,20 +469,29 @@ async function run() { await writeScaffolded(emitter, buildCtx('', 'feature')); if (positional === '--all') { + // #335: scenario-template names are derived from the active + // config's scenario-templates ABox — the single source of truth. + // The materializer is a generic transformer: for each ABox row it + // reads `scenarios/templates//` and emits to + // `playwright/templates//`. The on-disk layout mirrors the + // planner's so the scenario → emitted-spec relationship is visible + // from the directory structure alone. Returns `[]` when no ABox + // ships (template suites are then a no-op). + const templatesAbox = loadScenarioTemplatesAbox(repoRoot); + const templateNames = (templatesAbox?.templates ?? []).map((t) => t.name); + // #331: scenario-template coverage. Build the suppression set // from on-disk template scenario JSONs before the feature loop so // operations covered by a well-formed scenario-template spec do // not also emit a structurally weaker per-endpoint feature spec. // Suppression only applies to emitters that ship the - // corresponding template suites; for now that is Playwright. The - // canonical list of templates lives in `TEMPLATE_REGISTRY` - // (#333) — see `materializer/src/templateRegistry.ts` and #331. + // corresponding template suites; for now that is Playwright. let coverage: CoverageResult = { suppressedOpIds: new Set(), entries: [] }; if (emitter.id === PlaywrightEmitter.id) { coverage = await buildCoverage({ templateScenariosRootDir: getTemplateScenariosRootDir(repoRoot), templatesAboxPath: path.join(configDir, 'ontology', 'scenario-templates.json'), - templates: TEMPLATE_REGISTRY, + templateNames, }); } let count = 0; @@ -535,28 +544,28 @@ async function run() { // Template-derived suites (Lift 22 / #270; extended in #280 with // EntityLifecycle, #305 with UpdatedFieldVisibleOnReadBack + // StateTransitionVisibleAfterAction). One Playwright suite per - // subject under `//`. + // subject under `/templates//`. // Only the Playwright emitter wires this — other emitters opt in // by implementing their own template-aware renderer. The scenarios // are produced by the planner (`scenarioTemplateInstantiator.ts`); // if a directory is missing (older planner runs, configs that // don't ship the corresponding template), `emitTemplateSuites` - // no-ops. Adding a new template is one entry in `TEMPLATE_REGISTRY` - // (#333). + // no-ops. Adding a new template requires only an ABox row in + // `configs//ontology/scenario-templates.json` (#335). let lifecycleCount = 0; if (emitter.id === PlaywrightEmitter.id) { const seedsArg = globalContextSeeds.map((s) => ({ binding: s.binding, seedRule: s.seedRule, })); - for (const template of TEMPLATE_REGISTRY) { - const templateOutDir = path.join(outDir, template.outputDir); + for (const templateName of templateNames) { + const templateOutDir = path.join(outDir, templateOutputDir(templateName)); // Wipe the per-template subdir for the same reason the parent // `outDir` is wiped above: stale specs from a previous spec // version must not survive into the current run. await fs.rm(templateOutDir, { recursive: true, force: true }); const written = await emitTemplateSuites({ - scenariosDir: getTemplateScenariosDir(repoRoot, template.name), + scenariosDir: getTemplateScenariosDir(repoRoot, templateName), outDir: templateOutDir, globalContextSeeds: seedsArg, }); diff --git a/materializer/src/playwright/templateEmitter.ts b/materializer/src/playwright/templateEmitter.ts index 4b1eff6..d86dd42 100644 --- a/materializer/src/playwright/templateEmitter.ts +++ b/materializer/src/playwright/templateEmitter.ts @@ -30,16 +30,16 @@ export interface TemplateGlobalContextSeed { export interface EmitTemplateSuitesOptions { /** - * Absolute path to the EdgeLifecycle scenarios directory, i.e. - * `generated//scenarios/templates/EdgeLifecycle/`. - * Each `.json` underneath is read and rendered to one - * `.lifecycle.spec.ts`. + * Absolute path to a template's scenarios directory, i.e. + * `generated//scenarios/templates//` + * (e.g. `EdgeLifecycle`, `EntityLifecycle`). Each `.json` underneath + * is read and rendered to one `.lifecycle.spec.ts`. */ scenariosDir: string; /** * Absolute path to the destination directory, i.e. - * `generated//playwright/edges/`. Wiped and recreated by the - * caller (the materializer's `run()`). + * `generated//playwright/templates//`. Wiped + * and recreated by the caller (the materializer's `run()`). */ outDir: string; /** @@ -265,15 +265,15 @@ function renderLifecycleSuite( const lines: string[] = []; lines.push("import { expect, test } from '@playwright/test';"); - lines.push("import { authHeaders, buildBaseUrl } from '../support/env';"); + lines.push("import { authHeaders, buildBaseUrl } from '../../support/env';"); const seedingImports = ['initSpecSalt', 'seedBinding']; if (needsExtractInto) seedingImports.push('extractInto'); - lines.push(`import { ${seedingImports.join(', ')} } from '../support/seeding';`); + lines.push(`import { ${seedingImports.join(', ')} } from '../../support/seeding';`); if (needsAwaitEventually) { - lines.push("import { awaitEventually } from '../support/await-eventually';"); + lines.push("import { awaitEventually } from '../../support/await-eventually';"); } if (needsResolveFixture) { - lines.push("import { resolveFixture } from '../support/fixtures';"); + lines.push("import { resolveFixture } from '../../support/fixtures';"); } lines.push(''); lines.push(`initSpecSalt('${file.subjectName}.lifecycle');`); @@ -650,15 +650,15 @@ function renderReadBackSuite( const lines: string[] = []; lines.push("import { expect, test } from '@playwright/test';"); - lines.push("import { authHeaders, buildBaseUrl } from '../support/env';"); + lines.push("import { authHeaders, buildBaseUrl } from '../../support/env';"); const seedingImports = ['initSpecSalt', 'seedBinding']; if (needsExtractInto) seedingImports.push('extractInto'); - lines.push(`import { ${seedingImports.join(', ')} } from '../support/seeding';`); + lines.push(`import { ${seedingImports.join(', ')} } from '../../support/seeding';`); if (needsAwaitEventually) { - lines.push("import { awaitEventually } from '../support/await-eventually';"); + lines.push("import { awaitEventually } from '../../support/await-eventually';"); } if (needsResolveFixture) { - lines.push("import { resolveFixture } from '../support/fixtures';"); + lines.push("import { resolveFixture } from '../../support/fixtures';"); } lines.push(''); lines.push(`initSpecSalt('${file.subjectName}.lifecycle');`); @@ -837,15 +837,15 @@ function renderStateTransitionSuite( const lines: string[] = []; lines.push("import { expect, test } from '@playwright/test';"); - lines.push("import { authHeaders, buildBaseUrl } from '../support/env';"); + lines.push("import { authHeaders, buildBaseUrl } from '../../support/env';"); const seedingImports = ['initSpecSalt', 'seedBinding']; if (needsExtractInto) seedingImports.push('extractInto'); - lines.push(`import { ${seedingImports.join(', ')} } from '../support/seeding';`); + lines.push(`import { ${seedingImports.join(', ')} } from '../../support/seeding';`); if (needsAwaitEventually) { - lines.push("import { awaitEventually } from '../support/await-eventually';"); + lines.push("import { awaitEventually } from '../../support/await-eventually';"); } if (needsResolveFixture) { - lines.push("import { resolveFixture } from '../support/fixtures';"); + lines.push("import { resolveFixture } from '../../support/fixtures';"); } lines.push(''); lines.push(`initSpecSalt('${file.subjectName}.state-transition');`); diff --git a/materializer/src/templateRegistry.ts b/materializer/src/templateRegistry.ts deleted file mode 100644 index dd6af44..0000000 --- a/materializer/src/templateRegistry.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Scenario-template registry (#333). -// -// Single source of truth for the materializer's knowledge of which -// scenario templates exist and where their emitted Playwright suites -// live. Consumed by: -// -// - `materializer/src/index.ts` — the template emission loop reads -// this registry to wire `emitTemplateSuites` per template, instead -// of open-coding one block per template. -// - `materializer/src/coverage.ts` — `buildCoverage` reads this -// registry to map template-name → emitted output directory when -// computing the per-endpoint feature-spec suppression set (#331). -// -// Adding a new ScenarioTemplate normally requires two coordinated edits: -// -// 1. Add an ABox row in `configs//ontology/scenario-templates.json`. -// 2. Add one entry here. -// -// The TBox in `path-analyser/src/ontology/scenarioTemplateSchema.ts` -// is name-agnostic (template names are just strings), so it only needs -// updating when the ABox row *shape* changes (a new step kind, a new -// `appliesTo` kind, etc.). Likewise, `emitTemplateSuites` only needs -// extending when the new template's rendered shape genuinely differs -// from the lifecycle templates it already handles. -// -// No other site in the orchestrator needs to know about the new -// template. The guard in `tests/codegen/template-registry.test.ts` -// asserts symmetric equality between the active config's ABox and -// this registry so an ABox row without a registry entry (or vice -// versa) fails red. -// -// Step 2 of #333 (deriving the registry directly from the ABox) is -// deferred; that requires teaching the materializer to dispatch -// emitters by name when future templates need shapes beyond the -// universal `emitTemplateSuites` renderer. - -export interface TemplateBinding { - /** Template name as declared in `scenarioTemplateSchema.ts` and as - * the subdirectory name under `generated//scenarios/templates/`. */ - name: string; - /** Output subdirectory relative to the Playwright suite root, - * i.e. `generated//playwright//`. */ - outputDir: string; -} - -export const TEMPLATE_REGISTRY: readonly TemplateBinding[] = [ - { name: 'EdgeLifecycle', outputDir: 'edges' }, - { name: 'EntityLifecycle', outputDir: 'entities' }, - { name: 'UpdatedFieldVisibleOnReadBack', outputDir: 'runtime-entities' }, - { name: 'StateTransitionVisibleAfterAction', outputDir: 'state-transitions' }, -]; diff --git a/path-analyser/src/types.ts b/path-analyser/src/types.ts index a8f1249..5e4169d 100644 --- a/path-analyser/src/types.ts +++ b/path-analyser/src/types.ts @@ -944,7 +944,7 @@ export interface GeneratedModelSpec { // BFS-derived `EndpointScenario`s. They are written to // `generated//scenarios/templates//.json` // and consumed by the Playwright emitter to produce -// `generated//playwright/edges/.lifecycle.spec.ts`. +// `generated//playwright/templates/EdgeLifecycle/.lifecycle.spec.ts`. // The two output trees are independent — no field on `EndpointScenario` // is touched here. // --------------------------------------------------------------------------- diff --git a/tests/codegen/coverage.test.ts b/tests/codegen/coverage.test.ts index 78b2988..3b7f47e 100644 --- a/tests/codegen/coverage.test.ts +++ b/tests/codegen/coverage.test.ts @@ -57,7 +57,7 @@ describe('buildCoverage (#331)', () => { const result = await buildCoverage({ templateScenariosRootDir: root, templatesAboxPath: undefined, - templates: [{ name: 'EntityLifecycle', outputDir: 'entities' }], + templateNames: ['EntityLifecycle'], }); expect([...result.suppressedOpIds].sort()).toEqual(['createUser', 'getUser']); @@ -86,10 +86,7 @@ describe('buildCoverage (#331)', () => { const result = await buildCoverage({ templateScenariosRootDir: root, templatesAboxPath: aboxPath, - templates: [ - { name: 'EntityLifecycle', outputDir: 'entities' }, - { name: 'SmokeTemplate', outputDir: 'smoke' }, - ], + templateNames: ['EntityLifecycle', 'SmokeTemplate'], }); expect(result.suppressedOpIds.has('createUser')).toBe(true); @@ -107,14 +104,14 @@ describe('buildCoverage (#331)', () => { const result = await buildCoverage({ templateScenariosRootDir: root, templatesAboxPath: undefined, - templates: [{ name: 'EntityLifecycle', outputDir: 'entities' }], + templateNames: ['EntityLifecycle'], }); expect([...result.suppressedOpIds].sort()).toEqual(['createUser', 'getUser']); expect(result.suppressedOpIds.has('createTenant')).toBe(false); }); - test('templates not present in the buildCoverage templates option are skipped (no spec emitted, no coverage claimed)', async () => { + test('templates not present in the buildCoverage templateNames option are skipped (no spec emitted, no coverage claimed)', async () => { const root = path.join(tmp, 'scenarios', 'templates'); await writeScenario(root, 'UnwiredTemplate', 'Thing', [ { kind: 'invoke', operationId: 'doThing' }, @@ -123,7 +120,7 @@ describe('buildCoverage (#331)', () => { const result = await buildCoverage({ templateScenariosRootDir: root, templatesAboxPath: undefined, - templates: [], // not wired + templateNames: [], // not wired }); expect(result.suppressedOpIds.size).toBe(0); diff --git a/tests/codegen/template-registry.test.ts b/tests/codegen/template-registry.test.ts deleted file mode 100644 index f52ae28..0000000 --- a/tests/codegen/template-registry.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import path from 'node:path'; -import { describe, expect, test } from 'vitest'; -import { TEMPLATE_REGISTRY } from '../../materializer/src/templateRegistry.ts'; -import { loadScenarioTemplatesAbox } from '../../path-analyser/src/ontology/loader.ts'; - -const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..'); - -/** - * Registry / ABox symmetry guard (#333). - * - * Locks in the invariant that the active config's scenario-templates - * ABox and the materializer's `TEMPLATE_REGISTRY` agree on which - * templates exist. Drift in either direction is a real defect: - * - * - Template in ABox but not in registry → planner emits scenario - * JSONs that the orchestrator never renders to a spec, and - * `buildCoverage` silently skips them. Adding a template would - * appear to do nothing. - * - Template in registry but not in ABox → orchestrator wipes and - * re-creates an output dir for a template whose scenarios the - * planner never produced; the dir stays empty and `buildCoverage` - * sees no scenarios. Effectively dead wiring. - * - * The single registry file (`materializer/src/templateRegistry.ts`) - * is the only place to wire a new template into the orchestrator. - */ -describe('TEMPLATE_REGISTRY ↔ scenario-templates ABox symmetry (#333)', () => { - test('every ABox template has a registry entry, and vice versa', () => { - const abox = loadScenarioTemplatesAbox(REPO_ROOT); - if (!abox) { - throw new Error( - 'scenario-templates ABox missing for the active config — cannot verify registry symmetry', - ); - } - const aboxNames = abox.templates.map((t) => t.name).sort(); - const registryNames = TEMPLATE_REGISTRY.map((t) => t.name).sort(); - expect(registryNames).toEqual(aboxNames); - }); - - test('registry outputDirs are distinct (no two templates clobber the same subdir)', () => { - const outputDirs = TEMPLATE_REGISTRY.map((t) => t.outputDir); - const unique = new Set(outputDirs); - expect(unique.size).toBe(outputDirs.length); - }); -});