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.
- package/.cabloy-version +1 -1
- package/CHANGELOG.md +7 -0
- package/package.json +2 -1
- package/repo-agent-governance/managed-assets.json +6 -6
- package/repo-agent-governance/skills/cabloy-contract-loop/SKILL.md +14 -8
- package/repo-agent-governance/skills/cabloy-contract-loop/references/verification-checklist.md +4 -1
- package/repo-agent-governance/tests/governance.test.mjs +65 -0
- package/repo-docs/ai/playbook-contract-regeneration.md +2 -0
- package/repo-docs/frontend/openapi-sdk-guide.md +23 -4
- package/repo-docs/fullstack/contract-loop-playbook.md +8 -0
- package/repo-docs/fullstack/openapi-to-sdk.md +12 -6
- package/repo-docs/reference/repo-scripts.md +3 -0
- package/scripts/bootstrapAgentGovernance.mjs +18 -0
- package/scripts/generateOpenapiIsolated.test.ts +207 -0
- package/scripts/generateOpenapiIsolated.ts +91 -0
- package/scripts/runIsolatedTest.test.ts +90 -0
- package/scripts/runIsolatedTest.ts +157 -82
- package/scripts/upgrade.test.ts +113 -0
- package/scripts/upgrade.ts +32 -0
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +8 -1
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +2 -2
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/openapi.config.test.ts +29 -0
- package/zova/openapi.config.ts +13 -4
- package/zova/packages-cli/cli/package.json +2 -2
- package/zova/packages-cli/cli-set-front/cli/templates/openapi/config/boilerplate/project/openapi.config.ts +13 -4
- package/zova/packages-cli/cli-set-front/package.json +1 -1
- package/zova/packages-zova/zova/package.json +2 -2
- package/zova/src/suite/a-training/modules/training-student/src/.metadata/index.ts +7 -7
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +12 -0
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +173 -72
- package/zova/src/suite/a-training/modules/training-student/src/api/trainingStudent.ts +8 -1
- package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/schemaRefresh.ts +21 -12
- package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefreshLifecycle.test.ts +112 -0
- 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(
|
|
269
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
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 (
|
|
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])) {
|
package/scripts/upgrade.test.ts
CHANGED
|
@@ -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 {
|
package/scripts/upgrade.ts
CHANGED
|
@@ -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...');
|