Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<config>/playwright/edges/<EdgeName>.lifecycle.spec.ts` —
`generated/<config>/playwright/templates/EdgeLifecycle/<EdgeName>.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/<config>/scenarios/templates/EdgeLifecycle/<EdgeName>.json`,
Expand Down
18 changes: 11 additions & 7 deletions configs/camunda-oca/regression-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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/<config>/scenarios/templates/<TemplateName>/<EdgeName>.json,
// and the Playwright emitter materialises one
// generated/<config>/playwright/edges/<EdgeName>.lifecycle.spec.ts per edge.
// generated/<config>/playwright/templates/EdgeLifecycle/<EdgeName>.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 →
Expand All @@ -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;
Expand Down Expand Up @@ -8107,7 +8108,7 @@ describeForThisConfig(
}
});

it('every edge has a generated Playwright lifecycle suite under generated/<config>/playwright/edges/', async () => {
it('every edge has a generated Playwright lifecycle suite under generated/<config>/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');
Expand Down Expand Up @@ -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)) {
Expand All @@ -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)) {
Expand All @@ -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)) {
Expand Down
50 changes: 33 additions & 17 deletions materializer/src/coverage.ts
Original file line number Diff line number Diff line change
@@ -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/<config>/scenarios/templates/<TemplateName>/*.json`) and
Expand All @@ -13,9 +13,15 @@
// ScenarioTemplate added to `configs/<config>/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/<TemplateName>/`. This mirrors
// the planner's `scenarios/templates/<TemplateName>/` 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
Expand All @@ -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/<TemplateName>/` layout (#335).
*/
export function templateOutputDir(templateName: string): string {
return path.join('templates', templateName);
}

export interface CoverageEntry {
operationId: string;
Expand All @@ -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
* `<templateScenariosRootDir>/<binding.name>/<subjectName>.json` the
* recorded `emittedSpec` becomes
* `<binding.outputDir>/<subjectName>.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
* `<templateScenariosRootDir>/<templateName>/<subjectName>.json`
* the recorded `emittedSpec` becomes
* `templates/<templateName>/<subjectName>.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 {
Expand Down Expand Up @@ -95,10 +110,11 @@ export async function buildCoverage(opts: BuildCoverageOptions): Promise<Coverag
}
templateDirs.sort();

// Build a name → outputDir lookup from the registry. Templates the
// registry doesn't know about are skipped (no spec is emitted on disk,
// so coverage cannot legitimately claim the op).
const outputDirByName = new Map(opts.templates.map((t) => [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/<TemplateName>/`) — 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);
Expand Down
33 changes: 21 additions & 12 deletions materializer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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/<name>/` and emits to
// `playwright/templates/<name>/`. 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;
Expand Down Expand Up @@ -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 `<playwrightSuiteDir>/<template.outputDir>/`.
// subject under `<playwrightSuiteDir>/templates/<TemplateName>/`.
// 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/<config>/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,
});
Expand Down
36 changes: 18 additions & 18 deletions materializer/src/playwright/templateEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,16 @@ export interface TemplateGlobalContextSeed {

export interface EmitTemplateSuitesOptions {
/**
* Absolute path to the EdgeLifecycle scenarios directory, i.e.
* `generated/<config>/scenarios/templates/EdgeLifecycle/`.
* Each `.json` underneath is read and rendered to one
* `<EdgeName>.lifecycle.spec.ts`.
* Absolute path to a template's scenarios directory, i.e.
* `generated/<config>/scenarios/templates/<TemplateName>/`
* (e.g. `EdgeLifecycle`, `EntityLifecycle`). Each `.json` underneath
* is read and rendered to one `<Subject>.lifecycle.spec.ts`.
*/
scenariosDir: string;
/**
* Absolute path to the destination directory, i.e.
* `generated/<config>/playwright/edges/`. Wiped and recreated by the
* caller (the materializer's `run()`).
* `generated/<config>/playwright/templates/<TemplateName>/`. Wiped
* and recreated by the caller (the materializer's `run()`).
*/
outDir: string;
/**
Expand Down Expand Up @@ -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');`);
Expand Down Expand Up @@ -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');`);
Expand Down Expand Up @@ -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');`);
Expand Down
51 changes: 0 additions & 51 deletions materializer/src/templateRegistry.ts

This file was deleted.

2 changes: 1 addition & 1 deletion path-analyser/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,7 @@ export interface GeneratedModelSpec {
// BFS-derived `EndpointScenario`s. They are written to
// `generated/<config>/scenarios/templates/<TemplateName>/<EdgeName>.json`
// and consumed by the Playwright emitter to produce
// `generated/<config>/playwright/edges/<EdgeName>.lifecycle.spec.ts`.
// `generated/<config>/playwright/templates/EdgeLifecycle/<EdgeName>.lifecycle.spec.ts`.
// The two output trees are independent — no field on `EndpointScenario`
// is touched here.
// ---------------------------------------------------------------------------
Expand Down
Loading