cabloy 5.1.210 → 5.1.211

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/package.json +2 -1
  4. package/repo-agent-governance/managed-assets.json +6 -6
  5. package/repo-agent-governance/skills/cabloy-contract-loop/SKILL.md +14 -8
  6. package/repo-agent-governance/skills/cabloy-contract-loop/references/verification-checklist.md +4 -1
  7. package/repo-agent-governance/tests/governance.test.mjs +65 -0
  8. package/repo-docs/ai/playbook-contract-regeneration.md +2 -0
  9. package/repo-docs/frontend/openapi-sdk-guide.md +23 -4
  10. package/repo-docs/fullstack/contract-loop-playbook.md +8 -0
  11. package/repo-docs/fullstack/openapi-to-sdk.md +12 -6
  12. package/repo-docs/reference/repo-scripts.md +3 -0
  13. package/scripts/bootstrapAgentGovernance.mjs +18 -0
  14. package/scripts/generateOpenapiIsolated.test.ts +207 -0
  15. package/scripts/generateOpenapiIsolated.ts +91 -0
  16. package/scripts/runIsolatedTest.test.ts +90 -0
  17. package/scripts/runIsolatedTest.ts +157 -82
  18. package/scripts/upgrade.test.ts +113 -0
  19. package/scripts/upgrade.ts +32 -0
  20. package/vona/packages-cli/cli/package.json +1 -1
  21. package/vona/packages-cli/cli-set-api/package.json +1 -1
  22. package/vona/packages-vona/vona/package.json +1 -1
  23. package/vona/packages-vona/vona-core/package.json +1 -1
  24. package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +8 -1
  25. package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +2 -2
  26. package/vona/packages-vona/vona-mock/package.json +1 -1
  27. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  28. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  29. package/zova/openapi.config.test.ts +29 -0
  30. package/zova/openapi.config.ts +13 -4
  31. package/zova/packages-cli/cli/package.json +2 -2
  32. package/zova/packages-cli/cli-set-front/cli/templates/openapi/config/boilerplate/project/openapi.config.ts +13 -4
  33. package/zova/packages-cli/cli-set-front/package.json +1 -1
  34. package/zova/packages-zova/zova/package.json +2 -2
  35. package/zova/src/suite/a-training/modules/training-student/src/.metadata/index.ts +7 -7
  36. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +12 -0
  37. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +173 -72
  38. package/zova/src/suite/a-training/modules/training-student/src/api/trainingStudent.ts +8 -1
  39. package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
  40. package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/schemaRefresh.ts +21 -12
  41. package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefreshLifecycle.test.ts +112 -0
  42. package/zova/src/suite-vendor/a-zova/package.json +2 -2
