cabloy 5.1.216 → 5.1.218

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 (89) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/create-cabloy.yml +137 -0
  3. package/.github/workflows/playwright-e2e.yml +163 -13
  4. package/.github/workflows/vona-test-sqlite3.yml +77 -7
  5. package/CHANGELOG.md +22 -0
  6. package/CONTRIBUTING.md +4 -2
  7. package/package.json +1 -1
  8. package/repo-docs/frontend/scripts.md +2 -0
  9. package/repo-docs/reference/repo-scripts.md +27 -1
  10. package/repo-e2e/scripts/e2e.test.ts +1 -1
  11. package/repo-e2e/scripts/managedTest.test.ts +115 -3
  12. package/repo-e2e/scripts/managedTest.ts +69 -20
  13. package/repo-e2e/scripts/managedTestImports.test.ts +55 -5
  14. package/repo-e2e/scripts/runE2e.test.ts +109 -0
  15. package/repo-e2e/scripts/runE2e.ts +58 -39
  16. package/repo-e2e/scripts/runE2eArgs.test.ts +16 -1
  17. package/repo-e2e/scripts/runE2eArgs.ts +5 -0
  18. package/repo-e2e/specs/a-commerce.spec.ts +10 -2
  19. package/repo-e2e/specs/home-user-account.spec.ts +4 -0
  20. package/repo-e2e/specs/markdown.spec.ts +14 -3
  21. package/scripts/generateOpenapiIsolated.test.ts +5 -1
  22. package/scripts/runIsolatedTest.test.ts +403 -36
  23. package/scripts/runIsolatedTest.ts +139 -12
  24. package/scripts/testBrowserGuardianWindows.test.ts +327 -0
  25. package/scripts/testClaimCleanup.test.ts +25 -9
  26. package/scripts/testClaimCleanup.ts +14 -45
  27. package/scripts/testClaimProcesses.test.ts +81 -2
  28. package/scripts/testClaimProcesses.ts +46 -10
  29. package/scripts/testProcessGuardianWindows.test.ts +602 -0
  30. package/scripts/testProcessGuardianWindows.ts +155 -0
  31. package/vona/packages-cli/cli/package.json +1 -1
  32. package/vona/packages-cli/cli-set-api/package.json +1 -1
  33. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.build.ts +4 -1
  34. package/vona/packages-cli/cli-set-api/test/binBuildEntries.test.ts +159 -0
  35. package/vona/packages-vona/vona/package.json +1 -1
  36. package/vona/packages-vona/vona-core/package.json +1 -1
  37. package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +298 -17
  38. package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +205 -97
  39. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.test.ts +247 -0
  40. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.ts +224 -0
  41. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.cs +229 -0
  42. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ps1 +20 -0
  43. package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ts +200 -0
  44. package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.cs +336 -0
  45. package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ps1 +20 -0
  46. package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ts +531 -0
  47. package/vona/packages-vona/vona-core/src/lib/framework/bootstrap.test.ts +61 -0
  48. package/vona/packages-vona/vona-core/src/lib/framework/index.ts +1 -0
  49. package/vona/packages-vona/vona-core/src/lib/framework/processWorker.test.ts +59 -0
  50. package/vona/packages-vona/vona-core/src/lib/framework/processWorker.ts +8 -5
  51. package/vona/packages-vona/vona-core/src/lib/framework/useApp.test.ts +84 -0
  52. package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +2 -2
  53. package/vona/packages-vona/vona-core/src/lib/utils/util.ts +1 -1
  54. package/vona/packages-vona/vona-mock/package.json +1 -1
  55. package/vona/pnpm-lock.yaml +42 -181
  56. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  57. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  58. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  59. package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
  60. package/vona/src/suite-vendor/a-vona/modules/a-executor/src/bean/bean.executor.ts +2 -1
  61. package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
  62. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +2 -2
  63. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +16 -0
  64. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +6 -3
  65. package/vona/src/suite-vendor/a-vona/modules/a-logger/test/middlewareSystem.httpLog.test.ts +80 -0
  66. package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
  67. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +73 -2
  68. package/vona/src/suite-vendor/a-vona/modules/a-metrics/test/middlewareSystem.health.test.ts +309 -0
  69. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  70. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transactionAsyncLocalStorage_.ts +3 -3
  71. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  72. package/vona/src/suite-vendor/a-vona/modules/a-static/package.json +1 -1
  73. package/vona/src/suite-vendor/a-vona/modules/a-static/src/bean/middlewareSystem.static.ts +2 -2
  74. package/vona/src/suite-vendor/a-vona/modules/a-static/test/middlewareSystem.static.test.ts +101 -0
  75. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  76. package/zova/package.original.json +1 -1
  77. package/zova/packages-cli/cli/package.json +2 -2
  78. package/zova/packages-cli/cli-set-front/package.json +4 -4
  79. package/zova/packages-utils/zova-jsx/package.json +2 -2
  80. package/zova/packages-utils/zova-vite/package.json +2 -2
  81. package/zova/packages-utils/zova-vite/src/configUtils.ts +1 -1
  82. package/zova/packages-utils/zova-vite/test/configUtils.test.ts +55 -0
  83. package/zova/packages-zova/zova/package.json +3 -3
  84. package/zova/packages-zova/zova-core/package.json +2 -2
  85. package/zova/pnpm-lock.yaml +324 -63
  86. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +8 -4
  87. package/zova/src/suite/a-commerce/modules/commerce-trade/test/lib/cartModel.test.ts +71 -0
  88. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
  89. package/zova/src/suite-vendor/a-zova/package.json +2 -2
