-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathindex.ts
More file actions
834 lines (797 loc) · 35.7 KB
/
Copy pathindex.ts
File metadata and controls
834 lines (797 loc) · 35.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
import fsSync, { promises as fs } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
type EmitContext,
type EmitterStrategy,
getEmitter,
type LoadedRoleBundle,
listEmitters,
type RoleHookProvider,
registerEmitter,
registerRoleHookProvider,
} from '@camunda8/emitter-sdk';
import {
getActiveConfigDir,
getActiveConfigName,
getFeatureOutputDir,
getPlaywrightSuiteDir,
getSdkOutDir,
getSpecBundleDir,
getTemplateScenariosDir,
getTemplateScenariosRootDir,
getVariantOutputDir,
} from 'path-analyser/configResolver';
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, templateOutputDir } from './coverage.js';
import { buildCoverageSummary, loadSpecOperationIds } from './coverageSummary.js';
import { type CsharpOperationMap, createCsharpEmitter } from './csharp-sdk/emitter.js';
import { materializeCsharpSupport } from './csharp-sdk/materialize-support.js';
import { createJsSdkEmitter } from './js-sdk/emitter.js';
import { materializeSdkSupport } from './js-sdk/materialize-support.js';
import { OperationMapJsonSource } from './js-sdk/sdk-mapping.js';
import { writeEmitted, writeScaffolded } from './orchestrator.js';
import { PlaywrightEmitter } from './playwright/emitter.js';
import {
materializeFixtures,
materializeResponseSchemas,
materializeRoleSupportFiles,
materializeSupport,
} from './playwright/materialize-support.js';
import { loadRoleBundlesForActiveConfig } from './playwright/roleRenderer.js';
import { emitTemplateSuites } from './playwright/templateEmitter.js';
import { createPythonSdkEmitter } from './python-sdk/emitter.js';
import { materializePythonSupport } from './python-sdk/materialize-support.js';
import { createOperationMapSourceFromJson } from './python-sdk/sdk-mapping.js';
import { RoleHookConflictError, resolveRoleExtras } from './roleHookResolver.js';
// Built-in emitter registrations. RoleHookProviders are no longer
// registered statically here: every provider lives next to its role
// bundle under configs/<config>/codegen/playwright/roles/<role>/hook.ts
// and is discovered + registered at run time by `discoverRoleHooks`
// (Lift 19 / #261). This keeps the materializer a generic orchestrator
// — the previously hard-coded role-hook provider import
// pulled OCA-specific knowledge into a package that is supposed to be
// config-agnostic.
registerEmitter(PlaywrightEmitter);
// SDK emitters are registered inside run() after repoRoot is resolved so
// they can load operation-map.json files from the spec/ directory. Keeping
// them here (at module level) would require resolving the repo root before
// the CLI args are parsed, which is fragile on Windows paths and CI.
/** Load the JS SDK operation map from spec/js-sdk/operation-map.json, or undefined if absent. */
function loadJsSdkMap(repoRoot: string): OperationMapJsonSource | undefined {
const mapPath = path.join(repoRoot, 'spec', 'js-sdk', 'operation-map.json');
if (!fsSync.existsSync(mapPath)) return undefined;
try {
return OperationMapJsonSource.fromJson(fsSync.readFileSync(mapPath, 'utf-8'));
} catch {
return undefined;
}
}
/** Load the Python SDK operation map from spec/python-sdk/operation-map.json, or undefined if absent. */
function loadPythonSdkMap(
repoRoot: string,
): ReturnType<typeof createOperationMapSourceFromJson> | undefined {
const mapPath = path.join(repoRoot, 'spec', 'python-sdk', 'operation-map.json');
if (!fsSync.existsSync(mapPath)) return undefined;
try {
return createOperationMapSourceFromJson(fsSync.readFileSync(mapPath, 'utf-8'));
} catch {
return undefined;
}
}
/** Load the C# SDK operation map from csharp-sdk/examples/operation-map.json, or {} if absent. */
function loadCsharpMap(repoRoot: string): CsharpOperationMap {
const mapPath = path.join(repoRoot, 'csharp-sdk', 'examples', 'operation-map.json');
if (!fsSync.existsSync(mapPath)) return {};
try {
// biome-ignore lint/plugin: runtime contract boundary — JSON from committed operation-map file
return JSON.parse(fsSync.readFileSync(mapPath, 'utf-8')) as CsharpOperationMap;
} catch {
return {};
}
}
/**
* Walk the active config's role bundles and register any role-hook
* providers shipped alongside them.
*
* Conventions enforced here:
* - A hook lives at `<roleDir>/hook.ts` and default-exports a
* `RoleHookProvider`.
* - `provider.role` must equal the role directory name, so the role
* dispatcher in the emitter (which keys `roleExtras` by role) and
* the directory layout cannot drift.
*
* The build/runtime gap is bridged via tsx (see `codegen:playwright`
* scripts) — dynamic imports of `.ts` files only resolve when the
* orchestrator is run under tsx, which is why the codegen scripts no
* longer invoke `node materializer/dist/src/index.js` directly.
*/
async function discoverRoleHooks(roleBundles: Map<string, LoadedRoleBundle>): Promise<void> {
for (const [roleName, bundle] of roleBundles) {
const hookPath = path.join(bundle.dir, 'hook.ts');
if (!fsSync.existsSync(hookPath)) continue;
const mod = await import(pathToFileURL(hookPath).href);
if (!isRoleHookProvider(mod.default)) {
throw new Error(
`Role hook ${hookPath} must default-export a RoleHookProvider with { hook: string, role: string, compute: function }.`,
);
}
const provider: RoleHookProvider = mod.default;
if (provider.role !== roleName) {
throw new Error(
`Role hook ${hookPath} declares role ${JSON.stringify(provider.role)} ` +
`but lives under directory ${JSON.stringify(roleName)} — these must agree, ` +
`otherwise the emitter will look for ctx.roleExtras[${JSON.stringify(roleName)}] ` +
`and find nothing.`,
);
}
registerRoleHookProvider(provider);
}
}
function isRoleHookProvider(value: unknown): value is RoleHookProvider {
if (typeof value !== 'object' || value === null) return false;
if (!('hook' in value) || !('role' in value) || !('compute' in value)) return false;
// biome-ignore lint/plugin: runtime contract boundary for a dynamic-imported module
const v = value as { hook: unknown; role: unknown; compute: unknown };
return (
typeof v.hook === 'string' && typeof v.role === 'string' && typeof v.compute === 'function'
);
}
// JSON.parse is a runtime contract boundary: the on-disk scenario files are
// produced by the generator and conform structurally to EndpointScenarioCollection.
// Downstream code accesses `.endpoint?.operationId` optionally and tolerates
// malformed entries via the surrounding try/catch.
function parseScenarioCollection(text: string): EndpointScenarioCollection {
// biome-ignore lint/plugin: runtime contract boundary for parsed JSON
return JSON.parse(text) as EndpointScenarioCollection;
}
/**
* Load `globalContextSeeds` from the per-config global-context-seeds
* ABox (`configs/<active>/ontology/global-context-seeds.json`,
* Lift 8 / #218). Returns `[]` when no ABox is shipped. The graphLoader
* validates the ABox during planning, but we re-validate here because
* these values are interpolated directly into emitted TS source — a
* malformed entry (wrong type, unsafe characters, duplicate fieldName)
* would produce a broken suite or, worse, allow config-driven code
* injection.
*/
async function loadGlobalContextSeeds(baseDir: string): Promise<GlobalContextSeed[]> {
const repoRoot = path.resolve(baseDir, '..');
const aboxViews = deriveGlobalContextSeedsViews(repoRoot);
if (aboxViews === null) return [];
assertSafeGlobalContextSeeds(aboxViews.globalContextSeeds);
return aboxViews.globalContextSeeds;
}
/**
* Load this emitter's per-config knobs from
* `configs/<configName>/codegen/<emitterId>/config.json`. Returns `{}`
* when no such file exists (every field must have an explicit default
* inside the emitter). Always validated against the emitter's
* `configSchema` if one is declared.
*/
function loadEmitterConfig(configDir: string, emitter: EmitterStrategy): Record<string, unknown> {
const configPath = path.join(configDir, 'codegen', emitter.id, 'config.json');
if (!fsSync.existsSync(configPath)) return {};
let raw: unknown;
try {
raw = JSON.parse(fsSync.readFileSync(configPath, 'utf8'));
} catch (err) {
throw new Error(
`Failed to read ${configPath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
throw new Error(
`${configPath}: expected a JSON object, got ${Array.isArray(raw) ? 'array' : typeof raw}.`,
);
}
// biome-ignore lint/plugin: runtime contract boundary for parsed JSON
const cfg = raw as Record<string, unknown>;
validateEmitterConfig(emitter, cfg, configPath);
return cfg;
}
/**
* Minimal JSON-Schema validator covering only the constructs used by
* built-in emitter configSchemas (object, additionalProperties=false,
* top-level `properties` map with leaf `type` values from the subset
* `boolean|string|number|integer|array|object|null`).
* Keeps the SDK dependency-free — we deliberately avoid pulling in Ajv
* for the small surface that emitter configs cover today. Schemas that
* exceed this subset must extend this validator alongside.
*/
function validateEmitterConfig(
emitter: EmitterStrategy,
cfg: Record<string, unknown>,
source: string,
): void {
const schema = emitter.configSchema;
if (!schema) return;
const properties =
typeof schema.properties === 'object' && schema.properties !== null
? // biome-ignore lint/plugin: JSONSchema is intentionally permissive (Record<string, unknown>); narrowing the leaf to a typed shape is the validator's job
(schema.properties as Record<string, { type?: string }>)
: {};
const additionalProperties = schema.additionalProperties;
if (additionalProperties === false) {
for (const key of Object.keys(cfg)) {
if (!Object.hasOwn(properties, key)) {
throw new Error(
`${source}: unknown key '${key}' for emitter '${emitter.id}'. ` +
`Allowed: ${Object.keys(properties).join(', ') || '(none)'}.`,
);
}
}
}
for (const [key, value] of Object.entries(cfg)) {
const spec = properties[key];
if (!spec || typeof spec.type !== 'string') continue;
if (!matchesJsonType(value, spec.type)) {
throw new Error(
`${source}: key '${key}' for emitter '${emitter.id}' must be of type ${spec.type}, got ${typeof value}.`,
);
}
}
}
function matchesJsonType(value: unknown, jsonType: string): boolean {
switch (jsonType) {
case 'boolean':
return typeof value === 'boolean';
case 'string':
return typeof value === 'string';
case 'number':
return typeof value === 'number';
case 'integer':
return typeof value === 'number' && Number.isInteger(value);
case 'array':
return Array.isArray(value);
case 'object':
return typeof value === 'object' && value !== null && !Array.isArray(value);
case 'null':
return value === null;
default:
throw new Error(`Unsupported JSON schema type '${jsonType}'.`);
}
}
/**
* Read the active config's `codegen/emitters.json` registry. The file
* declares which emitters this config supports; the orchestrator
* cross-checks the requested `--target` against this list and against
* the emitter's own `supportedConfigs`.
*
* Returns the array of emitter ids. Throws when the file is missing or
* malformed — configs MUST be explicit about which emitters they
* authorise.
*/
function loadEnabledEmitters(configDir: string): string[] {
const file = path.join(configDir, 'codegen', 'emitters.json');
if (!fsSync.existsSync(file)) {
throw new Error(
`Missing ${file}. Every config must declare its enabled emitters as ` +
`{"emitters": ["playwright", ...]}.`,
);
}
let raw: unknown;
try {
raw = JSON.parse(fsSync.readFileSync(file, 'utf8'));
} catch (err) {
throw new Error(`Failed to read ${file}: ${err instanceof Error ? err.message : String(err)}`);
}
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
throw new Error(`${file}: expected a JSON object with an "emitters" array.`);
}
// biome-ignore lint/plugin: runtime contract boundary for parsed JSON
const obj = raw as Record<string, unknown>;
if (!Array.isArray(obj.emitters) || !obj.emitters.every((s) => typeof s === 'string')) {
throw new Error(`${file}: "emitters" must be an array of strings.`);
}
// biome-ignore lint/plugin: just validated above that every element is a string
return obj.emitters as string[];
}
function printUsage(): void {
const targets = listEmitters()
.map((e) => `${e.id} (${e.name})`)
.join(', ');
console.error(
'Usage: tsx materializer/src/index.ts [--target=<id>] <operationId>|--all\n' +
`Available targets: ${targets || '(none)'}`,
);
}
// Walks up from process.cwd() to find the repo root, identified by the
// presence of configs.json. Allows the CLI to be invoked from any
// workspace (root, materializer/, path-analyser/) without per-cwd
// special-casing — the legacy heuristic only worked when CWD was the
// repo root or path-analyser/.
function findRepoRoot(start: string): string {
let dir = path.resolve(start);
let parent = path.dirname(dir);
while (parent !== dir) {
if (fsSync.existsSync(path.join(dir, 'configs.json'))) return dir;
dir = parent;
parent = path.dirname(dir);
}
if (fsSync.existsSync(path.join(dir, 'configs.json'))) return dir;
throw new Error(
`Could not find repo root (no configs.json found walking up from ${start}). ` +
`Run from inside the api-test-generator repository.`,
);
}
/**
* Per-target run environment. Shared, target-independent inputs computed
* once in {@link run} and threaded into {@link runForTarget} so a single
* `--all-targets` invocation can iterate emitters without recomputing
* config/scenario locations per target.
*/
interface TargetRunEnv {
repoRoot: string;
/** `--all` sentinel or a single operationId. */
positional: string;
configName: string;
configDir: string;
baseDir: string;
featureDir: string;
variantDir: string;
}
/**
* Register the file-system-backed SDK emitters. Kept here (not at module
* level) so operation-map.json files load from the resolved repoRoot. The
* factories tolerate an absent map (fall back to operationId-derived method
* names), so this is safe to call even when the maps haven't been fetched —
* e.g. for the `list-targets` projection. The Playwright emitter is already
* registered at module level (it has no file-system dependencies).
*/
function registerSdkEmitters(repoRoot: string): void {
registerEmitter(createJsSdkEmitter(loadJsSdkMap(repoRoot)));
registerEmitter(createPythonSdkEmitter(loadPythonSdkMap(repoRoot)));
registerEmitter(createCsharpEmitter(loadCsharpMap(repoRoot)));
}
/**
* `list-targets` projection: print the registered emitters as JSON so the
* build system (e.g. the generic SDK-map fetcher) can read the registry
* instead of duplicating the per-target list across npm scripts. The
* registry is the single source of truth; this is its cross-process seam.
*/
function printTargetsJson(): void {
const targets = listEmitters().map((e) => ({
id: e.id,
name: e.name,
supportedConfigs: e.supportedConfigs,
sdkMap: e.sdkMap,
}));
console.log(JSON.stringify(targets, null, 2));
}
async function run() {
const { target, positional, help, allTargets, listTargets } = parseCliArgs(process.argv.slice(2));
const repoRoot = findRepoRoot(process.cwd());
registerSdkEmitters(repoRoot);
// `list-targets` is a pure registry projection — it needs neither a
// positional nor the active config, so handle it before any other gate.
if (listTargets) {
printTargetsJson();
return;
}
if (help || !positional) {
printUsage();
console.error(
'Additional commands/options:\n' +
' list-targets Print the registered emitters as JSON and exit\n' +
' --all-targets Run codegen for every enabled+registered emitter (ignores --target)\n',
);
process.exit(1);
}
// loadGraph / loadGlobalContextSeeds were carved out of the original
// path-analyser CLI and still take `baseDir = <repoRoot>/path-analyser`
// (they compute repoRoot internally as `path.resolve(baseDir, '..')`).
// Per-config output partition (#128 PR 2): scenario inputs and the
// emitted suites all live under generated/<config>/.
const configName = getActiveConfigName(repoRoot);
const configDir = getActiveConfigDir(repoRoot);
const enabledEmitters = loadEnabledEmitters(configDir);
const env: TargetRunEnv = {
repoRoot,
positional,
configName,
configDir,
baseDir: path.join(repoRoot, 'path-analyser'),
featureDir: getFeatureOutputDir(repoRoot),
variantDir: getVariantOutputDir(repoRoot),
};
// `--all-targets`: derive the target list from the config's enabled
// emitters, intersected with the registry and each emitter's
// supportedConfigs. The registry is authoritative — npm scripts no
// longer enumerate `codegen:<target>` per emitter.
if (allTargets) {
const targets = enabledEmitters.filter((id) => {
const e = getEmitter(id);
if (!e) {
console.warn(
`Config '${configName}' enables emitter '${id}', but no emitter with that id is registered; skipping.`,
);
return false;
}
return e.supportedConfigs.includes('*') || e.supportedConfigs.includes(configName);
});
for (const id of targets) {
const emitter = getEmitter(id);
if (!emitter) continue; // unreachable: filtered above, but keeps types honest
await runForTarget(emitter, env);
}
return;
}
const emitter = getEmitter(target);
if (!emitter) {
console.error(
`Unknown emitter target: '${target}'. Available: ${listEmitters()
.map((e) => e.id)
.join(', ')}`,
);
process.exit(1);
}
// Validate this emitter is wired to the active config from both sides:
// 1. emitter.supportedConfigs declares which configs the emitter targets;
// 2. configs/<config>/codegen/emitters.json declares which emitters the
// config authorises. Both must agree before the orchestrator invokes
// `emit()` — otherwise an emitter could write into a config that
// didn't opt in to its output shape, or vice versa.
if (!enabledEmitters.includes(emitter.id)) {
console.error(
`Emitter '${emitter.id}' is not enabled in config '${configName}'. ` +
`Add it to ${path.join(configDir, 'codegen', 'emitters.json')}.`,
);
process.exit(1);
}
if (!emitter.supportedConfigs.includes('*') && !emitter.supportedConfigs.includes(configName)) {
console.error(
`Emitter '${emitter.id}' does not support config '${configName}'. ` +
`Supported: ${emitter.supportedConfigs.join(', ')}.`,
);
process.exit(1);
}
await runForTarget(emitter, env);
}
/**
* Materialise a single emitter's suites. The caller guarantees the emitter
* is registered, enabled by the active config, and supports it. Pure per
* target: all shared inputs arrive via {@link TargetRunEnv}, so this is the
* unit iterated by `--all-targets`.
*/
async function runForTarget(emitter: EmitterStrategy, env: TargetRunEnv): Promise<void> {
const { repoRoot, positional, configName, configDir, baseDir, featureDir, variantDir } = env;
const emitterConfig = loadEmitterConfig(configDir, emitter);
const resolveConfigPath = (relative: string): string => path.resolve(configDir, relative);
// Each emitter owns its own output directory so every `codegen:<target>` run
// produces a self-contained, runnable artifact. Playwright writes to
// generated/<config>/playwright/; SDK emitters write to
// generated/<config>/<emitter-id>/ and materialise their own scaffolding
// so the output is runnable without a prior playwright run.
const outDir =
emitter.id === 'playwright'
? getPlaywrightSuiteDir(repoRoot)
: getSdkOutDir(repoRoot, emitter.id);
// Wipe before write so stale files from a previous spec version cannot
// survive into the current run. Each emitter owns its own directory so
// the wipe is always safe regardless of target.
await fs.rm(outDir, { recursive: true, force: true });
await fs.mkdir(outDir, { recursive: true });
// Lift 12 / #231: per-role template bundles for the active config's
// Playwright emitter. Loaded from configs/<config>/codegen/playwright/roles/
// and threaded into every EmitContext below. `undefined` for non-Playwright
// emitters (computed inside the gate below). Per-role scope additions
// (e.g. spec-derived `extracts` for deploymentGateway) live in
// `roleExtras`, keyed by role name.
let roleBundles: Map<string, LoadedRoleBundle> | undefined;
let roleExtras: Map<string, Record<string, unknown>> | undefined;
let getRoleForOperationFn: ((opId: string) => string | undefined) | undefined;
if (emitter.id === 'playwright') {
const recordResponses =
typeof emitterConfig.recordResponses === 'boolean' ? emitterConfig.recordResponses : false;
const excludeSupportFiles = recordResponses ? undefined : ['recorder.ts'];
await materializeSupport(outDir, undefined, excludeSupportFiles);
// Lift 12 / #231: load per-role bundles. Vendoring the role helper
// files into <outDir>/support/ is deferred until after the role-hook
// loop below populates `roleExtras` — templated helpers
// (`support.<ext>.tmpl`, #243) are rendered against those extras at
// codegen time so spec-derived constants (e.g. the deployment-gateway
// `EXTRACTS` list) live inside the helper instead of being threaded
// through every call-site literal.
roleBundles = loadRoleBundlesForActiveConfig(repoRoot);
// Lift 19 / #261: per-config role-hook discovery. Replaces the
// static `registerRoleHookProvider(...)` for the deployment role
// call at module load. Must run before the `emitter.roleHooks`
// loop below resolves providers via `getRoleHookProvider(hook)`.
await discoverRoleHooks(roleBundles);
// Copy BPMN/DMN/form fixture files into <outDir>/fixtures/ so the suite
// is self-contained: @@FILE:<rel-path> markers in emitted tests resolve
// via support/fixtures.ts regardless of process.cwd().
await materializeFixtures(outDir);
// Also extract response-body schemas alongside the emitted specs so the
// generated `validateResponse(...)` calls have a schema source. Co-located
// here (rather than a separate npm script) so every codegen run produces
// a runnable suite as a single artifact.
await materializeResponseSchemas(outDir);
}
if (emitter.id === 'js-sdk') {
await materializeSdkSupport(outDir);
}
if (emitter.id === 'python-sdk') {
await materializePythonSupport(outDir);
}
if (emitter.id === 'csharp-sdk') {
// materializeCsharpSupport already vendors the active config's BPMN/DMN/form
// fixtures into <outDir>/fixtures/ so @@FILE: paths in generated C# tests
// resolve to AppContext.BaseDirectory/fixtures/ at run time. No separate
// materializeFixtures() call is needed (it would duplicate the copy).
await materializeCsharpSupport(outDir);
}
const files = (await fs.readdir(featureDir)).filter((f) => f.endsWith('-scenarios.json'));
const globalContextSeeds = await loadGlobalContextSeeds(baseDir);
// Lift 9 / #225: discriminator for the role-based dispatch in
// emitters, sourced from the active config's artifact-kinds ABox
// (`operationRules[].role`). `undefined` when no ABox is shipped —
// role dispatch then has nothing to do and emitters take their
// fallback path for every step.
const artifactViews = deriveArtifactKindsViews(repoRoot);
// Wire role dispatch + per-role extras for any emitter that opts in
// via `roleHooks`. Previously, the orchestrator hard-coded
// deployment-gateway knowledge inline; now it delegates to
// `RoleHookProvider`s registered against the SDK (see #233 Step 6).
if (emitter.id === 'playwright') {
const domain = artifactViews
? { operationArtifactRules: artifactViews.operationArtifactRules }
: undefined;
getRoleForOperationFn = (opId: string) => getEmitterRoleForOperation(domain, opId);
}
// #350: roleHook resolution. Declarations are advisory — a config that
// doesn't ship a provider for a declared hook simply doesn't populate
// the corresponding role's extras. Operations actually dispatched to
// the unbacked role surface a named error at materialization time
// (`findRoleForStep` in playwright/emitter.ts and the support-template
// assertions in materialize-support.ts). The resolver throws
// `RoleHookConflictError` on duplicate-role conflicts (formerly
// `process.exit(1)` here); any other error from a provider’s
// `compute()` propagates with its full stack so unexpected hook bugs
// remain debuggable.
try {
roleExtras = await resolveRoleExtras(emitter, { repoRoot, configName });
} catch (err) {
if (!(err instanceof RoleHookConflictError)) throw err;
console.error(err.message);
process.exit(1);
}
// #243: Vendor per-role helper files now that `roleExtras` is populated,
// so templated helpers (`support.<ext>.tmpl`) render against the same
// per-role data the call-site renderer sees. Roles bound in the ABox but
// missing a bundle raise at render time (see roleRenderer.findRoleForStep
// in playwright/emitter.ts). The wider materializer LoadedRoleBundle
// (which carries `supportFilePath` and `supportIsTemplated`) is
// structurally a superset of the SDK shape, so we feed it straight into
// `materializeRoleSupportFiles` and the same map into ctx.roleBundles.
if (emitter.id === 'playwright' && roleBundles) {
await materializeRoleSupportFiles(outDir, roleBundles, roleExtras);
}
function buildCtx(suiteName: string, mode: 'feature' | 'variant'): EmitContext {
return {
outDir,
suiteName,
mode,
configName,
emitterConfig,
resolveConfigPath,
globalContextSeeds,
getRoleForOperation: getRoleForOperationFn,
roleBundles,
roleExtras,
};
}
// #233 Step 7: one-shot project-root scaffolding via the SDK contract.
// Generic across emitters — Playwright today returns the five
// PROJECT_TEMPLATE_FILES; future SDK emitters (JS/C#/Python) return their
// own project framing. No-op when emitter.scaffold is omitted.
// Called once per CLI invocation, before any emit() call, per
// EmitterStrategy.scaffold's contract. suiteName/mode are unused by
// scaffold itself but are part of the shared EmitContext type; pass safe
// placeholders.
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.
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'),
templateNames,
});
}
let count = 0;
let suppressedCount = 0;
// #335: track which opIds were emitted as feature specs so the
// coverage summary can compute the unmapped set (ops in the spec
// that are neither emitted as a feature spec nor suppressed by a
// scenario-template lifecycle suite). Should be empty on a healthy
// spec; a non-empty set surfaces planner / coverage drift.
const emittedFeatureOpIds = new Set<string>();
for (const f of files) {
try {
const content = await fs.readFile(path.join(featureDir, f), 'utf8');
const parsed = parseScenarioCollection(content);
if (!parsed.endpoint?.operationId) continue;
if (coverage.suppressedOpIds.has(parsed.endpoint.operationId)) {
suppressedCount++;
continue;
}
await writeEmitted(emitter, parsed, buildCtx(parsed.endpoint.operationId, 'feature'));
emittedFeatureOpIds.add(parsed.endpoint.operationId);
count++;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn('Skipping file (parse/emission failed):', f, msg);
}
}
// Issue #105: also materialise optional sub-shape variant scenarios
// (#37) into Playwright tests. The variant-output directory is
// populated by the planner only for endpoints with at least one
// optional sub-shape; emit nothing when the directory is absent so
// local runs that scope to feature scenarios still succeed.
let variantCount = 0;
let variantFiles: string[] = [];
try {
variantFiles = (await fs.readdir(variantDir)).filter((f) => f.endsWith('-scenarios.json'));
} catch (e) {
if (
!(typeof e === 'object' && e !== null && 'code' in e && Reflect.get(e, 'code') === 'ENOENT')
) {
throw e;
}
}
for (const f of variantFiles) {
try {
const content = await fs.readFile(path.join(variantDir, f), 'utf8');
const parsed = parseScenarioCollection(content);
if (!parsed.endpoint?.operationId) continue;
if (!parsed.scenarios?.length) continue;
await writeEmitted(emitter, parsed, buildCtx(parsed.endpoint.operationId, 'variant'));
variantCount++;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn('Skipping variant file (parse/emission failed):', f, msg);
}
}
// Template-derived suites (Lift 22 / #270; extended in #280 with
// EntityLifecycle, #305 with UpdatedFieldVisibleOnReadBack +
// StateTransitionVisibleAfterAction). One Playwright suite per
// 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 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,
// #342: forward `omitWhenUnbound` so the template emitter's
// `emitCtxSeeding` honours the same universal-prologue skip as
// the per-endpoint emitter. Without this, template-derived
// lifecycle suites would still auto-seed `tenantIdVar` and put
// a value on the wire for ops that the design says should omit
// the field.
omitWhenUnbound: s.omitWhenUnbound,
}));
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, templateName),
outDir: templateOutDir,
globalContextSeeds: seedsArg,
});
lifecycleCount += written.length;
}
}
// #335: build a deterministic coverage summary alongside the raw
// suppression set / entries. The summary block answers "how many
// operations in the spec are covered, by what kind of suite, and
// by which template" without requiring readers to re-walk the
// feature-output / template-scenarios directories. The summary is
// emitted for every emitter so PR diffs and the
// `npm run coverage:report` script see the same shape regardless
// of which target the materializer was invoked for.
const allSpecOpIds = await loadSpecOperationIds(getSpecBundleDir(repoRoot));
const summary = buildCoverageSummary({
allSpecOpIds,
emittedFeatureOpIds,
suppressedOpIds: coverage.suppressedOpIds,
entries: coverage.entries,
variantSpecs: variantCount,
lifecycleSpecs: lifecycleCount,
});
// #331: persist the coverage artefact alongside the suites so it
// is diffable in PRs and consumable by the L3 invariant in
// configs/<config>/regression-invariants.test.ts. Written for
// every emitter so the artefact's presence is independent of
// whether the current target shipped template suites this run.
await fs.writeFile(
path.join(outDir, 'coverage.json'),
`${JSON.stringify(
{
version: 2,
config: configName,
emitter: emitter.id,
summary,
suppressedOpIds: [...coverage.suppressedOpIds].sort(),
entries: [...coverage.entries].sort((a, b) =>
a.operationId === b.operationId
? a.template === b.template
? a.aboxRow.localeCompare(b.aboxRow) || a.stepKind.localeCompare(b.stepKind)
: a.template.localeCompare(b.template)
: a.operationId.localeCompare(b.operationId),
),
},
null,
2,
)}\n`,
'utf8',
);
console.log(
`Generated test suites for ${count} endpoints (+${variantCount} variant suites, +${lifecycleCount} lifecycle suites, -${suppressedCount} suppressed by scenario-template coverage) in ${outDir} (target: ${emitter.id})`,
);
return;
}
const endpointOpId = positional;
let match: string | null = null;
for (const f of files) {
const content = await fs.readFile(path.join(featureDir, f), 'utf8');
try {
const parsed = parseScenarioCollection(content);
if (parsed.endpoint?.operationId === endpointOpId) {
match = f;
break;
}
} catch {
/* ignore */
}
}
if (!match) {
console.error('Could not locate scenario file for operationId', endpointOpId);
process.exit(1);
}
const json = parseScenarioCollection(await fs.readFile(path.join(featureDir, match), 'utf8'));
await writeEmitted(emitter, json, buildCtx(endpointOpId, 'feature'));
console.log('Generated test suite for', endpointOpId, 'at', outDir, `(target: ${emitter.id})`);
}
function _hyphenizeOp(op: string) {
return op.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
}
// removed findMethodPrefix (obsolete)
run().catch((e) => {
console.error(e);
process.exit(1);
});