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
@@ -21,6 +21,7 @@ async function fixture(
21
21
  group?: number;
22
22
  noProfile?: boolean;
23
23
  exited?: boolean;
24
+ platform?: NodeJS.Platform;
24
25
  } = {},
25
26
  ) {
26
27
  const root = await realpath(await mkdtemp(join(tmpdir(), 'cabloy-managed-browser-')));
@@ -72,7 +73,9 @@ async function fixture(
72
73
  VONA_TEST_CLAIM_URL: 'http://127.0.0.1:1234/event',
73
74
  VONA_TEST_CLAIM_TOKEN: 'a'.repeat(64),
74
75
  TMPDIR: temporaryRoot,
76
+ ...(options.platform === 'win32' ? { TEMP: temporaryRoot, TMP: temporaryRoot } : {}),
75
77
  },
78
+ platform: options.platform ?? 'linux',
76
79
  projectPath,
77
80
  sendEvent: async (_env: NodeJS.ProcessEnv, event: TestClaimEvent) => {
78
81
  const stage = event.type === 'process' ? event.process.stage : event.type;
@@ -85,13 +88,24 @@ async function fixture(
85
88
  if (options.fault === 'inspect') throw new Error('inspection failed');
86
89
  return {
87
90
  pid,
88
- pgid: options.group ?? pid,
89
- startTime: 'Wed Oct 14 01:02:03 2026',
91
+ ...(options.platform === 'win32' ? {} : { pgid: options.group ?? pid }),
92
+ startTime: options.platform === 'win32' ? '134000000000000000' : 'Wed Oct 14 01:02:03 2026',
90
93
  host: 'test-host',
91
94
  state: 'S',
92
95
  args: '',
93
96
  };
94
97
  },
98
+ assertWindowsJob: async (
99
+ _env: NodeJS.ProcessEnv,
100
+ identity: { pid: number; startTime: string; host: string },
101
+ ) => {
102
+ const role = identity.pid === process.pid ? 'worker' : 'browser';
103
+ calls.push(`job-${role}`);
104
+ assert.equal(identity.startTime, '134000000000000000');
105
+ assert.equal(identity.host, 'test-host');
106
+ assert.equal(Object.hasOwn(identity, 'pgid'), false);
107
+ if (options.fault === `job-${role}`) throw new Error('designated Job membership unavailable');
108
+ },
95
109
  waitForGroupExit: async (pgid: number) => {
96
110
  calls.push('wait');
97
111
  assert.equal(pgid, 4242);
@@ -277,7 +291,7 @@ for (const invalid of ['tmpdir', 'owner', 'transport', 'connect', 'symlink']) {
277
291
  if (invalid === 'transport') f.dependencies.env.VONA_TEST_CLAIM_TOKEN = '';
278
292
  if (invalid === 'symlink') {
279
293
  await rm(f.temporaryRoot, { recursive: true });
280
- await symlink(f.root, f.temporaryRoot);
294
+ await symlink(f.root, f.temporaryRoot, process.platform === 'win32' ? 'junction' : 'dir');
281
295
  }
282
296
  const options =
283
297
  invalid === 'connect'
@@ -364,6 +378,104 @@ test('rejects an explicit external profile argument before launch', async () =>
364
378
  }
365
379
  });
366
380
 
381
+ test('Windows proves worker and browser Job membership without per-browser POSIX claims', async () => {
382
+ const f = await fixture({ platform: 'win32' });
383
+ try {
384
+ await useManagedBrowser(
385
+ f.browserOptions,
386
+ async () => {
387
+ f.calls.push('use');
388
+ },
389
+ f.dependencies,
390
+ );
391
+ assert.deepEqual(f.calls, [
392
+ 'inspect',
393
+ 'job-worker',
394
+ 'launch',
395
+ 'inspect',
396
+ 'job-browser',
397
+ 'connect',
398
+ 'use',
399
+ 'close',
400
+ ]);
401
+ assert.deepEqual(f.events, []);
402
+ } finally {
403
+ await f.dispose();
404
+ }
405
+ });
406
+
407
+ for (const fault of ['job-worker', 'job-browser', 'launch', 'inspect', 'connect', 'close']) {
408
+ test(`Windows fails closed on ${fault} without acknowledging browser drainage`, async () => {
409
+ const f = await fixture({ platform: 'win32', fault });
410
+ try {
411
+ await assert.rejects(useManagedBrowser(f.browserOptions, async () => {}, f.dependencies));
412
+ assert.deepEqual(f.events, []);
413
+ assert.ok(!f.calls.includes('wait'));
414
+ if (['job-worker', 'inspect'].includes(fault)) assert.ok(!f.calls.includes('launch'));
415
+ if (fault === 'job-browser') {
416
+ assert.ok(f.calls.includes('close'));
417
+ assert.ok(!f.calls.includes('connect'));
418
+ }
419
+ } finally {
420
+ await f.dispose();
421
+ }
422
+ });
423
+ }
424
+
425
+ for (const variable of ['TEMP', 'TMP', 'TMPDIR', 'temp']) {
426
+ test(`Windows rejects inherited ${variable} outside its run before browser launch`, async () => {
427
+ const f = await fixture({ platform: 'win32' });
428
+ try {
429
+ const env: NodeJS.ProcessEnv = { ...f.dependencies.env, [variable]: '/unowned' };
430
+ await assert.rejects(
431
+ useManagedBrowser(f.browserOptions, async () => {}, { ...f.dependencies, env }),
432
+ /run-owned temporary paths/,
433
+ );
434
+ assert.deepEqual(f.calls, []);
435
+ } finally {
436
+ await f.dispose();
437
+ }
438
+ });
439
+ }
440
+
441
+ test('Windows normalizes custom temporary environment names without mutating caller options', async () => {
442
+ const f = await fixture({ platform: 'win32' });
443
+ const env = { temp: '/unowned', Tmp: '/unowned', tmpdir: '/unowned', CUSTOM: 'keep' };
444
+ try {
445
+ await useManagedBrowser(
446
+ { ...f.browserOptions, launchOptions: { env } },
447
+ async () => {},
448
+ f.dependencies,
449
+ );
450
+ assert.deepEqual(f.launchedOptions()?.env, {
451
+ TEMP: f.temporaryRoot,
452
+ TMP: f.temporaryRoot,
453
+ TMPDIR: f.temporaryRoot,
454
+ CUSTOM: 'keep',
455
+ });
456
+ assert.equal(env.temp, '/unowned');
457
+ assert.equal(env.Tmp, '/unowned');
458
+ assert.equal(env.tmpdir, '/unowned');
459
+ } finally {
460
+ await f.dispose();
461
+ }
462
+ });
463
+
464
+ test('Windows closes a browser with an escaping actual profile without connecting', async () => {
465
+ const f = await fixture({ platform: 'win32', profile: '/unowned/profile' });
466
+ try {
467
+ await assert.rejects(
468
+ useManagedBrowser(f.browserOptions, async () => {}, f.dependencies),
469
+ /outside its run-owned area/,
470
+ );
471
+ assert.deepEqual(f.events, []);
472
+ assert.ok(f.calls.includes('close'));
473
+ assert.ok(!f.calls.includes('connect'));
474
+ } finally {
475
+ await f.dispose();
476
+ }
477
+ });
478
+
367
479
  test('discovers exact Chromium and Firefox argv profiles; uses the temporary area for WebKit', () => {
368
480
  assert.equal(
369
481
  discoverBrowserProfile(['browser', '--user-data-dir=/run/tmp/with spaces']),
@@ -24,6 +24,7 @@ import {
24
24
  } from '../../scripts/testClaimProcesses.ts';
25
25
  import {
26
26
  assertTestClaimSafePath,
27
+ assertTestClaimWindowsJob,
27
28
  getTestClaimPaths,
28
29
  hasTestClaimTransport,
29
30
  sendTestClaimEvent,
@@ -49,6 +50,11 @@ interface ManagedBrowserDependencies {
49
50
  sendEvent: (env: NodeJS.ProcessEnv, event: TestClaimEvent) => Promise<void>;
50
51
  inspectProcess: (pid: number) => Promise<TestProcessSnapshot>;
51
52
  waitForGroupExit: (pgid: number) => Promise<void>;
53
+ platform?: NodeJS.Platform;
54
+ assertWindowsJob?: (
55
+ env: NodeJS.ProcessEnv,
56
+ identity: { pid: number; startTime: string; host: string },
57
+ ) => Promise<void>;
52
58
  }
53
59
 
54
60
  function assertWithin(root: string, target: string) {
@@ -94,6 +100,8 @@ export async function useManagedBrowser(
94
100
  },
95
101
  ) {
96
102
  const { env } = dependencies;
103
+ const windows = (dependencies.platform ?? process.platform) === 'win32';
104
+ const assertWindowsJob = dependencies.assertWindowsJob ?? assertTestClaimWindowsJob;
97
105
  const runId = env.VONA_TEST_RUN_ID;
98
106
  if (!runId || !hasTestClaimTransport(env)) {
99
107
  throw new Error('Managed browser requires an isolated test claim transport');
@@ -103,8 +111,16 @@ export async function useManagedBrowser(
103
111
  const { dataRoot } = getTestClaimPaths(dependencies.projectPath, runId);
104
112
  const temporaryRoot = resolve(dataRoot, 'tmp');
105
113
  assertTestRunOwner(dataRoot, runId);
106
- if (env.TMPDIR !== temporaryRoot || !lstatSync(temporaryRoot).isDirectory()) {
107
- throw new Error('Managed browser requires the inherited run-owned TMPDIR');
114
+ const tempKeys = windows
115
+ ? Object.keys(env).filter(key => ['TEMP', 'TMP', 'TMPDIR'].includes(key.toUpperCase()))
116
+ : ['TMPDIR'];
117
+ if (
118
+ (windows &&
119
+ !['TEMP', 'TMP', 'TMPDIR'].every(name => tempKeys.some(key => key.toUpperCase() === name))) ||
120
+ tempKeys.some(key => env[key] !== temporaryRoot) ||
121
+ !lstatSync(temporaryRoot).isDirectory()
122
+ ) {
123
+ throw new Error('Managed browser requires inherited run-owned temporary paths');
108
124
  }
109
125
  assertWithin(dataRoot, temporaryRoot);
110
126
 
@@ -120,16 +136,37 @@ export async function useManagedBrowser(
120
136
  }
121
137
  // Browser profile creation happens in the Playwright worker's inherited
122
138
  // TMPDIR. A custom browser environment must not redirect child temp writes.
123
- if (launchOptions.env) launchOptions.env = { ...launchOptions.env, TMPDIR: temporaryRoot };
139
+ if (launchOptions.env) {
140
+ const browserEnv = { ...launchOptions.env };
141
+ if (windows) {
142
+ for (const key of Object.keys(browserEnv)) {
143
+ if (['TEMP', 'TMP', 'TMPDIR'].includes(key.toUpperCase())) delete browserEnv[key];
144
+ }
145
+ browserEnv.TEMP = temporaryRoot;
146
+ browserEnv.TMP = temporaryRoot;
147
+ }
148
+ launchOptions.env = { ...browserEnv, TMPDIR: temporaryRoot };
149
+ }
150
+ if (windows) {
151
+ const identity = await dependencies.inspectProcess(process.pid);
152
+ if (identity.pid !== process.pid || !identity.startTime || identity.state.startsWith('Z')) {
153
+ throw new Error('Managed browser worker has no live process identity');
154
+ }
155
+ await assertWindowsJob(env, {
156
+ pid: identity.pid,
157
+ startTime: identity.startTime,
158
+ host: identity.host,
159
+ });
160
+ }
124
161
  const processRecord: TestClaimProcess = {
125
162
  id: randomUUID(),
126
163
  role: 'browser',
127
164
  stage: 'pending',
128
165
  profilePath: temporaryRoot,
129
166
  };
130
- // Acknowledgement precedes spawning. A rejected launch leaves this intent
131
- // pending: launchServer may have spawned before it rejects or the worker dies.
132
- await dependencies.sendEvent(env, { type: 'process', process: processRecord });
167
+ // POSIX browsers own independent groups; Windows browsers inherit the verified
168
+ // guardian Job. Only that guardian can acknowledge whole-Job drainage.
169
+ if (!windows) await dependencies.sendEvent(env, { type: 'process', process: processRecord });
133
170
  const server = await options.browserType.launchServer({
134
171
  handleSIGINT: false,
135
172
  ...launchOptions,
@@ -147,25 +184,37 @@ export async function useManagedBrowser(
147
184
  child.exitCode !== null ||
148
185
  child.signalCode !== null ||
149
186
  identity.pid !== child.pid ||
150
- identity.pgid !== child.pid ||
151
187
  !identity.startTime ||
152
- identity.state.startsWith('Z')
188
+ identity.state.startsWith('Z') ||
189
+ (!windows && identity.pgid !== child.pid)
153
190
  ) {
154
- throw new Error('Managed browser has no independent live process group');
191
+ throw new Error(
192
+ windows
193
+ ? 'Managed browser has no live process identity'
194
+ : 'Managed browser has no independent live process group',
195
+ );
155
196
  }
156
197
  const profilePath = discoverBrowserProfile(child.spawnargs) ?? temporaryRoot;
157
198
  assertWithin(temporaryRoot, profilePath);
158
- const record: TestClaimProcess = {
159
- ...processRecord,
160
- stage: 'registered',
161
- pid: identity.pid,
162
- pgid: identity.pgid,
163
- startTime: identity.startTime,
164
- host: identity.host,
165
- profilePath,
166
- };
167
- await dependencies.sendEvent(env, { type: 'process', process: record });
168
- registration = record;
199
+ if (windows) {
200
+ await assertWindowsJob(env, {
201
+ pid: identity.pid,
202
+ startTime: identity.startTime,
203
+ host: identity.host,
204
+ });
205
+ } else {
206
+ const record: TestClaimProcess = {
207
+ ...processRecord,
208
+ stage: 'registered',
209
+ pid: identity.pid,
210
+ pgid: identity.pgid,
211
+ startTime: identity.startTime,
212
+ host: identity.host,
213
+ profilePath,
214
+ };
215
+ await dependencies.sendEvent(env, { type: 'process', process: record });
216
+ registration = record;
217
+ }
169
218
  const browser = await options.browserType.connect(server.wsEndpoint(), { slowMo });
170
219
  await use(browser);
171
220
  } finally {
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
- import { relative, resolve } from 'node:path';
4
+ import { dirname, relative, resolve } from 'node:path';
5
5
  import test from 'node:test';
6
6
  import { fileURLToPath } from 'node:url';
7
7
 
@@ -51,8 +51,60 @@ test('fixture import gate allows type-only imports and catches runtime bypass fo
51
51
  );
52
52
  });
53
53
 
54
- test('production gate rejects empty trees, bypasses in nested specs, and symlinks', async () => {
55
- const root = await mkdtemp(resolve(tmpdir(), 'cabloy-managed-imports-'));
54
+ test('fixture import gate rejects absolute imports even when they point to managedTest', () => {
55
+ const spec = resolve(specRoot, 'example.spec.ts');
56
+ assert.deepEqual(
57
+ violations(`import { test } from ${JSON.stringify(fixturePath.replaceAll('\\', '/'))};`, spec),
58
+ ['application spec must import test from managedTest'],
59
+ );
60
+ });
61
+
62
+ test('production gate rejects directory links, including Windows junctions', async () => {
63
+ const root = await mkdtemp(resolve(tmpdir(), 'cabloy-managed-directory-link-'));
64
+ try {
65
+ const nested = resolve(root, 'nested');
66
+ await mkdir(nested);
67
+ await symlink(
68
+ nested,
69
+ resolve(root, 'linked'),
70
+ process.platform === 'win32' ? 'junction' : 'dir',
71
+ );
72
+ await assert.rejects(assertManagedTestImports(root), /cannot contain symlinks/);
73
+ } finally {
74
+ await rm(root, { recursive: true, force: true });
75
+ }
76
+ });
77
+
78
+ test('production gate rejects file symlinks when supported', async t => {
79
+ // Windows OS temp and the checkout may be on different drives, preventing relative imports.
80
+ const root = await mkdtemp(resolve(dirname(fixturePath), '.cabloy-managed-file-link-'));
81
+ try {
82
+ const spec = resolve(root, 'application.spec.ts');
83
+ const managedPath = relative(root, fixturePath).replaceAll('\\', '/');
84
+ await writeFile(spec, `import { test } from ${JSON.stringify(managedPath)};`);
85
+ await assertManagedTestImports(root);
86
+ try {
87
+ await symlink(spec, resolve(root, 'linked.spec.ts'), 'file');
88
+ } catch (error) {
89
+ if (
90
+ process.platform === 'win32' &&
91
+ ['EPERM', 'EACCES', 'ENOTSUP'].includes((error as NodeJS.ErrnoException).code ?? '')
92
+ ) {
93
+ t.skip(
94
+ `Windows file symlink creation unavailable (${(error as NodeJS.ErrnoException).code})`,
95
+ );
96
+ return;
97
+ }
98
+ throw error;
99
+ }
100
+ await assert.rejects(assertManagedTestImports(root), /cannot contain symlinks/);
101
+ } finally {
102
+ await rm(root, { recursive: true, force: true });
103
+ }
104
+ });
105
+
106
+ test('production gate rejects empty trees and bypasses in nested specs', async () => {
107
+ const root = await mkdtemp(resolve(dirname(fixturePath), '.cabloy-managed-imports-'));
56
108
  try {
57
109
  await assert.rejects(assertManagedTestImports(root), /no specs/);
58
110
  const nested = resolve(root, 'nested');
@@ -66,8 +118,6 @@ test('production gate rejects empty trees, bypasses in nested specs, and symlink
66
118
  const managedPath = relative(nested, fixturePath).replaceAll('\\', '/');
67
119
  await writeFile(spec, `import { test } from ${JSON.stringify(managedPath)};`);
68
120
  await assertManagedTestImports(root);
69
- await symlink(spec, resolve(root, 'linked.spec.ts'));
70
- await assert.rejects(assertManagedTestImports(root), /cannot contain symlinks/);
71
121
  } finally {
72
122
  await rm(root, { recursive: true, force: true });
73
123
  }
@@ -0,0 +1,109 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { resolve } from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { runPlaywright } from './runE2e.ts';
8
+ import { buildSpecFilter, parseE2eArgs } from './runE2eArgs.ts';
9
+
10
+ function withCli(
11
+ callback: (fixture: {
12
+ rootDir: string;
13
+ configFile: string;
14
+ specsDir: string;
15
+ outputFile: string;
16
+ }) => void,
17
+ exitCode = 0,
18
+ ): void {
19
+ const rootDir = mkdtempSync(resolve(tmpdir(), 'cabloy e2e café [x]+(y).$-'));
20
+ try {
21
+ const packageDir = resolve(rootDir, 'node_modules', '@playwright', 'test');
22
+ const specsDir = resolve(rootDir, 'specs 中文 [selected]');
23
+ const configFile = resolve(rootDir, 'config café [ui].ts');
24
+ const outputFile = resolve(rootDir, 'observed argv.json');
25
+ mkdirSync(packageDir, { recursive: true });
26
+ mkdirSync(specsDir);
27
+ writeFileSync(resolve(rootDir, 'package.json'), JSON.stringify({ type: 'module' }));
28
+ writeFileSync(
29
+ resolve(packageDir, 'package.json'),
30
+ JSON.stringify({ name: '@playwright/test', exports: { './cli': './cli.cjs' } }),
31
+ );
32
+ writeFileSync(
33
+ resolve(packageDir, 'cli.cjs'),
34
+ `require('node:fs').writeFileSync(${JSON.stringify(outputFile)}, JSON.stringify({
35
+ args: process.argv.slice(2), cwd: process.cwd(), execPath: process.execPath,
36
+ })); process.exit(${exitCode});`,
37
+ );
38
+ for (const name of ['cabloy-basic', 'home-user-account']) {
39
+ writeFileSync(resolve(specsDir, `${name}.spec.ts`), '');
40
+ }
41
+ callback({ rootDir, configFile, specsDir, outputFile });
42
+ } finally {
43
+ rmSync(rootDir, { recursive: true, force: true });
44
+ }
45
+ }
46
+
47
+ test('launches the resolved JavaScript CLI through Node without a shell in Unicode/space paths', () => {
48
+ withCli(fixture => {
49
+ const playwrightArgs = [
50
+ '--grep',
51
+ 'café 中文 "quoted" & $HOME; $(not-a-command) | > ignored',
52
+ '--trace=retain-on-failure',
53
+ '--output',
54
+ 'artifacts with spaces 中文',
55
+ ];
56
+ runPlaywright({
57
+ ...fixture,
58
+ parsed: parseE2eArgs(
59
+ ['cabloy-basic', 'home-user-account', ...playwrightArgs],
60
+ fixture.specsDir,
61
+ ),
62
+ });
63
+ const observed = JSON.parse(readFileSync(fixture.outputFile, 'utf8'));
64
+ assert.equal(observed.cwd, realpathSync(fixture.rootDir));
65
+ assert.equal(observed.execPath, process.execPath);
66
+ assert.deepEqual(observed.args, [
67
+ 'test',
68
+ '--config',
69
+ fixture.configFile,
70
+ buildSpecFilter(resolve(fixture.specsDir, 'cabloy-basic.spec.ts')),
71
+ buildSpecFilter(resolve(fixture.specsDir, 'home-user-account.spec.ts')),
72
+ ...playwrightArgs,
73
+ ]);
74
+ for (let index = 0; index < 2; index++) {
75
+ const name = ['cabloy-basic', 'home-user-account'][index];
76
+ const selectedPath = resolve(fixture.specsDir, `${name}.spec.ts`).replaceAll('\\', '/');
77
+ const filter = new RegExp(observed.args[3 + index]);
78
+ assert.match(selectedPath, filter);
79
+ assert.doesNotMatch(`${selectedPath}.unselected`, filter);
80
+ assert.doesNotMatch(selectedPath.replace('.spec.ts', 'XspecXts'), filter);
81
+ }
82
+ });
83
+ });
84
+
85
+ test('keeps all-spec discovery and combines tags without changing additional arguments', () => {
86
+ withCli(fixture => {
87
+ runPlaywright({
88
+ ...fixture,
89
+ parsed: parseE2eArgs(['--tag', '@web', '--grep=@flow', '--list'], fixture.specsDir),
90
+ });
91
+ const observed = JSON.parse(readFileSync(fixture.outputFile, 'utf8'));
92
+ assert.deepEqual(observed.args, [
93
+ 'test',
94
+ '--config',
95
+ fixture.configFile,
96
+ '--grep=(?=.*(?:@flow))(?=.*@web)',
97
+ '--list',
98
+ ]);
99
+ });
100
+ });
101
+
102
+ test('propagates the Playwright CLI failure rather than reporting a successful launch', () => {
103
+ withCli(fixture => {
104
+ assert.throws(
105
+ () => runPlaywright({ ...fixture, parsed: parseE2eArgs([], fixture.specsDir) }),
106
+ (error: Error & { status?: number }) => error.status === 23,
107
+ );
108
+ }, 23);
109
+ });
@@ -1,31 +1,46 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
2
3
  import { createServer } from 'node:net';
3
4
  import { resolve } from 'node:path';
4
5
 
5
- import {
6
- E2E_CONFIG_FILE,
7
- E2E_LOCAL_BASE_URL,
8
- E2E_PORT,
9
- E2E_ROOT_DIR,
10
- E2E_SPECS_DIR,
11
- } from './e2e.ts';
12
- import { assertManagedTestImports } from './managedTestImports.ts';
13
- import { combineGreps, parseE2eArgs } from './runE2eArgs.ts';
14
-
15
- function run(command: string, args: string[]): void {
6
+ import type { ParsedE2eArgs } from './runE2eArgs.ts';
7
+
8
+ import { buildSpecFilter, combineGreps, parseE2eArgs } from './runE2eArgs.ts';
9
+
10
+ export function runPlaywright(options: {
11
+ rootDir: string;
12
+ configFile: string;
13
+ specsDir: string;
14
+ parsed: ParsedE2eArgs;
15
+ }): void {
16
+ const { rootDir, configFile, specsDir, parsed } = options;
17
+ const require = createRequire(resolve(rootDir, 'package.json'));
18
+ const cli = require.resolve('@playwright/test/cli');
19
+ const specFilters = parsed.specNames.map(name =>
20
+ buildSpecFilter(resolve(specsDir, `${name}.spec.ts`)),
21
+ );
22
+ const args = [
23
+ cli,
24
+ 'test',
25
+ '--config',
26
+ configFile,
27
+ ...specFilters,
28
+ ...combineGreps(parsed.playwrightArgs, parsed.tags),
29
+ ];
16
30
  // eslint-disable-next-line no-console
17
- console.log(`\n===> ${[command, ...args].join(' ')}`);
18
- execFileSync(command, args, {
19
- cwd: E2E_ROOT_DIR,
31
+ console.log(`\n===> ${[process.execPath, ...args].join(' ')}`);
32
+ execFileSync(process.execPath, args, {
33
+ cwd: rootDir,
20
34
  stdio: 'inherit',
35
+ shell: false,
21
36
  });
22
37
  }
23
38
 
24
- async function assertPortAvailable(): Promise<void> {
39
+ async function assertPortAvailable(port: number): Promise<void> {
25
40
  const server = createServer();
26
41
  await new Promise<void>((resolvePromise, reject) => {
27
42
  server.once('error', reject);
28
- server.listen(E2E_PORT, '127.0.0.1', () => {
43
+ server.listen(port, '127.0.0.1', () => {
29
44
  server.close(error => {
30
45
  if (error) reject(error);
31
46
  else resolvePromise();
@@ -34,28 +49,32 @@ async function assertPortAvailable(): Promise<void> {
34
49
  });
35
50
  }
36
51
 
37
- const parsed = parseE2eArgs(process.argv.slice(2), E2E_SPECS_DIR);
38
- if (
39
- process.env.VONA_TEST_RUN_ID &&
40
- process.env.APP_NAME !== `cabtest${process.env.VONA_TEST_RUN_ID}`
41
- ) {
42
- throw new Error('Managed E2E test identity does not match its run ID');
43
- }
44
- if (process.env.VONA_TEST_RUN_ID) {
45
- await assertManagedTestImports();
46
- } else {
47
- await assertPortAvailable();
52
+ async function main(): Promise<void> {
53
+ const { E2E_CONFIG_FILE, E2E_LOCAL_BASE_URL, E2E_PORT, E2E_ROOT_DIR, E2E_SPECS_DIR } =
54
+ await import('./e2e.ts');
55
+ const parsed = parseE2eArgs(process.argv.slice(2), E2E_SPECS_DIR);
56
+ if (
57
+ process.env.VONA_TEST_RUN_ID &&
58
+ process.env.APP_NAME !== `cabtest${process.env.VONA_TEST_RUN_ID}`
59
+ ) {
60
+ throw new Error('Managed E2E test identity does not match its run ID');
61
+ }
62
+ if (process.env.VONA_TEST_RUN_ID) {
63
+ const { assertManagedTestImports } = await import('./managedTestImports.ts');
64
+ await assertManagedTestImports();
65
+ } else {
66
+ await assertPortAvailable(E2E_PORT);
67
+ }
68
+
69
+ runPlaywright({
70
+ rootDir: E2E_ROOT_DIR,
71
+ configFile: E2E_CONFIG_FILE,
72
+ specsDir: E2E_SPECS_DIR,
73
+ parsed,
74
+ });
75
+
76
+ // eslint-disable-next-line no-console
77
+ console.log(`E2E target: ${E2E_LOCAL_BASE_URL}`);
48
78
  }
49
79
 
50
- const playwrightCommand = resolve(
51
- E2E_ROOT_DIR,
52
- 'node_modules',
53
- '.bin',
54
- process.platform === 'win32' ? 'playwright.cmd' : 'playwright',
55
- );
56
- const specPaths = parsed.specNames.map(name => resolve(E2E_SPECS_DIR, `${name}.spec.ts`));
57
- const playwrightArgs = combineGreps(parsed.playwrightArgs, parsed.tags);
58
- run(playwrightCommand, ['test', '--config', E2E_CONFIG_FILE, ...specPaths, ...playwrightArgs]);
59
-
60
- // eslint-disable-next-line no-console
61
- console.log(`E2E target: ${E2E_LOCAL_BASE_URL}`);
80
+ if (import.meta.main) await main();
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { resolve } from 'node:path';
5
5
  import test from 'node:test';
6
6
 
7
- import { buildTagGrep, combineGreps, parseE2eArgs } from './runE2eArgs.ts';
7
+ import { buildSpecFilter, buildTagGrep, combineGreps, parseE2eArgs } from './runE2eArgs.ts';
8
8
 
9
9
  function withSpecs(callback: (specsDir: string) => void): void {
10
10
  const tempDir = mkdtempSync(resolve(tmpdir(), 'cabloy-e2e-args-'));
@@ -70,6 +70,21 @@ test('rejects removed clean and fast options', () => {
70
70
  });
71
71
  });
72
72
 
73
+ test('spec filters normalize Windows paths and escape regex syntax literally', () => {
74
+ for (const specPath of [
75
+ '/synthetic/café 中文 [work]+(tree).$/specs/cabloy-basic.spec.ts',
76
+ 'C:\\synthetic\\café 中文 [work]+(tree).$\\specs\\cabloy-basic.spec.ts',
77
+ '\\\\server\\share with spaces\\café [work]\\specs\\a-commerce.spec.ts',
78
+ ]) {
79
+ const normalized = specPath.replaceAll('\\', '/');
80
+ const filter = new RegExp(buildSpecFilter(specPath));
81
+ assert.match(normalized, filter);
82
+ assert.doesNotMatch(`${normalized}.unselected`, filter);
83
+ assert.doesNotMatch(`prefix${normalized}`, filter);
84
+ assert.doesNotMatch(normalized.replace('.spec.ts', 'XspecXts'), filter);
85
+ }
86
+ });
87
+
73
88
  test('combines tags with an existing grep expression', () => {
74
89
  assert.equal(buildTagGrep(['@web', '@smoke']), '(?=.*@web)(?=.*@smoke)');
75
90
  assert.deepEqual(combineGreps(['--grep', '@flow'], ['@web', '@smoke']), [
@@ -36,6 +36,11 @@ function escapeRegExp(value: string): string {
36
36
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
37
  }
38
38
 
39
+ /** Playwright interprets positional file filters as regexes, including on Windows. */
40
+ export function buildSpecFilter(specPath: string): string {
41
+ return `^${escapeRegExp(specPath.replaceAll('\\', '/'))}$`;
42
+ }
43
+
39
44
  export function buildTagGrep(tags: string[]): string | undefined {
40
45
  if (tags.length === 0) return undefined;
41
46
  return tags.map(tag => `(?=.*${escapeRegExp(tag)})`).join('');