cabloy 5.1.215 → 5.1.217
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/.github/workflows/playwright-e2e.yml +163 -13
- package/.github/workflows/vona-test-sqlite3.yml +77 -7
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +4 -2
- package/package.json +1 -1
- package/repo-docs/frontend/scripts.md +2 -0
- package/repo-docs/reference/repo-scripts.md +27 -1
- package/repo-e2e/scripts/e2e.test.ts +1 -1
- package/repo-e2e/scripts/managedTest.test.ts +115 -3
- package/repo-e2e/scripts/managedTest.ts +69 -20
- package/repo-e2e/scripts/managedTestImports.test.ts +55 -5
- package/repo-e2e/scripts/runE2e.test.ts +109 -0
- package/repo-e2e/scripts/runE2e.ts +58 -39
- package/repo-e2e/scripts/runE2eArgs.test.ts +16 -1
- package/repo-e2e/scripts/runE2eArgs.ts +5 -0
- package/repo-e2e/specs/a-commerce.spec.ts +10 -2
- package/repo-e2e/specs/home-user-account.spec.ts +4 -0
- package/repo-e2e/specs/markdown.spec.ts +14 -3
- package/scripts/generateOpenapiIsolated.test.ts +5 -1
- package/scripts/runIsolatedTest.test.ts +403 -36
- package/scripts/runIsolatedTest.ts +139 -12
- package/scripts/testBrowserGuardianWindows.test.ts +327 -0
- package/scripts/testClaimCleanup.test.ts +25 -9
- package/scripts/testClaimCleanup.ts +14 -45
- package/scripts/testClaimProcesses.test.ts +81 -2
- package/scripts/testClaimProcesses.ts +46 -10
- package/scripts/testProcessGuardianWindows.test.ts +602 -0
- package/scripts/testProcessGuardianWindows.ts +155 -0
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.build.ts +4 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.masterDetail.ts +4 -4
- package/vona/packages-cli/cli-set-api/test/binBuildEntries.test.ts +159 -0
- 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 +298 -17
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +205 -97
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.test.ts +247 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.ts +224 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.cs +229 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ps1 +20 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ts +200 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.cs +336 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ps1 +20 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ts +531 -0
- package/vona/packages-vona/vona-core/src/lib/framework/bootstrap.test.ts +61 -0
- package/vona/packages-vona/vona-core/src/lib/framework/index.ts +1 -0
- package/vona/packages-vona/vona-core/src/lib/framework/processWorker.test.ts +59 -0
- package/vona/packages-vona/vona-core/src/lib/framework/processWorker.ts +8 -5
- package/vona/packages-vona/vona-core/src/lib/framework/useApp.test.ts +84 -0
- package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +2 -2
- package/vona/packages-vona/vona-core/src/lib/utils/util.ts +1 -1
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +22 -22
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/bean/bean.executor.ts +2 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +16 -0
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +6 -3
- package/vona/src/suite-vendor/a-vona/modules/a-logger/test/middlewareSystem.httpLog.test.ts +80 -0
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +73 -2
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/test/middlewareSystem.health.test.ts +309 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transactionAsyncLocalStorage_.ts +3 -3
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-static/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-static/src/bean/middlewareSystem.static.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-static/test/middlewareSystem.static.test.ts +101 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/packages-cli/cli/package.json +2 -2
- package/zova/packages-cli/cli-set-front/package.json +2 -2
- package/zova/packages-utils/zova-vite/package.json +1 -1
- package/zova/packages-utils/zova-vite/src/configUtils.ts +1 -1
- package/zova/packages-utils/zova-vite/test/configUtils.test.ts +55 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +8 -4
- package/zova/src/suite/a-commerce/modules/commerce-trade/test/lib/cartModel.test.ts +71 -0
- /package/vona/packages-cli/cli-set-api/cli/templates/tools/masterDetail/boilerplate/dto/{<%=argv.detailDtoBaseName%>.tsx_ → {{detailDtoBaseName}}.tsx_} +0 -0
- /package/vona/packages-cli/cli-set-api/cli/templates/tools/masterDetail/boilerplate/dto/{<%=argv.detailDtoMutateName%>.tsx_ → {{detailDtoMutateName}}.tsx_} +0 -0
- /package/vona/packages-cli/cli-set-api/cli/templates/tools/masterDetail/boilerplate/dto/{<%=argv.detailDtoResItemName%>.tsx_ → {{detailDtoResItemName}}.tsx_} +0 -0
- /package/vona/packages-cli/cli-set-api/cli/templates/tools/masterDetail/boilerplate/dto/{<%=argv.detailDtoViewName%>.tsx_ → {{detailDtoViewName}}.tsx_} +0 -0
|
@@ -15,6 +15,7 @@ import { createServer } from 'node:http';
|
|
|
15
15
|
import { tmpdir } from 'node:os';
|
|
16
16
|
import { join, resolve } from 'node:path';
|
|
17
17
|
import { test } from 'node:test';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
18
19
|
|
|
19
20
|
import type { VonaApplication } from '../vona/packages-vona/vona-core/src/lib/core/application.ts';
|
|
20
21
|
|
|
@@ -32,10 +33,14 @@ import {
|
|
|
32
33
|
createTestRunId,
|
|
33
34
|
getUnitTestCommands,
|
|
34
35
|
reserveTestRun,
|
|
36
|
+
resolveNpmCli,
|
|
35
37
|
resolveCabloyEdition,
|
|
36
38
|
ROOT_DIR,
|
|
37
39
|
runIsolatedProcess,
|
|
38
40
|
runIsolatedTest,
|
|
41
|
+
startOwnedProcess,
|
|
42
|
+
requestIsolatedVonaStop,
|
|
43
|
+
withIsolatedVona,
|
|
39
44
|
verifyReadyProof,
|
|
40
45
|
waitForReady,
|
|
41
46
|
withIsolatedRun,
|
|
@@ -65,7 +70,11 @@ test('public test scripts delegate to the isolated runner without recursion', ()
|
|
|
65
70
|
|
|
66
71
|
const commands = getUnitTestCommands(['--coverage', '--ci', 'src/test/example.test.ts']);
|
|
67
72
|
assert.deepEqual(commands, [
|
|
68
|
-
{
|
|
73
|
+
{
|
|
74
|
+
command: process.execPath,
|
|
75
|
+
args: [resolveNpmCli(), 'run', 'prerun'],
|
|
76
|
+
cwd: resolve(ROOT_DIR, 'vona'),
|
|
77
|
+
},
|
|
69
78
|
{
|
|
70
79
|
command: process.execPath,
|
|
71
80
|
args: [
|
|
@@ -85,6 +94,72 @@ test('public test scripts delegate to the isolated runner without recursion', ()
|
|
|
85
94
|
]);
|
|
86
95
|
});
|
|
87
96
|
|
|
97
|
+
test('resolves the npm JavaScript entrypoint without a command shell', () => {
|
|
98
|
+
const root = mkdtempSync(join(realpathSync(tmpdir()), 'cabloy npm argv '));
|
|
99
|
+
try {
|
|
100
|
+
const cli = join(root, 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
101
|
+
mkdirSync(join(root, 'node_modules', 'npm', 'bin'), { recursive: true });
|
|
102
|
+
writeFileSync(cli, '');
|
|
103
|
+
const executable = join(root, process.platform === 'win32' ? 'node.exe' : 'node');
|
|
104
|
+
assert.equal(resolveNpmCli({}, executable), realpathSync(cli));
|
|
105
|
+
assert.equal(resolveNpmCli({ npm_execpath: cli }, '/missing/node'), realpathSync(cli));
|
|
106
|
+
assert.throws(
|
|
107
|
+
() => resolveNpmCli({ npm_execpath: join(root, 'pnpm.cjs') }, join(root, 'missing', 'node')),
|
|
108
|
+
/Cannot resolve npm CLI/,
|
|
109
|
+
);
|
|
110
|
+
} finally {
|
|
111
|
+
rmSync(root, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test(
|
|
116
|
+
'public npm test entrypoint can be invoked from CMD and PowerShell',
|
|
117
|
+
{
|
|
118
|
+
skip: process.platform !== 'win32' ? 'Native Windows shells are required' : false,
|
|
119
|
+
},
|
|
120
|
+
() => {
|
|
121
|
+
const root = mkdtempSync(join(realpathSync(tmpdir()), 'cabloy shell entry '));
|
|
122
|
+
try {
|
|
123
|
+
writeFileSync(join(root, '__CABLOY_BASIC__'), '');
|
|
124
|
+
mkdirSync(join(root, 'vona'));
|
|
125
|
+
const entry = resolve(ROOT_DIR, 'scripts/runIsolatedTest.ts');
|
|
126
|
+
writeFileSync(
|
|
127
|
+
join(root, 'package.json'),
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
private: true,
|
|
130
|
+
scripts: { test: `node "${entry}" unit` },
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
for (const [command, args] of [
|
|
134
|
+
['cmd.exe', ['/d', '/s', '/c', 'npm run test -- --ci']],
|
|
135
|
+
[
|
|
136
|
+
'pwsh.exe',
|
|
137
|
+
[
|
|
138
|
+
'-NoLogo',
|
|
139
|
+
'-NoProfile',
|
|
140
|
+
'-NonInteractive',
|
|
141
|
+
'-Command',
|
|
142
|
+
'npm run test -- --ci; exit $LASTEXITCODE',
|
|
143
|
+
],
|
|
144
|
+
],
|
|
145
|
+
] as const) {
|
|
146
|
+
const result = spawnSync(command, [...args], {
|
|
147
|
+
cwd: root,
|
|
148
|
+
env: { ...process.env, E2E_BASE_URL: 'https://example.invalid' },
|
|
149
|
+
encoding: 'utf8',
|
|
150
|
+
timeout: 30_000,
|
|
151
|
+
});
|
|
152
|
+
assert.equal(result.error, undefined);
|
|
153
|
+
assert.notEqual(result.status, 0);
|
|
154
|
+
assert.match(result.stderr, /E2E_BASE_URL is unsupported/);
|
|
155
|
+
assert.equal(existsSync(join(root, 'vona', '.vona-test-claim')), false);
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
rmSync(root, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
88
163
|
test('accepts either Cabloy edition and rejects ambiguous or missing markers', () => {
|
|
89
164
|
const root = mkdtempSync(join(tmpdir(), 'cabloy-run-edition-'));
|
|
90
165
|
const basicMarker = join(root, '__CABLOY_BASIC__');
|
|
@@ -132,12 +207,12 @@ test('creates exclusive manifest claims and refuses stale or symlinked roots', a
|
|
|
132
207
|
assert.equal(readFileSync(join(staleRoot, 'business-data'), 'utf8'), 'keep');
|
|
133
208
|
assert.equal(existsSync(join(staleRoot, '.vona-test-owner')), false);
|
|
134
209
|
const linkedRoot = join(vonaDir, `.vona-test-${id}`);
|
|
135
|
-
symlinkSync(staleRoot, linkedRoot);
|
|
210
|
+
symlinkSync(staleRoot, linkedRoot, process.platform === 'win32' ? 'junction' : 'dir');
|
|
136
211
|
assert.throws(() => createOwned(linkedRoot, id), /already exists/);
|
|
137
212
|
assert.equal(readFileSync(join(staleRoot, 'business-data'), 'utf8'), 'keep');
|
|
138
213
|
|
|
139
214
|
const linked = join(root, 'linked');
|
|
140
|
-
symlinkSync(vonaDir, linked);
|
|
215
|
+
symlinkSync(vonaDir, linked, process.platform === 'win32' ? 'junction' : 'dir');
|
|
141
216
|
await assert.rejects(reserveTestRun(linked, '20261014010204-d4e5f6a7'), /Unsafe/);
|
|
142
217
|
assert.equal(
|
|
143
218
|
existsSync(join(vonaDir, '.vona-test-claim', '20261014010204-d4e5f6a7.json')),
|
|
@@ -294,7 +369,7 @@ test('rejects external E2E targets before creating run directories', async () =>
|
|
|
294
369
|
|
|
295
370
|
test('resolves distinct run paths and rejects invalid identity without touching dev paths', () => {
|
|
296
371
|
const script = `
|
|
297
|
-
import { resolveVonaLocalPaths, getTestAppName } from ${JSON.stringify(isolationSource)};
|
|
372
|
+
import { resolveVonaLocalPaths, getTestAppName } from ${JSON.stringify(pathToFileURL(isolationSource).href)};
|
|
298
373
|
const project = '/project/vona';
|
|
299
374
|
const a = resolveVonaLocalPaths(project, 'test', '20261014010203-a1b2c3d4');
|
|
300
375
|
const b = resolveVonaLocalPaths(project, 'test', '20261014010203-d4e5f6a7');
|
|
@@ -316,7 +391,7 @@ test('owner checks reject unowned paths and symlinked leaves', () => {
|
|
|
316
391
|
const root = mkdtempSync(join(tmpdir(), 'cabloy-test-isolation-'));
|
|
317
392
|
const id = '20261014010203-a1b2c3d4';
|
|
318
393
|
const script = `
|
|
319
|
-
import { assertTestRunOwner } from ${JSON.stringify(isolationSource)};
|
|
394
|
+
import { assertTestRunOwner } from ${JSON.stringify(pathToFileURL(isolationSource).href)};
|
|
320
395
|
const [unowned, linked] = process.argv.slice(1);
|
|
321
396
|
try { assertTestRunOwner(unowned, ${JSON.stringify(id)}); process.exit(1); } catch {}
|
|
322
397
|
try { assertTestRunOwner(linked, ${JSON.stringify(id)}); process.exit(2); } catch {}
|
|
@@ -328,7 +403,7 @@ test('owner checks reject unowned paths and symlinked leaves', () => {
|
|
|
328
403
|
const unowned = join(root, 'unowned');
|
|
329
404
|
mkdirSync(unowned);
|
|
330
405
|
const linked = join(root, 'linked');
|
|
331
|
-
symlinkSync(owned, linked);
|
|
406
|
+
symlinkSync(owned, linked, process.platform === 'win32' ? 'junction' : 'dir');
|
|
332
407
|
const result = spawnSync(
|
|
333
408
|
process.execPath,
|
|
334
409
|
['--input-type=module', '-e', script, unowned, linked],
|
|
@@ -345,13 +420,13 @@ test('owner checks reject unowned paths and symlinked leaves', () => {
|
|
|
345
420
|
});
|
|
346
421
|
|
|
347
422
|
test('waits for descendants before reporting a successful child exit', async () => {
|
|
348
|
-
if (process.platform === 'win32') return;
|
|
349
423
|
const root = mkdtempSync(join(tmpdir(), 'cabloy-process-group-'));
|
|
350
424
|
const flag = join(root, 'descendant-finished');
|
|
351
425
|
try {
|
|
352
426
|
const script = `
|
|
353
427
|
import { spawn } from 'node:child_process';
|
|
354
|
-
|
|
428
|
+
// Avoid libuv's parent-owned kill-on-close Job on Windows; the outer Job still owns this child.
|
|
429
|
+
const child = spawn(process.execPath, ['-e', 'setTimeout(() => require("node:fs").writeFileSync(process.argv[1], "done"), 400)', process.argv[1]], { stdio: 'ignore', detached: process.platform === 'win32' });
|
|
355
430
|
child.unref();
|
|
356
431
|
`;
|
|
357
432
|
await runIsolatedProcess(
|
|
@@ -367,13 +442,13 @@ test('waits for descendants before reporting a successful child exit', async ()
|
|
|
367
442
|
});
|
|
368
443
|
|
|
369
444
|
test('a guardian waits for an inherited-group descendant before success', async () => {
|
|
370
|
-
if (process.platform === 'win32') return;
|
|
371
445
|
const root = mkdtempSync(join(resolve(ROOT_DIR, 'vona'), '.e2e-group-test-'));
|
|
372
446
|
const flag = join(root, 'server-finished');
|
|
373
447
|
try {
|
|
374
448
|
const script = `
|
|
375
449
|
import { spawn } from 'node:child_process';
|
|
376
|
-
|
|
450
|
+
// Preserve the inherited POSIX group, but avoid libuv's kill-on-close Job on Windows.
|
|
451
|
+
const child = spawn(process.execPath, ['-e', 'setTimeout(() => require("node:fs").writeFileSync(process.argv[1], "done"), 400)', process.argv[1]], { stdio: 'ignore', detached: process.platform === 'win32' });
|
|
377
452
|
child.unref();
|
|
378
453
|
`;
|
|
379
454
|
await runIsolatedProcess(
|
|
@@ -408,7 +483,13 @@ test('managed guardians journal registration and drainage for successful and fai
|
|
|
408
483
|
for (const item of claim.processes) {
|
|
409
484
|
assert.equal(item.stage, 'drained');
|
|
410
485
|
assert.equal(item.role, 'guardian');
|
|
411
|
-
|
|
486
|
+
if (process.platform === 'win32') {
|
|
487
|
+
assert.equal(item.containment, 'windows-job');
|
|
488
|
+
assert.equal(item.pgid, undefined);
|
|
489
|
+
assert.ok(item.jobId);
|
|
490
|
+
} else {
|
|
491
|
+
assert.equal(item.pid, item.pgid);
|
|
492
|
+
}
|
|
412
493
|
assert.ok(item.host);
|
|
413
494
|
assert.ok(item.startTime);
|
|
414
495
|
}
|
|
@@ -419,20 +500,26 @@ test('managed guardians journal registration and drainage for successful and fai
|
|
|
419
500
|
});
|
|
420
501
|
|
|
421
502
|
test('does not report success when a child exits nonzero', async () => {
|
|
422
|
-
if (process.platform === 'win32') return;
|
|
423
503
|
await assert.rejects(
|
|
424
504
|
runIsolatedProcess(process.execPath, ['-e', 'process.exit(7)'], ROOT_DIR, { ...process.env }),
|
|
425
505
|
/Isolated test exited: 7/,
|
|
426
506
|
);
|
|
427
507
|
});
|
|
428
508
|
|
|
429
|
-
test(
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
509
|
+
test(
|
|
510
|
+
'interrupts an owned group and waits for its graceful child shutdown',
|
|
511
|
+
{
|
|
512
|
+
skip:
|
|
513
|
+
process.platform === 'win32'
|
|
514
|
+
? 'Windows cancellation terminates the Job; POSIX graceful signals are not supported'
|
|
515
|
+
: false,
|
|
516
|
+
},
|
|
517
|
+
async () => {
|
|
518
|
+
const root = mkdtempSync(join(resolve(ROOT_DIR, 'vona'), '.guardian-interrupt-test-'));
|
|
519
|
+
const readyFile = join(root, 'ready');
|
|
520
|
+
const stoppedFile = join(root, 'stopped');
|
|
521
|
+
try {
|
|
522
|
+
const script = `
|
|
436
523
|
import { writeFileSync } from 'node:fs';
|
|
437
524
|
writeFileSync(process.argv[1], 'ready');
|
|
438
525
|
process.on('SIGINT', () => {
|
|
@@ -443,24 +530,25 @@ test('interrupts an owned group and waits for its graceful child shutdown', asyn
|
|
|
443
530
|
});
|
|
444
531
|
setInterval(() => {}, 1000);
|
|
445
532
|
`;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
533
|
+
const pending = runIsolatedProcess(
|
|
534
|
+
process.execPath,
|
|
535
|
+
['--input-type=module', '-e', script, readyFile, stoppedFile],
|
|
536
|
+
ROOT_DIR,
|
|
537
|
+
{ ...process.env },
|
|
538
|
+
);
|
|
539
|
+
const deadline = Date.now() + 5000;
|
|
540
|
+
while (!existsSync(readyFile) && Date.now() < deadline) {
|
|
541
|
+
await new Promise(resolvePromise => setTimeout(resolvePromise, 50));
|
|
542
|
+
}
|
|
543
|
+
assert.ok(existsSync(readyFile));
|
|
544
|
+
process.emit('SIGINT');
|
|
545
|
+
await assert.rejects(pending, /Isolated test exited/);
|
|
546
|
+
assert.ok(existsSync(stoppedFile));
|
|
547
|
+
} finally {
|
|
548
|
+
rmSync(root, { recursive: true, force: true });
|
|
455
549
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
await assert.rejects(pending, /Isolated test exited/);
|
|
459
|
-
assert.ok(existsSync(stoppedFile));
|
|
460
|
-
} finally {
|
|
461
|
-
rmSync(root, { recursive: true, force: true });
|
|
462
|
-
}
|
|
463
|
-
});
|
|
550
|
+
},
|
|
551
|
+
);
|
|
464
552
|
|
|
465
553
|
test('rejects merged paths, Redis prefixes, and database overrides before cleanup', () => {
|
|
466
554
|
const root = mkdtempSync(join(resolve(ROOT_DIR, 'vona'), '.isolation-guard-test-'));
|
|
@@ -535,3 +623,282 @@ test('rejects merged paths, Redis prefixes, and database overrides before cleanu
|
|
|
535
623
|
rmSync(root, { recursive: true, force: true });
|
|
536
624
|
}
|
|
537
625
|
});
|
|
626
|
+
|
|
627
|
+
function shutdownFixture(
|
|
628
|
+
options: {
|
|
629
|
+
readyFailure?: boolean;
|
|
630
|
+
requestFailure?: boolean;
|
|
631
|
+
fallbackFailure?: boolean;
|
|
632
|
+
drainageFailure?: boolean;
|
|
633
|
+
earlyExit?: boolean;
|
|
634
|
+
hang?: boolean;
|
|
635
|
+
code?: number;
|
|
636
|
+
} = {},
|
|
637
|
+
) {
|
|
638
|
+
const events: string[] = [];
|
|
639
|
+
let finish!: (result: import('./runIsolatedTest.ts').GuardianResult) => void;
|
|
640
|
+
const result = {
|
|
641
|
+
type: 'drained' as const,
|
|
642
|
+
failed: !!options.drainageFailure,
|
|
643
|
+
result: { code: options.code ?? 0, signal: null },
|
|
644
|
+
};
|
|
645
|
+
const waited = new Promise<import('./runIsolatedTest.ts').GuardianResult>(resolve => {
|
|
646
|
+
finish = resolve;
|
|
647
|
+
});
|
|
648
|
+
const server = {
|
|
649
|
+
wait: () => waited,
|
|
650
|
+
stop: async () => {
|
|
651
|
+
events.push('force-stop');
|
|
652
|
+
if (options.fallbackFailure) throw new Error('fallback failed');
|
|
653
|
+
finish(result);
|
|
654
|
+
return result;
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
const dependencies = {
|
|
658
|
+
platform: 'win32' as const,
|
|
659
|
+
shutdownTimeoutMs: 30,
|
|
660
|
+
start: async () => {
|
|
661
|
+
events.push('start');
|
|
662
|
+
return server;
|
|
663
|
+
},
|
|
664
|
+
ready: async () => {
|
|
665
|
+
events.push('ready');
|
|
666
|
+
if (options.earlyExit) {
|
|
667
|
+
finish(result);
|
|
668
|
+
await Promise.resolve();
|
|
669
|
+
}
|
|
670
|
+
if (options.readyFailure) throw new Error('readiness failed');
|
|
671
|
+
},
|
|
672
|
+
requestStop: async () => {
|
|
673
|
+
events.push('cooperative-stop');
|
|
674
|
+
if (options.requestFailure) throw new Error('authorization failed');
|
|
675
|
+
if (!options.hang) finish(result);
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
return { dependencies, events, server };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
test('Windows verified-readiness teardown requests cooperative shutdown then waits without force-stop', async () => {
|
|
682
|
+
const f = shutdownFixture();
|
|
683
|
+
const before = process.listenerCount('SIGINT');
|
|
684
|
+
assert.equal(
|
|
685
|
+
await withIsolatedVona(
|
|
686
|
+
1234,
|
|
687
|
+
{ VONA_TEST_RUN_ID: '20261014010203-a1b2c3d4' },
|
|
688
|
+
async () => {
|
|
689
|
+
f.events.push('callback');
|
|
690
|
+
return 'done';
|
|
691
|
+
},
|
|
692
|
+
f.dependencies,
|
|
693
|
+
),
|
|
694
|
+
'done',
|
|
695
|
+
);
|
|
696
|
+
assert.deepEqual(f.events, ['start', 'ready', 'callback', 'cooperative-stop']);
|
|
697
|
+
assert.equal(process.listenerCount('SIGINT'), before);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
for (const options of [
|
|
701
|
+
{ requestFailure: true },
|
|
702
|
+
{ hang: true },
|
|
703
|
+
{ drainageFailure: true },
|
|
704
|
+
{ code: 1 },
|
|
705
|
+
]) {
|
|
706
|
+
test(`Windows cooperative failure always force-stops and fails (${JSON.stringify(options)})`, async () => {
|
|
707
|
+
const f = shutdownFixture(options);
|
|
708
|
+
await assert.rejects(withIsolatedVona(1234, {}, async () => {}, f.dependencies));
|
|
709
|
+
assert.deepEqual(f.events, ['start', 'ready', 'cooperative-stop', 'force-stop']);
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
test('Windows readiness failure or early exit never requests cooperative shutdown', async () => {
|
|
714
|
+
for (const options of [{ readyFailure: true }, { earlyExit: true }]) {
|
|
715
|
+
const f = shutdownFixture(options);
|
|
716
|
+
await assert.rejects(withIsolatedVona(1234, {}, async () => {}, f.dependencies));
|
|
717
|
+
assert.deepEqual(f.events, ['start', 'ready', 'force-stop']);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test('Windows interruption is failure and uses force-stop, not authenticated normal shutdown', async () => {
|
|
722
|
+
const f = shutdownFixture();
|
|
723
|
+
await assert.rejects(
|
|
724
|
+
withIsolatedVona(
|
|
725
|
+
1234,
|
|
726
|
+
{},
|
|
727
|
+
async () => {
|
|
728
|
+
process.emit('SIGINT');
|
|
729
|
+
},
|
|
730
|
+
f.dependencies,
|
|
731
|
+
),
|
|
732
|
+
/interrupted/,
|
|
733
|
+
);
|
|
734
|
+
assert.equal(f.events.includes('cooperative-stop'), false);
|
|
735
|
+
assert.ok(f.events.includes('force-stop'));
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test('callback, cooperative shutdown and fallback errors all survive', async () => {
|
|
739
|
+
const f = shutdownFixture({ requestFailure: true, fallbackFailure: true });
|
|
740
|
+
await assert.rejects(
|
|
741
|
+
withIsolatedVona(
|
|
742
|
+
1234,
|
|
743
|
+
{},
|
|
744
|
+
async () => {
|
|
745
|
+
throw new Error('callback failed');
|
|
746
|
+
},
|
|
747
|
+
f.dependencies,
|
|
748
|
+
),
|
|
749
|
+
(error: unknown) => {
|
|
750
|
+
assert.ok(error instanceof AggregateError);
|
|
751
|
+
assert.equal(error.errors[0].message, 'callback failed');
|
|
752
|
+
assert.ok(error.errors[1] instanceof AggregateError);
|
|
753
|
+
assert.deepEqual(
|
|
754
|
+
error.errors[1].errors.map((e: Error) => e.message),
|
|
755
|
+
['authorization failed', 'fallback failed'],
|
|
756
|
+
);
|
|
757
|
+
return true;
|
|
758
|
+
},
|
|
759
|
+
);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test('callback error survives a clean Windows cooperative shutdown', async () => {
|
|
763
|
+
const f = shutdownFixture();
|
|
764
|
+
await assert.rejects(
|
|
765
|
+
withIsolatedVona(
|
|
766
|
+
1234,
|
|
767
|
+
{},
|
|
768
|
+
async () => {
|
|
769
|
+
throw new Error('callback failed');
|
|
770
|
+
},
|
|
771
|
+
f.dependencies,
|
|
772
|
+
),
|
|
773
|
+
/callback failed/,
|
|
774
|
+
);
|
|
775
|
+
assert.deepEqual(f.events, ['start', 'ready', 'cooperative-stop']);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test('POSIX teardown preserves the existing stop path', async () => {
|
|
779
|
+
const f = shutdownFixture();
|
|
780
|
+
await withIsolatedVona(1234, {}, async () => {}, { ...f.dependencies, platform: 'darwin' });
|
|
781
|
+
assert.deepEqual(f.events, ['start', 'ready', 'force-stop']);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test('shutdown HTTP request uses distinct nonce-bound domains and rejects invalid acknowledgments or redirects', async () => {
|
|
785
|
+
const key = 'a'.repeat(64);
|
|
786
|
+
const runId = '20261014010203-a1b2c3d4';
|
|
787
|
+
let mode = 'ok';
|
|
788
|
+
let requests = 0;
|
|
789
|
+
const listener = createServer((req, res) => {
|
|
790
|
+
requests++;
|
|
791
|
+
assert.equal(req.method, 'POST');
|
|
792
|
+
assert.equal(req.url, '/health/test-stop');
|
|
793
|
+
const nonce = req.headers['x-vona-test-stop-nonce'];
|
|
794
|
+
assert.match(String(nonce), /^[a-f0-9]{32}$/);
|
|
795
|
+
const payload = `${runId}\nPOST\n/health/test-stop\n${nonce}`;
|
|
796
|
+
const sign = (value: string) => createHmac('sha256', key).update(value).digest('hex');
|
|
797
|
+
assert.equal(
|
|
798
|
+
req.headers['x-vona-test-stop-authorization'],
|
|
799
|
+
sign(`vona-test-stop-request-v1\n${payload}`),
|
|
800
|
+
);
|
|
801
|
+
assert.equal(JSON.stringify(req.headers).includes(key), false);
|
|
802
|
+
if (mode === 'timeout') return;
|
|
803
|
+
if (mode === 'redirect') {
|
|
804
|
+
res.writeHead(307, { location: '/health/test-stop' });
|
|
805
|
+
res.end();
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const proof =
|
|
809
|
+
mode === 'ready-proof' ? sign(String(nonce)) : sign(`vona-test-stop-ack-v1\n${payload}`);
|
|
810
|
+
res.writeHead(mode === 'refuse' ? 403 : 200, {
|
|
811
|
+
'x-vona-test-stop-proof': mode === 'bad-proof' ? 'f'.repeat(64) : proof,
|
|
812
|
+
});
|
|
813
|
+
res.end(JSON.stringify({ status: mode === 'bad-body' ? 'ok' : 'stopping' }));
|
|
814
|
+
});
|
|
815
|
+
await new Promise<void>(resolve => listener.listen(0, '127.0.0.1', resolve));
|
|
816
|
+
const address = listener.address();
|
|
817
|
+
assert.ok(address && typeof address !== 'string');
|
|
818
|
+
try {
|
|
819
|
+
await requestIsolatedVonaStop(address.port, runId, key);
|
|
820
|
+
for (mode of ['bad-proof', 'ready-proof', 'refuse', 'bad-body', 'redirect', 'timeout']) {
|
|
821
|
+
const before = requests;
|
|
822
|
+
await assert.rejects(requestIsolatedVonaStop(address.port, runId, key, 40));
|
|
823
|
+
assert.equal(requests, before + 1);
|
|
824
|
+
}
|
|
825
|
+
} finally {
|
|
826
|
+
listener.closeAllConnections();
|
|
827
|
+
await new Promise<void>((resolve, reject) =>
|
|
828
|
+
listener.close(error => (error ? reject(error) : resolve())),
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test(
|
|
834
|
+
'cooperative worker exit retains native guardian full drainage proof (Windows Job on Windows)',
|
|
835
|
+
{ timeout: 30_000 },
|
|
836
|
+
async () => {
|
|
837
|
+
const root = mkdtempSync(join(realpathSync(tmpdir()), 'cabloy cooperative worker '));
|
|
838
|
+
const marker = join(root, 'closed');
|
|
839
|
+
const descendant = join(root, 'descendant');
|
|
840
|
+
const port = await allocateTestPort();
|
|
841
|
+
const workerUrl = new URL(
|
|
842
|
+
'../vona/packages-vona/vona-core/src/lib/framework/processWorker.ts',
|
|
843
|
+
import.meta.url,
|
|
844
|
+
).href;
|
|
845
|
+
const source = `
|
|
846
|
+
import { createServer } from 'node:http';
|
|
847
|
+
import { createHmac } from 'node:crypto';
|
|
848
|
+
import { spawn } from 'node:child_process';
|
|
849
|
+
import { writeFileSync } from 'node:fs';
|
|
850
|
+
import { registerHooks } from 'node:module';
|
|
851
|
+
const workerUrl = ${JSON.stringify(workerUrl)};
|
|
852
|
+
globalThis.closeMock = async () => {
|
|
853
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
854
|
+
writeFileSync(${JSON.stringify(marker)}, 'closed');
|
|
855
|
+
};
|
|
856
|
+
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
857
|
+
if (context.parentURL === workerUrl) {
|
|
858
|
+
const source = {
|
|
859
|
+
'@cabloy/utils': 'export async function catchError(fn) { try { return [await fn()]; } catch (error) { return [undefined, error]; } } export function catchErrorSync(fn) { try { return [fn()]; } catch (error) { return [undefined, error]; } }',
|
|
860
|
+
'./useApp.ts': 'export const closeApp = globalThis.closeMock; export function useApp() {}',
|
|
861
|
+
}[specifier];
|
|
862
|
+
if (source) return { url: 'data:text/javascript,' + encodeURIComponent(source), shortCircuit: true };
|
|
863
|
+
}
|
|
864
|
+
return nextResolve(specifier, context);
|
|
865
|
+
} });
|
|
866
|
+
const { closeProcessWorker } = await import(workerUrl);
|
|
867
|
+
const sign = payload => createHmac('sha256', process.env.VONA_TEST_READY_KEY).update(payload).digest('hex');
|
|
868
|
+
const listener = createServer((req, res) => {
|
|
869
|
+
if (req.url === '/health/ready') {
|
|
870
|
+
res.writeHead(200, { 'x-vona-test-proof': sign(String(req.headers['x-vona-test-challenge'])) });
|
|
871
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const payload = process.env.VONA_TEST_RUN_ID + '\\nPOST\\n/health/test-stop\\n' + req.headers['x-vona-test-stop-nonce'];
|
|
875
|
+
if (req.method !== 'POST' || req.url !== '/health/test-stop' || req.headers['x-vona-test-stop-authorization'] !== sign('vona-test-stop-request-v1\\n' + payload)) {
|
|
876
|
+
res.writeHead(403); res.end(); return;
|
|
877
|
+
}
|
|
878
|
+
const child = spawn(process.execPath, ['-e', 'setTimeout(() => require("node:fs").writeFileSync(process.argv[1], "done"), 300)', ${JSON.stringify(descendant)}], { stdio: 'ignore', detached: process.platform === 'win32' });
|
|
879
|
+
child.unref();
|
|
880
|
+
res.writeHead(200, { 'x-vona-test-stop-proof': sign('vona-test-stop-ack-v1\\n' + payload) });
|
|
881
|
+
res.once('finish', () => void closeProcessWorker());
|
|
882
|
+
res.end(JSON.stringify({ status: 'stopping' }));
|
|
883
|
+
});
|
|
884
|
+
listener.listen(${port}, '127.0.0.1');
|
|
885
|
+
`;
|
|
886
|
+
try {
|
|
887
|
+
await withIsolatedVona(
|
|
888
|
+
port,
|
|
889
|
+
{ ...process.env, META_MODE: 'test', VONA_TEST_RUN_ID: '20261014010203-a1b2c3d4' },
|
|
890
|
+
async () => {},
|
|
891
|
+
{
|
|
892
|
+
platform: 'win32',
|
|
893
|
+
start: (_command, _args, cwd, env) =>
|
|
894
|
+
startOwnedProcess(process.execPath, ['--input-type=module', '-e', source], cwd, env),
|
|
895
|
+
ready: waitForReady,
|
|
896
|
+
},
|
|
897
|
+
);
|
|
898
|
+
assert.equal(readFileSync(marker, 'utf8'), 'closed');
|
|
899
|
+
assert.equal(readFileSync(descendant, 'utf8'), 'done');
|
|
900
|
+
} finally {
|
|
901
|
+
rmSync(root, { recursive: true, force: true });
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
);
|