@@ -0,0 +1,91 @@
1
+ import { resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import {
5
+ ROOT_DIR,
6
+ runIsolatedProcess,
7
+ withIsolatedVona,
8
+ withIsolatedRun,
9
+ } from './runIsolatedTest.ts';
10
+
11
+ const OPENAPI_VERSION = 'V31';
12
+ const SOURCE_TIMEOUT_MS = 30_000;
13
+
14
+ export function getOpenapiSource(port: number) {
15
+ return `http://127.0.0.1:${port}/swagger/json?version=${OPENAPI_VERSION}`;
16
+ }
17
+
18
+ export function getOpenapiGeneratorCommand(args: string[]) {
19
+ if (args.some(arg => /^--projectPath(?:=|$)/.test(arg))) {
20
+ throw new Error(
21
+ 'Isolated OpenAPI generation uses the current Zova project; projectPath overrides are unsupported',
22
+ );
23
+ }
24
+ return {
25
+ command: process.execPath,
26
+ args: [
27
+ './zova/packages-cli/cli/src/bin/zova.ts',
28
+ '--projectPath=zova',
29
+ ':openapi:generate',
30
+ ...args,
31
+ ],
32
+ cwd: ROOT_DIR,
33
+ };
34
+ }
35
+
36
+ export async function validateOpenapiSource(source: string) {
37
+ let response: Response;
38
+ try {
39
+ response = await fetch(source, {
40
+ signal: AbortSignal.timeout(SOURCE_TIMEOUT_MS),
41
+ redirect: 'error',
42
+ });
43
+ } catch (error) {
44
+ throw new Error('Isolated OpenAPI source request failed', { cause: error });
45
+ }
46
+ if (!response.ok) {
47
+ throw new Error(`Isolated OpenAPI source returned HTTP ${response.status}`);
48
+ }
49
+ let document: unknown;
50
+ try {
51
+ document = await response.json();
52
+ } catch (error) {
53
+ throw new Error('Isolated OpenAPI source returned invalid JSON', { cause: error });
54
+ }
55
+ if (!document || typeof document !== 'object' || Array.isArray(document)) {
56
+ throw new Error('Isolated OpenAPI source returned an invalid document');
57
+ }
58
+ const record = document as Record<string, unknown>;
59
+ if (
60
+ (typeof record.openapi !== 'string' && typeof record.swagger !== 'string') ||
61
+ !record.paths ||
62
+ typeof record.paths !== 'object' ||
63
+ Array.isArray(record.paths)
64
+ ) {
65
+ throw new Error('Isolated OpenAPI source returned an invalid document');
66
+ }
67
+ }
68
+
69
+ export async function generateOpenapiIsolated(args: string[]) {
70
+ const command = getOpenapiGeneratorCommand(args);
71
+ if (process.env.E2E_BASE_URL !== undefined) {
72
+ throw new Error(
73
+ 'E2E_BASE_URL is unsupported; isolated OpenAPI generation manages a local test-mode target.',
74
+ );
75
+ }
76
+ return await withIsolatedRun('contract', async ({ env, port }) => {
77
+ const source = getOpenapiSource(port);
78
+ await withIsolatedVona(port, env, async () => {
79
+ await validateOpenapiSource(source);
80
+ await runIsolatedProcess(command.command, command.args, command.cwd, {
81
+ ...env,
82
+ OPENAPI_SOURCE_URL: source,
83
+ VONA_TEST_READY_KEY: undefined,
84
+ });
85
+ });
86
+ });
87
+ }
88
+
89
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
90
+ await generateOpenapiIsolated(process.argv.slice(2));
91
+ }
@@ -38,6 +38,7 @@ import {
38
38
  runIsolatedTest,
39
39
  verifyReadyProof,
40
40
  waitForReady,
41
+ withIsolatedRun,
41
42
  } from './runIsolatedTest.ts';
42
43
 
43
44
  const isolationSource = resolve(
@@ -53,6 +54,10 @@ test('public test scripts delegate to the isolated runner without recursion', ()
53
54
  const working = JSON.parse(readFileSync(resolve(ROOT_DIR, 'vona/package.json'), 'utf8'));
54
55
  assert.equal(root.scripts.test, 'cd vona && npm run test');
55
56
  assert.equal(root.scripts['test:e2e'], 'node scripts/runIsolatedTest.ts e2e');
57
+ assert.equal(
58
+ root.scripts['contract:generate:isolated'],
59
+ 'node scripts/generateOpenapiIsolated.ts',
60
+ );
56
61
  assert.equal(original.scripts.test, 'node ../scripts/runIsolatedTest.ts unit');
57
62
  assert.equal(original.scripts.cov, 'node ../scripts/runIsolatedTest.ts unit --coverage');
58
63
  assert.equal(working.scripts.test, original.scripts.test);
@@ -143,6 +148,91 @@ test('creates exclusive manifest claims and refuses stale or symlinked roots', a
143
148
  }
144
149
  });
145
150
 
