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
128 changes: 114 additions & 14 deletions configs/camunda-oca/regression-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,29 @@ const VARIANT_SCENARIOS_DIR = getVariantOutputDir(REPO_ROOT);
const GENERATED_TESTS_DIR = getPlaywrightSuiteDir(REPO_ROOT);
const BUNDLED_SPEC_PATH = join(getSpecBundleDir(REPO_ROOT), 'rest-api.bundle.json');

// #331: opIds whose per-endpoint feature spec is intentionally omitted
// because a scenario-template instantiation already encodes the
// canonical functional test for that operation. Materializer writes
// the coverage artefact alongside the suite. Loaded lazily — feature-
// presence invariants subtract this set; the lifecycle specs under
// `edges/`, `entities/`, `runtime-entities/`, and `state-transitions/`
// are the regression guard for the suppressed operations.
let _suppressedOpIdsCache: Set<string> | undefined;
function loadSuppressedOpIds(): Set<string> {
if (_suppressedOpIdsCache) return _suppressedOpIdsCache;
const coveragePath = join(GENERATED_TESTS_DIR, 'coverage.json');
if (!existsSync(coveragePath)) {
_suppressedOpIdsCache = new Set();
return _suppressedOpIdsCache;
}
// biome-ignore lint/plugin: runtime contract boundary — materializer-emitted coverage artefact; only `suppressedOpIds: string[]` is read.
const parsed = JSON.parse(readFileSync(coveragePath, 'utf8')) as {
suppressedOpIds?: string[];
};
_suppressedOpIdsCache = new Set(parsed.suppressedOpIds ?? []);
return _suppressedOpIdsCache;
}

// ---------------------------------------------------------------------------
// Bundled-spec helpers (shared between #326 and #247 invariants).
//
Expand Down Expand Up @@ -2037,13 +2060,18 @@ describeForThisConfig('bundled-spec invariants: emitted Playwright suite', () =>
let totalExpected = 0;
let totalActual = 0;
let suitesWithEc = 0;
// #331: suppressed opIds have no feature spec to wrap — the
// equivalent eventually-consistent reads inside their lifecycle
// spec are guarded by the template emitter's own wrap logic.
const suppressed = loadSuppressedOpIds();

for (const f of readdirSync(FEATURE_SCENARIOS_DIR)) {
if (!f.endsWith('-scenarios.json')) continue;
const raw = readFileSync(join(FEATURE_SCENARIOS_DIR, f), 'utf8');
// biome-ignore lint/plugin: parsed JSON is a runtime contract boundary; shape locally typed as CollectionLite
const coll = JSON.parse(raw) as CollectionLite;
if (!coll || typeof coll !== 'object') continue;
if (suppressed.has(coll.endpoint?.operationId)) continue;
const expected = expectedWraps(coll);
if (expected === 0) continue;
suitesWithEc++;
Expand Down Expand Up @@ -2277,7 +2305,7 @@ describeForThisConfig('bundled-spec invariants: fixture selection by required st
);
});