@@ -801,7 +801,7 @@ test(
801
801
 
802
802
  const addResponse = waitForApiResponse(page, 'POST', '/api/commerce/trade/cart/items');
803
803
  await page.getByRole('button', { name: 'Add to cart', exact: true }).click();
804
- await addResponse;
804
+ expect((await addResponse).ok()).toBeTruthy();
805
805
 
806
806
  await page.getByRole('link', { name: /^Cart/ }).click();
807
807
  await expect(page).toHaveURL(/\/commerce\/commerce\/trade\/cart(?:\/|$)/);
@@ -1343,7 +1343,7 @@ test(
1343
1343
  await expect(page.getByText('$45.99')).toBeVisible();
1344
1344
  const addResponse = waitForApiResponse(page, 'POST', '/api/commerce/trade/cart/items');
1345
1345
  await page.getByRole('button', { name: 'Add to cart', exact: true }).click();
1346
- await addResponse;
1346
+ expect((await addResponse).ok()).toBeTruthy();
1347
1347
  await page.getByRole('link', { name: /^Cart/ }).click();
1348
1348
  await expect(page).toHaveURL(/\/commerce\/commerce\/trade\/cart(?:\/|$)/);
1349
1349
  await page.getByRole('link', { name: 'Checkout', exact: true }).click();
@@ -1662,6 +1662,10 @@ test(
1662
1662
  /\/commerce-admin\/rest\/resource\/commerce-promotion(?:%3A|:|%253A)couponTemplate\/create(?:[/?#]|$)/,
1663
1663
  );
1664
1664
 
1665
+ await expect(adminPage.locator('html')).toHaveAttribute(
1666
+ 'data-zova-hydrated',
1667
+ 'commerceAdmin',
1668
+ );
1665
1669
  for (const groupName of [
1666
1670
  'Basic Information',
1667
1671
  'Discount Policy',
@@ -1765,6 +1769,10 @@ test(
1765
1769
  expectTableIdentity(parentId);
1766
1770
 
1767
1771
  await adminPage.goto(categoryCreatePath, { waitUntil: 'load' });
1772
+ await expect(adminPage.locator('html')).toHaveAttribute(
1773
+ 'data-zova-hydrated',
1774
+ 'commerceAdmin',
1775
+ );
1768
1776
  const parentPublication = adminPage
1769
1777
  .getByRole('group', { name: 'Published' })
1770
1778
  .getByRole('combobox');
@@ -536,6 +536,10 @@ test(
536
536
  expect(profile.ok()).toBeTruthy();
537
537
  const profileBody = (await profile.json()).data;
538
538
  expect(profileBody.avatar).toEqual(expect.any(String));
539
+ const avatarResponse = await request.get(profileBody.avatar);
540
+ expect(avatarResponse.ok()).toBeTruthy();
541
+ expect(avatarResponse.headers()['content-type']).toMatch(/^image\//);
542
+ expect((await avatarResponse.body()).length).toBeGreaterThan(0);
539
543
  expect(pageErrors).toEqual([]);
540
544
  expect(consoleErrors).toEqual([]);
541
545
  } finally {
@@ -590,15 +590,26 @@ test(
590
590
  await linkInput.fill('');
591
591
  await linkInput.press('Enter');
592
592
  await expect(editor.locator('h1 a')).toHaveCount(0);
593
+ const persistedText = `Link removal saved ${suffix}`;
594
+ await editor.press('ControlOrMeta+End');
595
+ await editor.press('Enter');
596
+ await editor.pressSequentially(persistedText);
597
+ const productUpdate = waitForApiResponse(
598
+ adminPage,
599
+ 'PATCH',
600
+ `${productActionPath}/${productId}`,
601
+ );
593
602
  await adminPage.getByRole('button', { name: 'Submit', exact: true }).click();
603
+ expect((await productUpdate).ok()).toBeTruthy();
594
604
  const productResponseAfterLinkRemoval = await adminPage.request.get(
595
605
  `${productActionPath}/${productId}`,
596
606
  { headers },
597
607
  );
598
608
  expect(productResponseAfterLinkRemoval.ok()).toBeTruthy();
599
- expect(
600
- (await productResponseAfterLinkRemoval.json()).productContentForm.descriptionMarkdown,
601
- ).not.toContain('https://example.com/updated');
609
+ const savedMarkdown = (await productResponseAfterLinkRemoval.json()).productContentForm
610
+ .descriptionMarkdown;
611
+ expect(savedMarkdown).toContain(persistedText);
612
+ expect(savedMarkdown).not.toContain('https://example.com/updated');
602
613
  expect(adminPageErrors).toEqual([]);
603
614
  expect(adminConsoleErrors).toEqual([]);
604
615
  } finally {
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { once } from 'node:events';
3
3
  import { createServer } from 'node:http';
4
+ import { resolve } from 'node:path';
4
5
  import { test } from 'node:test';
5
6
 
6
7
  import type { GuardianResult, OwnedProcess } from './runIsolatedTest.ts';
@@ -46,7 +47,7 @@ test('launches only a loopback single-worker test-mode Vona service', () => {
46
47
  '--flavor=normal',
47
48
  '--mode=test',
48
49
  ]);
49
- assert.equal(command.cwd, `${ROOT_DIR}/vona`);
50
+ assert.equal(command.cwd, resolve(ROOT_DIR, 'vona'));
50
51
  assert.deepEqual(command.env, {
51
52
  CUSTOM: 'keep',
52
53
  VONA_TEST_READY_KEY: 'test-key',
@@ -81,6 +82,9 @@ function lifecycleFixture(options: { readyFailure?: boolean; stopFailure?: boole
81
82
  finish,
82
83
  result,
83
84
  dependencies: {
85
+ // These legacy fixtures assert POSIX signal/stop semantics; Windows cooperative
86
+ // shutdown is exercised separately with explicit platform injection.
87
+ platform: 'darwin' as const,
84
88
  start: async () => {
85
89
  events.push('start');
86
90
  return server;
@@ -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
- { command: 'npm', args: ['run', 'prerun'], cwd: resolve(ROOT_DIR, 'vona') },
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
- const child = spawn(process.execPath, ['-e', 'setTimeout(() => require("node:fs").writeFileSync(process.argv[1], "done"), 400) ', process.argv[1]], { stdio: 'ignore' });
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
- const child = spawn(process.execPath, ['-e', 'setTimeout(() => require("node:fs").writeFileSync(process.argv[1], "done"), 400)', process.argv[1]], { stdio: 'ignore' });
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
- assert.equal(item.pid, item.pgid);
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('interrupts an owned group and waits for its graceful child shutdown', async () => {
430
- if (process.platform === 'win32') return;
431
- const root = mkdtempSync(join(resolve(ROOT_DIR, 'vona'), '.guardian-interrupt-test-'));
432
- const readyFile = join(root, 'ready');
433
- const stoppedFile = join(root, 'stopped');
434
- try {
435
- const script = `
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
- const pending = runIsolatedProcess(
447
- process.execPath,
448
- ['--input-type=module', '-e', script, readyFile, stoppedFile],
449
- ROOT_DIR,
450
- { ...process.env },
451
- );
452
- const deadline = Date.now() + 5000;
453
- while (!existsSync(readyFile) && Date.now() < deadline) {
454
- await new Promise(resolvePromise => setTimeout(resolvePromise, 50));
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
- assert.ok(existsSync(readyFile));
457
- process.emit('SIGINT');
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
+ );