151
+ test('contract runs clean owned roots on success and retain failed-run evidence', async () => {
152
+ const root = mkdtempSync(join(realpathSync(tmpdir()), 'cabloy-contract-run-'));
153
+ mkdirSync(join(root, 'vona'));
154
+ writeFileSync(join(root, '__CABLOY_BASIC__'), '');
155
+ let claimPath = '';
156
+ let dataRoot = '';
157
+ let generatedRoot = '';
158
+ try {
159
+ assert.equal(
160
+ await withIsolatedRun(
161
+ 'contract',
162
+ async context => {
163
+ ({ claimPath, dataRoot, generatedRoot } = context);
164
+ assert.equal((await loadTestClaim(claimPath)).kind, 'contract');
165
+ assert.equal(context.env.META_MODE, 'test');
166
+ assert.equal(context.env.VONA_DB_RESET, undefined);
167
+ assert.equal(context.env.VONA_TEST_READY_KEY, undefined);
168
+ assert.ok(existsSync(join(dataRoot, '.vona-test-owner')));
169
+ return 'generated';
170
+ },
171
+ root,
172
+ ),
173
+ 'generated',
174
+ );
175
+ assert.equal(existsSync(claimPath), false);
176
+ assert.equal(existsSync(dataRoot), false);
177
+ assert.equal(existsSync(generatedRoot), false);
178
+ await assert.rejects(
179
+ withIsolatedRun(
180
+ 'contract',
181
+ async context => {
182
+ ({ claimPath, dataRoot, generatedRoot } = context);
183
+ throw new Error('generator failed');
184
+ },
185
+ root,
186
+ ),
187
+ /generator failed/,
188
+ );
189
+ assert.equal((await loadTestClaim(claimPath)).status, 'failed');
190
+ assert.equal(existsSync(dataRoot), true);
191
+ assert.equal(existsSync(generatedRoot), true);
192
+ } finally {
193
+ rmSync(root, { recursive: true, force: true });
194
+ }
195
+ });
196
+
197
+ test('readiness retries signed not-ready responses and rejects server exit or timeout', async () => {
198
+ const key = 'a'.repeat(64);
199
+ let requests = 0;
200
+ const listener = createServer((req, res) => {
201
+ requests++;
202
+ res.writeHead(requests === 1 ? 503 : 200, {
203
+ 'content-type': 'application/json',
204
+ 'x-vona-test-proof': createHmac('sha256', key)
205
+ .update(String(req.headers['x-vona-test-challenge']))
206
+ .digest('hex'),
207
+ });
208
+ res.end(JSON.stringify({ status: requests === 1 ? 'starting' : 'ok' }));
209
+ });
210
+ await new Promise<void>(resolve => listener.listen(0, '127.0.0.1', resolve));
211
+ const address = listener.address();
212
+ assert.ok(address && typeof address !== 'string');
213
+ const pending = { wait: () => new Promise(() => {}), stop: async () => undefined } as never;
214
+ try {
215
+ await waitForReady(address.port, pending, key, 2000);
216
+ assert.equal(requests, 2);
217
+ await assert.rejects(waitForReady(address.port, pending, key, 0), /did not become ready/);
218
+ await assert.rejects(
219
+ waitForReady(
220
+ address.port,
221
+ {
222
+ wait: async () => ({ type: 'drained', failed: true }),
223
+ stop: async () => ({ type: 'drained', failed: true }),
224
+ },
225
+ key,
226
+ ),
227
+ /exited before readiness/,
228
+ );
229
+ } finally {
230
+ await new Promise<void>((resolve, reject) =>
231
+ listener.close(error => (error ? reject(error) : resolve())),
232
+ );
233
+ }
234
+ });
235
+
146
236
  test('allocates a loopback listener without a persistent reservation', async () => {
147
237
  const first = await allocateTestPort();
148
238
  assert.ok(first > 0 && first <= 65535);
@@ -44,7 +44,7 @@ export function createTestRunId(date = new Date()) {
44
44
  export async function reserveTestRun(
45
45
  vonaDir: string,
46
46
  runId: string,
47
- kind: 'unit' | 'cov' | 'e2e' = 'unit',
47
+ kind: 'unit' | 'cov' | 'e2e' | 'contract' = 'unit',
48
48
  ) {
49
49
  const identity = await inspectTestProcess(process.pid);
50
50
  const { claimPath } = await createTestClaim(vonaDir, runId, kind, {
@@ -75,18 +75,18 @@ function toError(value: unknown) {
75
75
  return value instanceof Error ? value : new Error(String(value));
76
76
  }
77
77
 
78
- interface GuardianResult {
78
+ export interface GuardianResult {
79
79
  type: 'drained';
80
80
  result?: { code: number | null; signal: NodeJS.Signals | null };
81
81
  failed: boolean;
82
82
  }
83
83
 
84
- interface OwnedProcess {
84
+ export interface OwnedProcess {
85
85
  wait: () => Promise<GuardianResult>;
86
86
  stop: () => Promise<GuardianResult>;
87
87
  }
88
88
 
89
- async function startOwnedProcess(
89
+ export async function startOwnedProcess(
90
90
  command: string,
91
91
  args: string[],
92
92
  cwd: string,
@@ -234,6 +234,89 @@ async function startOwnedProcess(
234
234
  return { wait, stop };
235
235
  }
236
236
 
237
+ export function getIsolatedVonaCommand(port: number, env: NodeJS.ProcessEnv, readyKey: string) {
238
+ return {
239
+ command: process.execPath,
240
+ args: [
241
+ './packages-cli/cli/src/bin/vona.ts',
242
+ ':bin:dev',
243
+ '--workers=1',
244
+ '--flavor=normal',
245
+ '--mode=test',
246
+ ],
247
+ cwd: resolve(ROOT_DIR, 'vona'),
248
+ env: {
249
+ ...env,
250
+ VONA_TEST_READY_KEY: readyKey,
251
+ VONA_TEST_SERVER_ORIGIN: 'true',
252
+ SERVER_SERVE_PROTOCOL: 'http',
253
+ SERVER_SERVE_HOST: `127.0.0.1:${port}`,
254
+ },
255
+ };
256
+ }
257
+
258
+ export async function withIsolatedVona<T>(
259
+ port: number,
260
+ env: NodeJS.ProcessEnv,
261
+ callback: () => Promise<T>,
262
+ dependencies = { start: startOwnedProcess, ready: waitForReady },
263
+ ): Promise<T> {
264
+ const readyKey = randomBytes(32).toString('hex');
265
+ const command = getIsolatedVonaCommand(port, env, readyKey);
266
+ const server = await dependencies.start(command.command, command.args, command.cwd, command.env);
267
+ let stopping = false;
268
+ let exitedEarly = false;
269
+ void server.wait().then(
270
+ () => {
271
+ if (!stopping) exitedEarly = true;
272
+ },
273
+ () => {
274
+ if (!stopping) exitedEarly = true;
275
+ },
276
+ );
277
+ let interrupted = false;
278
+ const onInterrupt = () => {
279
+ interrupted = true;
280
+ void server.stop().catch(console.error);
281
+ };
282
+ process.on('SIGINT', onInterrupt);
283
+ process.on('SIGTERM', onInterrupt);
284
+ let operationError: Error | undefined;
285
+ let shutdownError: Error | undefined;
286
+ let result: T | undefined;
287
+ try {
288
+ await dependencies.ready(port, server, readyKey);
289
+ if (interrupted) throw new Error('Isolated Vona run interrupted');
290
+ result = await callback();
291
+ } catch (error) {
292
+ operationError = toError(error);
293
+ } finally {
294
+ stopping = true;
295
+ try {
296
+ const stopped = await server.stop();
297
+ if (stopped.failed || interrupted || exitedEarly || stopped.result?.code !== 0) {
298
+ shutdownError = new Error(
299
+ 'Isolated Vona server did not stop cleanly, exited early, or run was interrupted',
300
+ );
301
+ }
302
+ } catch (error) {
303
+ shutdownError = toError(error);
304
+ } finally {
305
+ process.off('SIGINT', onInterrupt);
306
+ process.off('SIGTERM', onInterrupt);
307
+ }
308
+ }
309
+ if (operationError && shutdownError) {
310
+ throw new AggregateError(
311
+ [operationError, shutdownError],
312
+ 'Isolated Vona operation and shutdown failed',
313
+ );
314
+ }
315
+ if (shutdownError) throw toError(shutdownError);
316
+ if (operationError) throw toError(operationError);
317
+ return result as T;
318
+ }
319
+
237
320
  export async function runIsolatedProcess(
238
321
  command: string,
239
322
  args: string[],
@@ -265,11 +348,16 @@ export function verifyReadyProof(challenge: string, key: string, proof: string |
265
348
  return timingSafeEqual(expected, Buffer.from(proof, 'hex'));
266
349
  }
267
350
 
268
- export async function waitForReady(port: number, server: OwnedProcess, key: string) {
269
- const deadline = Date.now() + 180_000;
351
+ export async function waitForReady(
352
+ port: number,
353
+ server: OwnedProcess,
354
+ key: string,
355
+ timeoutMs = 180_000,
356
+ ) {
357
+ const deadline = Date.now() + timeoutMs;
270
358
  const url = `http://127.0.0.1:${port}/health/ready`;
271
359
  const exited = server.wait().then(() => {
272
- throw new Error('Isolated E2E server exited before readiness');
360
+ throw new Error('Isolated Vona server exited before readiness');
273
361
  });
274
362
  while (Date.now() < deadline) {
275
363
  const challenge = randomBytes(16).toString('hex');
@@ -288,12 +376,12 @@ export async function waitForReady(port: number, server: OwnedProcess, key: stri
288
376
  continue;
289
377
  }
290
378
  if (!verifyReadyProof(challenge, key, response.headers.get('x-vona-test-proof'))) {
291
- throw new Error('Isolated E2E listener does not belong to this test run');
379
+ throw new Error('Isolated Vona listener does not belong to this test run');
292
380
  }
293
381
  if (response.ok && ((await response.json()) as { status?: string }).status === 'ok') return;
294
382
  await Promise.race([new Promise(resolvePromise => setTimeout(resolvePromise, 200)), exited]);
295
383
  }
296
- throw new Error('Isolated E2E server did not become ready with a valid ownership proof');
384
+ throw new Error('Isolated Vona server did not become ready with a valid ownership proof');
297
385
  }
298
386
 
299
387
  export function getUnitTestCommands(args: string[]) {
@@ -326,14 +414,24 @@ export function resolveCabloyEdition(rootDir = ROOT_DIR): 'basic' | 'start' {
326
414
  return hasBasic ? 'basic' : 'start';
327
415
  }
328
416
 
329
- export async function runIsolatedTest(kind: 'unit' | 'e2e', args: string[]) {
330
- resolveCabloyEdition();
331
- if (process.env.E2E_BASE_URL !== undefined) {
332
- throw new Error(
333
- 'E2E_BASE_URL is unsupported; the E2E runner manages a local test-mode target.',
334
- );
335
- }
336
- const vonaDir = resolve(ROOT_DIR, 'vona');
417
+ export interface IsolatedRunContext {
418
+ runId: string;
419
+ vonaDir: string;
420
+ dataRoot: string;
421
+ generatedRoot: string;
422
+ port: number;
423
+ claimPath: string;
424
+ broker: Awaited<ReturnType<typeof startTestClaimBroker>>;
425
+ env: NodeJS.ProcessEnv;
426
+ }
427
+
428
+ export async function withIsolatedRun<T>(
429
+ kind: 'unit' | 'cov' | 'e2e' | 'contract',
430
+ callback: (context: IsolatedRunContext) => Promise<T>,
431
+ rootDir = ROOT_DIR,
432
+ ): Promise<T> {
433
+ resolveCabloyEdition(rootDir);
434
+ const vonaDir = resolve(rootDir, 'vona');
337
435
  await reapExpiredTestClaims(vonaDir, {
338
436
  ttlSeconds: parseTestClaimTtlSeconds(),
339
437
  now: Date.now(),
@@ -344,13 +442,10 @@ export async function runIsolatedTest(kind: 'unit' | 'e2e', args: string[]) {
344
442
  const dataRoot = resolve(vonaDir, `.app-test-${runId}`);
345
443
  const generatedRoot = resolve(vonaDir, `.vona-test-${runId}`);
346
444
  const port = await allocateTestPort();
347
- const claimPath = await reserveTestRun(
348
- vonaDir,
349
- runId,
350
- kind === 'unit' && args.includes('--coverage') ? 'cov' : kind,
351
- );
445
+ const claimPath = await reserveTestRun(vonaDir, runId, kind);
352
446
  let broker: Awaited<ReturnType<typeof startTestClaimBroker>> | undefined;
353
447
  let completed = false;
448
+ let result: T | undefined;
354
449
  try {
355
450
  broker = await startTestClaimBroker(claimPath);
356
451
  createOwned(dataRoot, runId);
@@ -374,64 +469,16 @@ export async function runIsolatedTest(kind: 'unit' | 'e2e', args: string[]) {
374
469
  TMPDIR: resolve(dataRoot, 'tmp'),
375
470
  };
376
471
  await broker.update({ type: 'status', status: 'running' });
377
- if (kind === 'unit') {
378
- for (const { command, args: commandArgs, cwd } of getUnitTestCommands(args)) {
379
- await runIsolatedProcess(command, commandArgs, cwd, env);
380
- }
381
- } else {
382
- let e2eError: unknown;
383
- const readyKey = randomBytes(32).toString('hex');
384
- const server = await startOwnedProcess(
385
- process.execPath,
386
- [
387
- './packages-cli/cli/src/bin/vona.ts',
388
- ':bin:dev',
389
- '--workers=1',
390
- '--flavor=normal',
391
- '--mode=test',
392
- ],
393
- resolve(ROOT_DIR, 'vona'),
394
- {
395
- ...env,
396
- VONA_TEST_READY_KEY: readyKey,
397
- VONA_TEST_SERVER_ORIGIN: 'true',
398
- SERVER_SERVE_PROTOCOL: 'http',
399
- SERVER_SERVE_HOST: `127.0.0.1:${port}`,
400
- },
401
- );
402
- let interrupted = false;
403
- let serverError: unknown;
404
- const onInterrupt = () => {
405
- interrupted = true;
406
- void server.stop().catch(console.error);
407
- };
408
- process.on('SIGINT', onInterrupt);
409
- process.on('SIGTERM', onInterrupt);
410
- try {
411
- await waitForReady(port, server, readyKey);
412
- await runIsolatedProcess(
413
- process.execPath,
414
- ['repo-e2e/scripts/runE2e.ts', ...args],
415
- ROOT_DIR,
416
- env,
417
- );
418
- } catch (error) {
419
- e2eError = error;
420
- } finally {
421
- process.off('SIGINT', onInterrupt);
422
- process.off('SIGTERM', onInterrupt);
423
- try {
424
- const result = await server.stop();
425
- if (result.failed || interrupted) {
426
- serverError = new Error('Isolated E2E server did not stop cleanly');
427
- }
428
- } catch (error) {
429
- serverError = error;
430
- }
431
- }
432
- if (serverError) throw toError(serverError);
433
- if (e2eError) throw toError(e2eError);
434
- }
472
+ result = await callback({
473
+ runId,
474
+ vonaDir,
475
+ dataRoot,
476
+ generatedRoot,
477
+ port,
478
+ claimPath,
479
+ broker,
480
+ env,
481
+ });
435
482
  completed = true;
436
483
  } catch (error) {
437
484
  await broker?.update({ type: 'status', status: 'failed' }).catch(() => {
@@ -444,15 +491,43 @@ export async function runIsolatedTest(kind: 'unit' | 'e2e', args: string[]) {
444
491
  }
445
492
  await broker?.close();
446
493
  }
447
- const result = await cleanTestClaim(claimPath, {
494
+ const cleanup = await cleanTestClaim(claimPath, {
448
495
  projectPath: vonaDir,
449
496
  isInactive: claim => isTestClaimInactive(claim, true),
450
497
  warn: message => console.error(message),
451
498
  });
452
- if (result.status !== 'cleaned') {
499
+ if (cleanup.status !== 'cleaned') {
453
500
  console.error(`Isolated test claim retained for inspection: ${claimPath}`);
454
501
  throw new Error('Isolated test succeeded but resource cleanup did not complete');
455
502
  }
503
+ return result as T;
504
+ }
505
+
506
+ export async function runIsolatedTest(kind: 'unit' | 'e2e', args: string[]) {
507
+ if (process.env.E2E_BASE_URL !== undefined) {
508
+ throw new Error(
509
+ 'E2E_BASE_URL is unsupported; the E2E runner manages a local test-mode target.',
510
+ );
511
+ }
512
+ await withIsolatedRun(
513
+ kind === 'unit' && args.includes('--coverage') ? 'cov' : kind,
514
+ async ({ env, port }) => {
515
+ if (kind === 'unit') {
516
+ for (const { command, args: commandArgs, cwd } of getUnitTestCommands(args)) {
517
+ await runIsolatedProcess(command, commandArgs, cwd, env);
518
+ }
519
+ return;
520
+ }
521
+ await withIsolatedVona(port, env, () =>
522
+ runIsolatedProcess(
523
+ process.execPath,
524
+ ['repo-e2e/scripts/runE2e.ts', ...args],
525
+ ROOT_DIR,
526
+ env,
527
+ ),
528
+ );
529
+ },
530
+ );
456
531
  }
457
532
 
458
533
  if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
@@ -1,4 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { dirname, resolve } from 'node:path';
@@ -128,6 +129,118 @@ test('isolated dry-run reports changes without writing files or contacting npm',
128
129
  }
129
130
  });
130
131
 
132
+ const isolatedContractCommand = 'node scripts/generateOpenapiIsolated.ts';
133
+
134
+ function reconcileContract(project: string, source: string, dryRun = false): string {
135
+ const result = spawnSync(
136
+ process.execPath,
137
+ [
138
+ '--input-type=module',
139
+ '--eval',
140
+ `import { reconcileContractPackageJson } from ${JSON.stringify(new URL('./upgrade.ts', import.meta.url).href)};
141
+ reconcileContractPackageJson(${dryRun}, ${JSON.stringify(project)}, ${JSON.stringify(source)});`,
142
+ ],
143
+ { encoding: 'utf8' },
144
+ );
145
+ const output = `${result.stdout}${result.stderr}`;
146
+ assert.equal(result.status, 0, output);
147
+ return output;
148
+ }
149
+
150
+ function addIncomingContractCommand(source: string): void {
151
+ const pkg = readPackage(source);
152
+ pkg.scripts['contract:generate:isolated'] = isolatedContractCommand;
153
+ writeFile(source, 'package.json', `${JSON.stringify(pkg)}\n`);
154
+ }
155
+
156
+ test('adopts the missing isolated contract command without changing other package entries', () => {
157
+ const { project, source, dispose } = fixture();
158
+ try {
159
+ addIncomingContractCommand(source);
160
+ const before = readPackage(project);
161
+ assert.match(reconcileContract(project, source), /Added missing.*contract:generate:isolated/);
162
+ assert.deepEqual(readPackage(project), {
163
+ ...before,
164
+ scripts: { ...before.scripts, 'contract:generate:isolated': isolatedContractCommand },
165
+ });
166
+ const first = readFileSync(resolve(project, 'package.json'), 'utf-8');
167
+ assert.equal(reconcileContract(project, source), '');
168
+ assert.equal(readFileSync(resolve(project, 'package.json'), 'utf-8'), first);
169
+ } finally {
170
+ dispose();
171
+ }
172
+ });
173
+
174
+ test('isolated contract reconciliation creates a missing scripts object', () => {
175
+ const { project, source, dispose } = fixture();
176
+ try {
177
+ addIncomingContractCommand(source);
178
+ writeFile(project, 'package.json', '{"name":"project-owned"}\n');
179
+ reconcileContract(project, source);
180
+ assert.deepEqual(readPackage(project), {
181
+ name: 'project-owned',
182
+ scripts: { 'contract:generate:isolated': isolatedContractCommand },
183
+ });
184
+ } finally {
185
+ dispose();
186
+ }
187
+ });
188
+
189
+ test('isolated contract reconciliation visibly preserves customized values, including empty commands', () => {
190
+ const { project, source, dispose } = fixture();
191
+ try {
192
+ addIncomingContractCommand(source);
193
+ for (const command of ['node project-contract.ts', '']) {
194
+ const pkg = readPackage(project);
195
+ pkg.scripts['contract:generate:isolated'] = command;
196
+ const before = `${JSON.stringify(pkg)}\n`;
197
+ writeFile(project, 'package.json', before);
198
+ for (const dryRun of [false, true]) {
199
+ const output = reconcileContract(project, source, dryRun);
200
+ assert.match(output, /Preserved customized.*contract:generate:isolated/);
201
+ assert.ok(output.includes(isolatedContractCommand), output);
202
+ assert.equal(readFileSync(resolve(project, 'package.json'), 'utf-8'), before);
203
+ }
204
+ }
205
+ } finally {
206
+ dispose();
207
+ }
208
+ });
209
+
210
+ test('isolated contract dry-run reports adoption without writing and matching commands are a byte-preserving no-op', () => {
211
+ const { project, source, dispose } = fixture();
212
+ try {
213
+ addIncomingContractCommand(source);
214
+ const before = readFileSync(resolve(project, 'package.json'), 'utf-8');
215
+ assert.match(
216
+ reconcileContract(project, source, true),
217
+ /\[dry-run\] Add missing.*contract:generate:isolated/,
218
+ );
219
+ assert.equal(readFileSync(resolve(project, 'package.json'), 'utf-8'), before);
220
+
221
+ const pkg = readPackage(project);
222
+ pkg.scripts['contract:generate:isolated'] = isolatedContractCommand;
223
+ const matching = `${JSON.stringify(pkg)}\n`;
224
+ writeFile(project, 'package.json', matching);
225
+ assert.equal(reconcileContract(project, source), '');
226
+ assert.equal(reconcileContract(project, source, true), '');
227
+ assert.equal(readFileSync(resolve(project, 'package.json'), 'utf-8'), matching);
228
+ } finally {
229
+ dispose();
230
+ }
231
+ });
232
+
233
+ test('isolated contract reconciliation tolerates an older incoming package without the command', () => {
234
+ const { project, source, dispose } = fixture();
235
+ try {
236
+ const before = readFileSync(resolve(project, 'package.json'), 'utf-8');
237
+ assert.equal(reconcileContract(project, source), '');
238
+ assert.equal(readFileSync(resolve(project, 'package.json'), 'utf-8'), before);
239
+ } finally {
240
+ dispose();
241
+ }
242
+ });
243
+
131
244
  test('Start edition is not reconciled by the Basic framework E2E upgrader', () => {
132
245
  const { project, source, dispose } = fixture();
133
246
  try {
@@ -322,6 +322,34 @@ function reconcileGovernancePackageJson(dryRun?: boolean): void {
322
322
  }
323
323
  }
324
324
 
325
+ export function reconcileContractPackageJson(
326
+ dryRun?: boolean,
327
+ rootDir = ROOT_DIR,
328
+ tempDir = TEMP_DIR,
329
+ ): void {
330
+ const name = 'contract:generate:isolated';
331
+ const sourcePackage = readPackageJson(resolve(tempDir, 'package.json'));
332
+ const command = sourcePackage.scripts?.[name];
333
+ if (!command) return;
334
+
335
+ const projectPackagePath = resolve(rootDir, 'package.json');
336
+ const projectPackage = readPackageJson(projectPackagePath);
337
+ if (Object.hasOwn(projectPackage.scripts ?? {}, name)) {
338
+ if (projectPackage.scripts?.[name] !== command) {
339
+ log(` Preserved customized package.json scripts.${name}; incoming command: ${command}`);
340
+ }
341
+ return;
342
+ }
343
+ if (dryRun) {
344
+ log(` [dry-run] Add missing package.json scripts.${name} = ${command}`);
345
+ return;
346
+ }
347
+ projectPackage.scripts ??= {};
348
+ projectPackage.scripts[name] = command;
349
+ writeFileSync(projectPackagePath, `${JSON.stringify(projectPackage, null, 2)}\n`);
350
+ log(` Added missing package.json scripts.${name}`);
351
+ }
352
+
325
353
  function reconcileGovernanceAssets(dryRun?: boolean): void {
326
354
  const command = 'node repo-agent-governance/scripts/governance.mjs adopt --apply';
327
355
  if (dryRun) {
@@ -615,6 +643,10 @@ async function main(): Promise<void> {
615
643
  reconcileGovernanceAssets(dryRun);
616
644
  log('');
617
645
 
646
+ log('Reconciling missing contract-generation package entries...');
647
+ reconcileContractPackageJson(dryRun);
648
+ log('');
649
+
618
650
  // 5. Reconcile Cabloy Basic framework E2E manifest entries
619
651
  if (isCabloyBasic()) {
620
652
  log('Reconciling framework-owned E2E package entries...');