it('getIncident.feature.spec.ts deploys bpmn/incident-script-task.bpmn and chains searchIncidents → getIncident (#305 Phase 5a)', () => {
it('Incident.resolveIncident lifecycle spec deploys bpmn/incident-script-task.bpmn and chains searchIncidents → getIncident (#305 Phase 5a)', () => {
// Phase 5a promoted IncidentKey serverEmergent → runtimeEmission, gated
// by the new ModelEmitsIncident capability. The selector must pick the
// new incident-script-task fixture (the only one declaring
Expand All @@ -2291,30 +2319,42 @@ describeForThisConfig('bundled-spec invariants: fixture selection by required st
// IncidentKey (synthetic seed only), the emitted spec would have a
// single getIncident step seeded with `seedBinding('incidentKeyVar')`,
// and the test would be a no-op against any real broker.
const spec = join(GENERATED_TESTS_DIR, 'getIncident.feature.spec.ts');
//
// #331: getIncident's per-endpoint feature spec is now suppressed
// because `Incident.resolveIncident` (StateTransitionVisibleAfterAction
// template) is the canonical runtime-discovery test. The same chain
// assertions hold against the lifecycle spec — the production behaviour
// is unchanged, only the spec file moved.
const spec = join(
GENERATED_TESTS_DIR,
'state-transitions',
'Incident.resolveIncident.lifecycle.spec.ts',
);
if (!existsSync(spec)) {
throw new Error(`expected emitted spec ${spec} not found — run 'npm run testsuite:generate'`);
}
const src = readFileSync(spec, 'utf8');
expect(src, 'getIncident must deploy the incident-emitting fixture').toContain(
expect(src, 'Incident.resolveIncident must deploy the incident-emitting fixture').toContain(
'@@FILE:bpmn/incident-script-task.bpmn',
);
expect(src, 'getIncident must include searchIncidents discovery step').toContain(
"test.step('searchIncidents'",
);
expect(src, 'getIncident must include getIncident endpoint step').toContain(
"test.step('getIncident'",
expect(src, 'Incident.resolveIncident must include searchIncidents discovery step').toContain(
"test.step('prereq: searchIncidents'",
);
expect(src, 'getIncident must extract incidentKey from searchIncidents response').toContain(
"extractInto(ctx, 'incidentKeyVar', json?.items?.[0]?.incidentKey)",
);
expect(src, 'getIncident must consume the runtime-discovered incidentKey').toContain(
'${ctx.incidentKeyVar',
expect(src, 'Incident.resolveIncident must include getIncident endpoint step').toContain(
"test.step('observe (read-back): getIncident'",
);
expect(
src,
'Incident.resolveIncident must extract incidentKey from searchIncidents response',
).toMatch(/extractInto\(ctx, 'incidentKeyVar', json\d*\?\.items\?\.\[0\]\?\.incidentKey\)/);
expect(
src,
'Incident.resolveIncident must consume the runtime-discovered incidentKey',
).toContain('${ctx.incidentKeyVar');
// Negative: must NOT fall back to seeded-only resolution.
expect(
src,
'getIncident must NOT seed incidentKeyVar — the runtimeEmission chain supplies it',
'Incident.resolveIncident must NOT seed incidentKeyVar — the runtimeEmission chain supplies it',
).not.toContain("seedBinding('incidentKeyVar')");
});

Expand Down Expand Up @@ -4167,11 +4207,23 @@ describeForThisConfig('bundled-spec invariants: suite-partition cut (#162 PR 4)'
// without a corresponding `.feature.spec.ts` means the emitter
// dropped the suite silently — exactly the failure mode the
// partition cut is supposed to prevent.
//
// #331: operations covered by a scenario-template instantiation
// (EdgeLifecycle / EntityLifecycle / UpdatedFieldVisibleOnReadBack /
// StateTransitionVisibleAfterAction) are intentionally suppressed
// — the lifecycle spec under edges/, entities/, runtime-entities/,
// or state-transitions/ is the canonical functional test. Read
// the suppression set from the coverage artefact and exclude
// those opIds from this guard. A separate invariant below pins
// the inverse (every suppressed opId is backed by an emitted
// lifecycle spec).
const suppressed = loadSuppressedOpIds();
const offenders: { jsonFile: string; expectedSpec: string }[] = [];
for (const { file, parsed } of loadAllFeatureFiles()) {
if (!parsed.scenarios?.length) continue;
const opId = parsed.endpoint?.operationId;
if (!opId) continue;
if (suppressed.has(opId)) continue;
const expectedSpec = `${opId}.feature.spec.ts`;
const specPath = join(GENERATED_TESTS_DIR, expectedSpec);
if (!existsSync(specPath)) {
Expand All @@ -4184,6 +4236,54 @@ describeForThisConfig('bundled-spec invariants: suite-partition cut (#162 PR 4)'
).toEqual([]);
});

it('every scenario-template-suppressed opId is backed by an emitted lifecycle spec (#331)', () => {
// The inverse of the partition-cut guard above. The coverage
// artefact declares which opIds the materializer suppressed
// because a scenario-template instantiation already covers them;
// for every such opId there must be a `coverage.entries[]` row
// pointing at an emitted lifecycle spec that actually exists on
// disk. Without this guard a generator regression that emitted
// an empty coverage map (or one pointing at non-existent specs)
// would silently delete the feature spec for an operation that
// now has no test at all.
const coveragePath = join(GENERATED_TESTS_DIR, 'coverage.json');
if (!existsSync(coveragePath)) {
throw new Error(
`coverage artefact not found at ${coveragePath} — run 'npm run testsuite:generate'`,
);
}
// biome-ignore lint/plugin: runtime contract boundary — materializer-emitted coverage artefact.
const cov = JSON.parse(readFileSync(coveragePath, 'utf8')) as {
suppressedOpIds?: string[];
entries?: Array<{ operationId: string; emittedSpec: string }>;
};
const entries = cov.entries ?? [];
const opToSpecs = new Map<string, string[]>();
for (const e of entries) {
const arr = opToSpecs.get(e.operationId) ?? [];
arr.push(e.emittedSpec);
opToSpecs.set(e.operationId, arr);
}
const offenders: { opId: string; reason: string }[] = [];
for (const opId of cov.suppressedOpIds ?? []) {
const specs = opToSpecs.get(opId);
if (!specs || specs.length === 0) {
offenders.push({ opId, reason: 'no coverage entry' });
continue;
}
const missing = specs.filter((rel) => !existsSync(join(GENERATED_TESTS_DIR, rel)));
if (missing.length > 0) {
offenders.push({ opId, reason: `missing emitted spec(s): ${missing.join(', ')}` });
}
}
expect(
offenders,
'Operations whose feature spec was suppressed must be covered by an emitted lifecycle spec.',
).toEqual([]);
// Sanity: the bundled spec exercises this pattern non-trivially.
expect((cov.suppressedOpIds ?? []).length).toBeGreaterThan(0);
});

it('variant suite covers every flat top-level optional present in the feature suite pre-cut (#162 PR 4)', () => {
// After dropping the `subShapeRootOf` `segments.length < 2`
// filter, the variant planner must enumerate flat top-level
Expand Down
182 changes: 182 additions & 0 deletions materializer/src/coverage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Scenario-template coverage extractor (#331).
//
// Walks the per-template scenario JSON files emitted by the planner
// (`generated/<config>/scenarios/templates/<TemplateName>/*.json`) and
// collects every operationId that appears as the `op` of an `Invoke` or
// `Observe` step. Those operations are, by definition, the
// units-under-test of a well-formed scenario-driven spec — emitting a
// per-endpoint feature spec for the same operation would be redundant
// at best and structurally malformed at worst (see #331 for the
// EdgeLifecycle motivating example).
//
// The collector is deliberately template-agnostic: a new
// 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 wired into `templateOutputDirs`.
//
// `PrereqChain` steps are intentionally *excluded* from coverage —
// they are scaffolding, not units-under-test. The Invoke/Observe vs
// PrereqChain distinction is the same closed taxonomy declared in
// `scenarioTemplateSchema.ts`.

import { promises as fs } from 'node:fs';
import path from 'node:path';

export interface CoverageEntry {
operationId: string;
template: string;
appliesToKind: string;
aboxRow: string;
stepKind: 'invoke' | 'observe';
emittedSpec: string;
}

export interface CoverageResult {
suppressedOpIds: Set<string>;
entries: CoverageEntry[];
}

export interface BuildCoverageOptions {
/** `generated/<config>/scenarios/templates/`. */
templateScenariosRootDir: string;
/** `configs/<config>/ontology/scenario-templates.json`, or `undefined`
* to treat every template as suppressing (the default). */
templatesAboxPath: string | undefined;
/**
* Maps `templateName → emitted output directory relative to the
* playwright suite root`. For each scenario file under
* `<templateScenariosRootDir>/<templateName>/<subjectName>.json` the
* recorded `emittedSpec` becomes
* `<emittedDir>/<subjectName>.lifecycle.spec.ts`. Templates not
* present in this map are skipped (no spec is emitted, so coverage
* cannot legitimately claim the op).
*/
templateOutputDirs: Record<string, string>;
}

interface ScenarioStep {
kind?: string;
operationId?: string;
}

interface ScenarioFile {
templateName?: string;
subjectName?: string;
subjectKind?: string;
scenario?: { steps?: ScenarioStep[] };
}

interface ScenarioTemplateAbox {
templates?: Array<{ name?: string; suppressesFeatureTest?: boolean }>;
}

/**
* Collect coverage from on-disk scenario JSON files. Pure I/O —
* caller decides where the artefact lives and how the suppression set
* is consumed.
*/
export async function buildCoverage(opts: BuildCoverageOptions): Promise<CoverageResult> {
const suppressByTemplate = await loadSuppressionMap(opts.templatesAboxPath);
const entries: CoverageEntry[] = [];

let templateDirs: string[];
try {
templateDirs = await fs.readdir(opts.templateScenariosRootDir);
} catch (e) {
if (typeof e === 'object' && e !== null && 'code' in e && Reflect.get(e, 'code') === 'ENOENT') {
return { suppressedOpIds: new Set(), entries: [] };
}
throw e;
}
templateDirs.sort();

for (const templateName of templateDirs) {
const templateDir = path.join(opts.templateScenariosRootDir, templateName);
const stat = await fs.stat(templateDir);
if (!stat.isDirectory()) continue;

// Templates not wired to an emitted output dir don't actually
// produce a spec on disk; suppressing the feature spec on the
// strength of a scenario JSON nobody renders would create an
// untested gap. Skip.
const emittedDir = opts.templateOutputDirs[templateName];
if (!emittedDir) continue;

// Default: every well-formed scenario template suppresses. The
// opt-out is per-template so a future non-functional template
// (smoke / chaos / load) can emit a spec without claiming
// coverage.
const suppresses = suppressByTemplate.get(templateName) ?? true;
if (!suppresses) continue;

const files = (await fs.readdir(templateDir)).filter((f) => f.endsWith('.json')).sort();
for (const f of files) {
const raw = await fs.readFile(path.join(templateDir, f), 'utf8');
let parsed: ScenarioFile;
try {
// biome-ignore lint/plugin: runtime contract boundary — planner-emitted scenario JSON; downstream emitter validates the full shape.
parsed = JSON.parse(raw) as ScenarioFile;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(
`coverage: failed to parse scenario JSON ${path.join(templateDir, f)}: ${msg}`,
);
}
const subjectName = parsed.subjectName;
const subjectKind = parsed.subjectKind;
const tmplName = parsed.templateName ?? templateName;
if (!subjectName) continue;
const emittedSpec = path.join(emittedDir, `${subjectName}.lifecycle.spec.ts`);
const steps = parsed.scenario?.steps ?? [];
for (const step of steps) {
const kind = step.kind;
if ((kind === 'invoke' || kind === 'observe') && step.operationId) {
entries.push({
operationId: step.operationId,
template: tmplName,
appliesToKind: subjectKind ?? '',
aboxRow: subjectName,
stepKind: kind,
emittedSpec,
});
}
}
}
}

return {
suppressedOpIds: new Set(entries.map((e) => e.operationId)),
entries,
};
}

async function loadSuppressionMap(
templatesAboxPath: string | undefined,
): Promise<Map<string, boolean>> {
const out = new Map<string, boolean>();
if (!templatesAboxPath) return out;
let raw: string;
try {
raw = await fs.readFile(templatesAboxPath, 'utf8');
} catch (e) {
if (typeof e === 'object' && e !== null && 'code' in e && Reflect.get(e, 'code') === 'ENOENT') {
return out;
}
throw e;
}
let parsed: ScenarioTemplateAbox;
try {
// biome-ignore lint/plugin: runtime contract boundary — scenario-templates ABox JSON; only `templates[].name` and `templates[].suppressesFeatureTest` are read.
parsed = JSON.parse(raw) as ScenarioTemplateAbox;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`coverage: failed to parse ${templatesAboxPath}: ${msg}`);
}
for (const t of parsed.templates ?? []) {
if (typeof t.name === 'string') {
out.set(t.name, t.suppressesFeatureTest !== false);
}
Comment thread
jwulf marked this conversation as resolved.
}
return out;
}
Loading