cabloy 5.1.203 → 5.1.205

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 (127) hide show
  1. package/.cabloy-version +1 -1
  2. package/.gitignore +2 -0
  3. package/CHANGELOG.md +21 -0
  4. package/lint-staged.config.mjs +2 -0
  5. package/oxfmt.config.ts +2 -0
  6. package/oxlint.config.ts +2 -0
  7. package/package.json +6 -5
  8. package/repo-agent-governance/managed-assets.json +9 -9
  9. package/repo-agent-governance/policies/repository.md +5 -2
  10. package/repo-agent-governance/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +2 -2
  11. package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +2 -0
  12. package/repo-docs/backend/migration-and-changes.md +13 -12
  13. package/repo-docs/backend/orm-select-guide.md +6 -0
  14. package/repo-docs/backend/runtime-and-flavors.md +2 -1
  15. package/repo-docs/backend/transaction-guide.md +12 -0
  16. package/repo-docs/backend/unit-testing.md +12 -8
  17. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/cover-en-v1.png +0 -0
  18. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/completion-summary.jpg +0 -0
  19. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/multitasking-draft.jpg +0 -0
  20. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/images/ssr-refresh.jpg +0 -0
  21. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/index.md +216 -0
  22. package/repo-docs/blogs/index.md +15 -0
  23. package/repo-docs/frontend/scripts.md +1 -1
  24. package/repo-docs/fullstack/parallel-worktree-environment.md +6 -6
  25. package/repo-docs/fullstack/ssr-entry-modes.md +19 -0
  26. package/repo-docs/index.md +2 -0
  27. package/repo-docs/reference/repo-scripts.md +22 -1
  28. package/repo-e2e/config/playwright.config.ts +32 -14
  29. package/repo-e2e/docs/specs/blogs-index.spec.ts +23 -6
  30. package/repo-e2e/scripts/e2e.test.ts +39 -0
  31. package/repo-e2e/scripts/e2e.ts +19 -1
  32. package/repo-e2e/scripts/managedTest.test.ts +389 -0
  33. package/repo-e2e/scripts/managedTest.ts +210 -0
  34. package/repo-e2e/scripts/managedTestImports.test.ts +74 -0
  35. package/repo-e2e/scripts/managedTestImports.ts +110 -0
  36. package/repo-e2e/scripts/runE2e.ts +12 -1
  37. package/repo-e2e/scripts/startE2eVona.ts +37 -5
  38. package/repo-e2e/specs/a-commerce.spec.ts +1 -1
  39. package/repo-e2e/specs/cabloy-basic.spec.ts +1 -1
  40. package/repo-e2e/specs/controller-boundary.spec.ts +1 -1
  41. package/repo-e2e/specs/home-user-account.spec.ts +2 -1
  42. package/repo-e2e/specs/markdown.spec.ts +1 -1
  43. package/repo-e2e/specs/routed-dialog.spec.ts +1 -1
  44. package/scripts/runIsolatedTest.test.ts +426 -0
  45. package/scripts/runIsolatedTest.ts +454 -0
  46. package/scripts/testClaimCleanup.test.ts +346 -0
  47. package/scripts/testClaimCleanup.ts +375 -0
  48. package/scripts/testClaimProcesses.test.ts +106 -0
  49. package/scripts/testClaimProcesses.ts +129 -0
  50. package/scripts/testProcessGuardian.ts +163 -0
  51. package/scripts/upgrade.ts +1 -16
  52. package/vona/oxfmt.config.ts +2 -0
  53. package/vona/oxlint.config.ts +2 -0
  54. package/vona/package.original.json +2 -2
  55. package/vona/packages-cli/cli/package.json +1 -1
  56. package/vona/packages-cli/cli/src/play.test.ts +37 -0
  57. package/vona/packages-cli/cli/src/play.ts +23 -1
  58. package/vona/packages-cli/cli-set-api/package.json +1 -1
  59. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dbReset.ts +16 -7
  60. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +13 -7
  61. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.play.ts +5 -1
  62. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.test.ts +12 -7
  63. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/configUtils.ts +21 -2
  64. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/generateVonaMeta.ts +10 -0
  65. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.test.ts +153 -0
  66. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.ts +141 -0
  67. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.test.ts +148 -0
  68. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.ts +127 -0
  69. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.test.ts +47 -0
  70. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.ts +139 -0
  71. package/vona/packages-cli/cli-set-api/src/lib/utils.ts +2 -3
  72. package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_project.json +1 -0
  73. package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_source.json +1 -0
  74. package/vona/packages-cli/cli-set-api/test/binDevMode.test.ts +18 -0
  75. package/vona/packages-cli/cli-set-api/toolsIsolate/dbReset.ts +15 -2
  76. package/vona/packages-cli/cli-set-api/tsconfig.build.json +2 -1
  77. package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +1 -1
  78. package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.test.ts +16 -0
  79. package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.ts +3 -2
  80. package/vona/packages-vona/vona/package.json +1 -1
  81. package/vona/packages-vona/vona-core/package.json +1 -1
  82. package/vona/packages-vona/vona-core/src/lib/addon/sqlite3.ts +1 -1
  83. package/vona/packages-vona/vona-core/src/lib/core/config.ts +44 -6
  84. package/vona/packages-vona/vona-core/src/lib/core/configCleanup.test.ts +40 -0
  85. package/vona/packages-vona/vona-core/src/lib/core/index.ts +3 -0
  86. package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.test.ts +301 -0
  87. package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.ts +312 -0
  88. package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +577 -0
  89. package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +985 -0
  90. package/vona/packages-vona/vona-core/src/lib/core/testIsolation.ts +129 -0
  91. package/vona/packages-vona/vona-core/src/lib/framework/createApp.ts +2 -0
  92. package/vona/packages-vona/vona-core/src/lib/framework/start.ts +4 -0
  93. package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +5 -1
  94. package/vona/packages-vona/vona-core/src/types/application/app.ts +2 -0
  95. package/vona/packages-vona/vona-core/src/types/utils/env.ts +1 -0
  96. package/vona/packages-vona/vona-mock/package.json +1 -1
  97. package/vona/pnpm-lock.yaml +40 -40
  98. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentIndexes.test.ts +73 -10
  99. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  100. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  101. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  102. package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
  103. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +15 -1
  104. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +24 -0
  105. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +11 -0
  106. package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
  107. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +15 -0
  108. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  109. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/extend/schemaBuilder.ts +11 -4
  110. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/databaseClient_.ts +6 -1
  111. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/package.json +1 -1
  112. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.betterSqlite3.ts +41 -1
  113. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.mysql.ts +4 -1
  114. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.pg.ts +2 -1
  115. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  116. package/vona/src/suite-vendor/a-vona/modules/a-redis/package.json +1 -1
  117. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/lib/redis.ts +8 -0
  118. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redis.ts +23 -6
  119. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redisClient_.ts +18 -1
  120. package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
  121. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +43 -15
  122. package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
  123. package/vona/src/suite-vendor/a-vona/modules/a-version/src/lib/databaseTestName.ts +3 -37
  124. package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +93 -15
  125. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  126. package/vona/tsconfig.json +1 -0
  127. package/scripts/initTestData.ts +0 -25
@@ -0,0 +1,389 @@
1
+ import type { Browser, BrowserServer, BrowserType, ConnectOptions } from '@playwright/test';
2
+ import type { ChildProcess } from 'node:child_process';
3
+
4
+ import { test as vanillaTest } from '@playwright/test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import test from 'node:test';
10
+
11
+ import type { TestClaimEvent } from '../../vona/packages-vona/vona-core/src/lib/core/testClaim.ts';
12
+
13
+ import { discoverBrowserProfile, test as managedTest, useManagedBrowser } from './managedTest.ts';
14
+
15
+ const runId = '20261014010203-a1b2c3d4';
16
+
17
+ async function fixture(
18
+ options: {
19
+ fault?: string;
20
+ profile?: string;
21
+ group?: number;
22
+ noProfile?: boolean;
23
+ exited?: boolean;
24
+ } = {},
25
+ ) {
26
+ const root = await realpath(await mkdtemp(join(tmpdir(), 'cabloy-managed-browser-')));
27
+ const projectPath = join(root, 'vona');
28
+ const dataRoot = join(projectPath, `.app-test-${runId}`);
29
+ const temporaryRoot = join(dataRoot, 'tmp');
30
+ await mkdir(temporaryRoot, { recursive: true });
31
+ await writeFile(join(dataRoot, '.vona-test-owner'), `${runId}\n`);
32
+ const events: TestClaimEvent[] = [];
33
+ const calls: string[] = [];
34
+ const profile = options.profile ?? join(temporaryRoot, 'playwright_chromiumdev_profile-fake');
35
+ const browser = { newContext: async () => ({ close: async () => {} }) } as unknown as Browser;
36
+ let launchedOptions: Parameters<BrowserType['launchServer']>[0];
37
+ let connectedOptions: ConnectOptions | undefined;
38
+ const server = {
39
+ process: () =>
40
+ ({
41
+ pid: 4242,
42
+ exitCode: options.exited ? 1 : null,
43
+ signalCode: null,
44
+ spawnargs: options.noProfile
45
+ ? ['webkit', '--no-startup-window']
46
+ : ['chromium', `--user-data-dir=${profile}`],
47
+ }) as ChildProcess,
48
+ wsEndpoint: () => 'ws://127.0.0.1/fake-token-never-journaled',
49
+ close: async () => {
50
+ calls.push('close');
51
+ if (options.fault === 'close') throw new Error('close failed');
52
+ },
53
+ } as unknown as BrowserServer;
54
+ const browserType = {
55
+ launchServer: async (launchOptions: Parameters<BrowserType['launchServer']>[0]) => {
56
+ calls.push('launch');
57
+ launchedOptions = launchOptions;
58
+ if (options.fault === 'launch') throw new Error('launch failed after possible spawn');
59
+ return server;
60
+ },
61
+ connect: async (_endpoint: string, connectOptions: ConnectOptions | undefined) => {
62
+ calls.push('connect');
63
+ connectedOptions = connectOptions;
64
+ if (options.fault === 'connect') throw new Error('connect failed');
65
+ return browser;
66
+ },
67
+ };
68
+ const dependencies = {
69
+ env: {
70
+ META_MODE: 'test',
71
+ VONA_TEST_RUN_ID: runId,
72
+ VONA_TEST_CLAIM_URL: 'http://127.0.0.1:1234/event',
73
+ VONA_TEST_CLAIM_TOKEN: 'a'.repeat(64),
74
+ TMPDIR: temporaryRoot,
75
+ },
76
+ projectPath,
77
+ sendEvent: async (_env: NodeJS.ProcessEnv, event: TestClaimEvent) => {
78
+ const stage = event.type === 'process' ? event.process.stage : event.type;
79
+ calls.push(stage);
80
+ if (options.fault === stage) throw new Error(`${stage} acknowledgement failed`);
81
+ events.push(structuredClone(event));
82
+ },
83
+ inspectProcess: async (pid: number) => {
84
+ calls.push('inspect');
85
+ if (options.fault === 'inspect') throw new Error('inspection failed');
86
+ return {
87
+ pid,
88
+ pgid: options.group ?? pid,
89
+ startTime: 'Wed Oct 14 01:02:03 2026',
90
+ host: 'test-host',
91
+ state: 'S',
92
+ args: '',
93
+ };
94
+ },
95
+ waitForGroupExit: async (pgid: number) => {
96
+ calls.push('wait');
97
+ assert.equal(pgid, 4242);
98
+ if (options.fault === 'wait') throw new Error('group still live or inspection failed');
99
+ },
100
+ };
101
+ return {
102
+ root,
103
+ dataRoot,
104
+ temporaryRoot,
105
+ dependencies,
106
+ events,
107
+ calls,
108
+ browser,
109
+ browserOptions: { browserType, launchOptions: {}, headless: true, channel: undefined },
110
+ launchedOptions: () => launchedOptions,
111
+ connectedOptions: () => connectedOptions,
112
+ dispose: () => rm(root, { recursive: true, force: true }),
113
+ };
114
+ }
115
+
116
+ if (process.env.VONA_TEST_RUN_ID === undefined) {
117
+ test('unmanaged application imports preserve the exact vanilla Playwright fixtures', () => {
118
+ assert.equal(managedTest, vanillaTest);
119
+ });
120
+ }
121
+
122
+ test('registers launch intent and complete identity before use; closes and drains before reporting', async () => {
123
+ const f = await fixture();
124
+ try {
125
+ await useManagedBrowser(
126
+ f.browserOptions,
127
+ async browser => {
128
+ f.calls.push('use');
129
+ assert.equal(browser, f.browser);
130
+ const extraContext = await browser.newContext();
131
+ await extraContext.close();
132
+ },
133
+ f.dependencies,
134
+ );
135
+ assert.deepEqual(f.calls, [
136
+ 'pending',
137
+ 'launch',
138
+ 'inspect',
139
+ 'registered',
140
+ 'connect',
141
+ 'use',
142
+ 'close',
143
+ 'wait',
144
+ 'drained',
145
+ ]);
146
+ assert.equal(f.events.length, 3);
147
+ const records = f.events.map(event => {
148
+ assert.equal(event.type, 'process');
149
+ return (event as Extract<TestClaimEvent, { type: 'process' }>).process;
150
+ });
151
+ assert.equal(records[0].profilePath, f.temporaryRoot);
152
+ assert.equal(records[1].pid, 4242);
153
+ assert.equal(records[1].pgid, 4242);
154
+ assert.equal(records[1].host, 'test-host');
155
+ assert.equal(records[1].startTime, 'Wed Oct 14 01:02:03 2026');
156
+ assert.equal(
157
+ records[1].profilePath,
158
+ join(f.temporaryRoot, 'playwright_chromiumdev_profile-fake'),
159
+ );
160
+ assert.equal(records[0].id, records[1].id);
161
+ assert.equal(records[1].id, records[2].id);
162
+ assert.doesNotMatch(JSON.stringify(f.events), /private-token|fake-token|ws:\/\//);
163
+ } finally {
164
+ await f.dispose();
165
+ }
166
+ });
167
+
168
+ test('preserves public launch options, explicit headless/channel overrides, and slowMo', async () => {
169
+ const f = await fixture();
170
+ try {
171
+ const args = ['--disable-dev-shm-usage'];
172
+ const launchOptions = {
173
+ args,
174
+ channel: 'chrome',
175
+ headless: true,
176
+ slowMo: 17,
177
+ timeout: 1234,
178
+ env: { CUSTOM_BROWSER_ENV: 'keep', TMPDIR: '/unowned' },
179
+ };
180
+ await useManagedBrowser(
181
+ { ...f.browserOptions, launchOptions, headless: false, channel: 'msedge' },
182
+ async () => {},
183
+ f.dependencies,
184
+ );
185
+ assert.deepEqual(f.launchedOptions(), {
186
+ handleSIGINT: false,
187
+ args,
188
+ channel: 'msedge',
189
+ headless: false,
190
+ timeout: 1234,
191
+ env: { CUSTOM_BROWSER_ENV: 'keep', TMPDIR: f.temporaryRoot },
192
+ });
193
+ assert.deepEqual(f.connectedOptions(), { slowMo: 17 });
194
+ assert.equal(launchOptions.env.TMPDIR, '/unowned');
195
+ } finally {
196
+ await f.dispose();
197
+ }
198
+ });
199
+
200
+ for (const fault of ['pending', 'launch', 'inspect', 'registered', 'close', 'wait', 'drained']) {
201
+ test(`retains uncertain process evidence on ${fault} failure`, async () => {
202
+ const f = await fixture({ fault });
203
+ try {
204
+ await assert.rejects(useManagedBrowser(f.browserOptions, async () => {}, f.dependencies));
205
+ assert.ok(
206
+ !f.events.some(event => event.type === 'process' && event.process.stage === 'drained'),
207
+ );
208
+ if (fault === 'pending') assert.ok(!f.calls.includes('launch'));
209
+ if (['launch', 'inspect', 'registered'].includes(fault)) {
210
+ assert.deepEqual(
211
+ f.events.map(event => event.type === 'process' && event.process.stage),
212
+ ['pending'],
213
+ );
214
+ assert.ok(!f.calls.includes('use'));
215
+ }
216
+ if (['inspect', 'registered'].includes(fault)) assert.ok(f.calls.includes('close'));
217
+ if (fault === 'close') assert.ok(!f.calls.includes('wait'));
218
+ } finally {
219
+ await f.dispose();
220
+ }
221
+ });
222
+ }
223
+
224
+ for (const failure of ['connect', 'use']) {
225
+ test(`closes and verifies the registered group on ${failure} failure`, async () => {
226
+ const f = await fixture({ fault: failure });
227
+ try {
228
+ await assert.rejects(
229
+ useManagedBrowser(
230
+ f.browserOptions,
231
+ async () => {
232
+ throw new Error('use failed');
233
+ },
234
+ f.dependencies,
235
+ ),
236
+ );
237
+ assert.deepEqual(f.calls.slice(-3), ['close', 'wait', 'drained']);
238
+ } finally {
239
+ await f.dispose();
240
+ }
241
+ });
242
+ }
243
+
244
+ test('refuses a shared browser group before connection or drainage acknowledgement', async () => {
245
+ const f = await fixture({ group: 1234 });
246
+ try {
247
+ await assert.rejects(
248
+ useManagedBrowser(f.browserOptions, async () => {}, f.dependencies),
249
+ /independent live process group/,
250
+ );
251
+ assert.deepEqual(f.calls, ['pending', 'launch', 'inspect', 'close']);
252
+ } finally {
253
+ await f.dispose();
254
+ }
255
+ });
256
+
257
+ test('refuses an actual profile escaping the pending run area', async () => {
258
+ const f = await fixture({ profile: '/tmp/unowned-browser-profile' });
259
+ try {
260
+ await assert.rejects(
261
+ useManagedBrowser(f.browserOptions, async () => {}, f.dependencies),
262
+ /outside its run-owned area/,
263
+ );
264
+ assert.deepEqual(f.calls, ['pending', 'launch', 'inspect', 'close']);
265
+ } finally {
266
+ await f.dispose();
267
+ }
268
+ });
269
+
270
+ for (const invalid of ['tmpdir', 'owner', 'transport', 'connect', 'symlink']) {
271
+ test(`refuses invalid ${invalid} before launching`, async () => {
272
+ const f = await fixture();
273
+ try {
274
+ if (invalid === 'tmpdir') f.dependencies.env.TMPDIR = '/tmp/unowned';
275
+ if (invalid === 'owner')
276
+ await writeFile(join(f.dataRoot, '.vona-test-owner'), 'another-run\n');
277
+ if (invalid === 'transport') f.dependencies.env.VONA_TEST_CLAIM_TOKEN = '';
278
+ if (invalid === 'symlink') {
279
+ await rm(f.temporaryRoot, { recursive: true });
280
+ await symlink(f.root, f.temporaryRoot);
281
+ }
282
+ const options =
283
+ invalid === 'connect'
284
+ ? { ...f.browserOptions, connectOptions: { wsEndpoint: 'ws://external/browser' } }
285
+ : f.browserOptions;
286
+ await assert.rejects(useManagedBrowser(options, async () => {}, f.dependencies));
287
+ assert.deepEqual(f.calls, []);
288
+ } finally {
289
+ await f.dispose();
290
+ }
291
+ });
292
+ }
293
+
294
+ test('registers the run-owned profile area when a browser has no argv profile flag', async () => {
295
+ const f = await fixture({ noProfile: true });
296
+ try {
297
+ await useManagedBrowser(f.browserOptions, async () => {}, f.dependencies);
298
+ assert.ok(
299
+ f.events.every(
300
+ event => event.type === 'process' && event.process.profilePath === f.temporaryRoot,
301
+ ),
302
+ );
303
+ } finally {
304
+ await f.dispose();
305
+ }
306
+ });
307
+
308
+ test('does not register an already-exited child as a potentially reused PID', async () => {
309
+ const f = await fixture({ exited: true });
310
+ try {
311
+ await assert.rejects(
312
+ useManagedBrowser(f.browserOptions, async () => {}, f.dependencies),
313
+ /no live process identity/,
314
+ );
315
+ assert.deepEqual(f.calls, ['pending', 'launch', 'close']);
316
+ } finally {
317
+ await f.dispose();
318
+ }
319
+ });
320
+
321
+ for (const area of ['downloadsPath', 'tracesDir', 'artifactsDir'] as const) {
322
+ test(`allows owned ${area} and rejects external ${area} before launch`, async () => {
323
+ const f = await fixture();
324
+ try {
325
+ await assert.rejects(
326
+ useManagedBrowser(
327
+ { ...f.browserOptions, launchOptions: { [area]: '/unowned/browser-output' } },
328
+ async () => {},
329
+ f.dependencies,
330
+ ),
331
+ /outside its run-owned area/,
332
+ );
333
+ assert.deepEqual(f.calls, []);
334
+ const owned = join(f.dataRoot, area);
335
+ await useManagedBrowser(
336
+ { ...f.browserOptions, launchOptions: { [area]: owned } },
337
+ async () => {},
338
+ f.dependencies,
339
+ );
340
+ assert.equal(f.launchedOptions()?.[area], owned);
341
+ } finally {
342
+ await f.dispose();
343
+ }
344
+ });
345
+ }
346
+
347
+ test('rejects an explicit external profile argument before launch', async () => {
348
+ const f = await fixture();
349
+ try {
350
+ await assert.rejects(
351
+ useManagedBrowser(
352
+ {
353
+ ...f.browserOptions,
354
+ launchOptions: { args: ['--user-data-dir=/unowned/profile'], ignoreDefaultArgs: true },
355
+ },
356
+ async () => {},
357
+ f.dependencies,
358
+ ),
359
+ /outside its run-owned area/,
360
+ );
361
+ assert.deepEqual(f.calls, []);
362
+ } finally {
363
+ await f.dispose();
364
+ }
365
+ });
366
+
367
+ test('discovers exact Chromium and Firefox argv profiles; uses the temporary area for WebKit', () => {
368
+ assert.equal(
369
+ discoverBrowserProfile(['browser', '--user-data-dir=/run/tmp/with spaces']),
370
+ '/run/tmp/with spaces',
371
+ );
372
+ assert.equal(
373
+ discoverBrowserProfile(['browser', '--user-data-dir', '/run/tmp/chromium']),
374
+ '/run/tmp/chromium',
375
+ );
376
+ assert.equal(
377
+ discoverBrowserProfile(['browser', '-profile', '/run/tmp/firefox']),
378
+ '/run/tmp/firefox',
379
+ );
380
+ assert.equal(
381
+ discoverBrowserProfile(['browser', '--profile=/run/tmp/firefox']),
382
+ '/run/tmp/firefox',
383
+ );
384
+ assert.equal(
385
+ discoverBrowserProfile(['browser', '--profile', '/run/tmp/firefox with spaces']),
386
+ '/run/tmp/firefox with spaces',
387
+ );
388
+ assert.equal(discoverBrowserProfile(['browser', '--no-startup-window']), undefined);
389
+ });
@@ -0,0 +1,210 @@
1
+ import type {
2
+ Browser,
3
+ BrowserType,
4
+ ConnectOptions,
5
+ LaunchOptions,
6
+ PlaywrightWorkerOptions,
7
+ } from '@playwright/test';
8
+
9
+ import { expect, test as baseTest } from '@playwright/test';
10
+ import { randomUUID } from 'node:crypto';
11
+ import { lstatSync } from 'node:fs';
12
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ import type { TestProcessSnapshot } from '../../scripts/testClaimProcesses.ts';
16
+ import type {
17
+ TestClaimEvent,
18
+ TestClaimProcess,
19
+ } from '../../vona/packages-vona/vona-core/src/lib/core/testClaim.ts';
20
+
21
+ import {
22
+ inspectTestProcess,
23
+ waitForTestProcessGroupExit,
24
+ } from '../../scripts/testClaimProcesses.ts';
25
+ import {
26
+ assertTestClaimSafePath,
27
+ getTestClaimPaths,
28
+ hasTestClaimTransport,
29
+ sendTestClaimEvent,
30
+ } from '../../vona/packages-vona/vona-core/src/lib/core/testClaim.ts';
31
+ import { assertTestRunOwner } from '../../vona/packages-vona/vona-core/src/lib/core/testIsolation.ts';
32
+
33
+ const projectPath = fileURLToPath(new URL('../../vona', import.meta.url));
34
+
35
+ interface ManagedBrowserType {
36
+ launchServer: BrowserType['launchServer'];
37
+ connect: (wsEndpoint: string, options?: ConnectOptions) => Promise<Browser>;
38
+ }
39
+ interface ManagedBrowserOptions {
40
+ browserType: ManagedBrowserType;
41
+ launchOptions: LaunchOptions;
42
+ headless: boolean;
43
+ channel: string | undefined;
44
+ connectOptions?: PlaywrightWorkerOptions['connectOptions'];
45
+ }
46
+ interface ManagedBrowserDependencies {
47
+ env: NodeJS.ProcessEnv;
48
+ projectPath: string;
49
+ sendEvent: (env: NodeJS.ProcessEnv, event: TestClaimEvent) => Promise<void>;
50
+ inspectProcess: (pid: number) => Promise<TestProcessSnapshot>;
51
+ waitForGroupExit: (pgid: number) => Promise<void>;
52
+ }
53
+
54
+ function assertWithin(root: string, target: string) {
55
+ const tail = relative(root, target);
56
+ if (
57
+ !isAbsolute(target) ||
58
+ resolve(target) !== target ||
59
+ tail === '..' ||
60
+ tail.startsWith(`..${sep}`) ||
61
+ isAbsolute(tail)
62
+ ) {
63
+ throw new Error('Managed browser path is outside its run-owned area');
64
+ }
65
+ assertTestClaimSafePath(target);
66
+ }
67
+
68
+ /** Public ChildProcess.spawnargs preserves argument boundaries, unlike ps output. */
69
+ export function discoverBrowserProfile(spawnargs: readonly string[]): string | undefined {
70
+ for (let index = 0; index < spawnargs.length; index++) {
71
+ const argument = spawnargs[index];
72
+ if (argument.startsWith('--user-data-dir=')) return argument.slice('--user-data-dir='.length);
73
+ if (['--user-data-dir', '--profile', '-profile'].includes(argument))
74
+ return spawnargs[index + 1];
75
+ if (argument.startsWith('--profile=') || argument.startsWith('-profile='))
76
+ return argument.slice(argument.indexOf('=') + 1);
77
+ }
78
+ // ps flattens argument boundaries and cannot prove a path containing spaces.
79
+ // When no public argv flag is available (e.g. nonpersistent WebKit), retain
80
+ // the inherited run-owned temporary area as the profile boundary.
81
+ return undefined;
82
+ }
83
+
84
+ /** Exposed for focused tests with fake launchers; application specs use test below. */
85
+ export async function useManagedBrowser(
86
+ options: ManagedBrowserOptions,
87
+ use: (browser: Browser) => Promise<void>,
88
+ dependencies: ManagedBrowserDependencies = {
89
+ env: process.env,
90
+ projectPath,
91
+ sendEvent: sendTestClaimEvent,
92
+ inspectProcess: inspectTestProcess,
93
+ waitForGroupExit: waitForTestProcessGroupExit,
94
+ },
95
+ ) {
96
+ const { env } = dependencies;
97
+ const runId = env.VONA_TEST_RUN_ID;
98
+ if (!runId || !hasTestClaimTransport(env)) {
99
+ throw new Error('Managed browser requires an isolated test claim transport');
100
+ }
101
+ if (options.connectOptions)
102
+ throw new Error('Managed E2E cannot use an external browser connection');
103
+ const { dataRoot } = getTestClaimPaths(dependencies.projectPath, runId);
104
+ const temporaryRoot = resolve(dataRoot, 'tmp');
105
+ assertTestRunOwner(dataRoot, runId);
106
+ if (env.TMPDIR !== temporaryRoot || !lstatSync(temporaryRoot).isDirectory()) {
107
+ throw new Error('Managed browser requires the inherited run-owned TMPDIR');
108
+ }
109
+ assertWithin(dataRoot, temporaryRoot);
110
+
111
+ const { slowMo, ...launchOptions } = options.launchOptions;
112
+ const requestedProfile = discoverBrowserProfile(launchOptions.args ?? []);
113
+ if (requestedProfile !== undefined) assertWithin(temporaryRoot, requestedProfile);
114
+ for (const area of [
115
+ launchOptions.downloadsPath,
116
+ launchOptions.tracesDir,
117
+ launchOptions.artifactsDir,
118
+ ]) {
119
+ if (area !== undefined) assertWithin(dataRoot, area);
120
+ }
121
+ // Browser profile creation happens in the Playwright worker's inherited
122
+ // TMPDIR. A custom browser environment must not redirect child temp writes.
123
+ if (launchOptions.env) launchOptions.env = { ...launchOptions.env, TMPDIR: temporaryRoot };
124
+ const processRecord: TestClaimProcess = {
125
+ id: randomUUID(),
126
+ role: 'browser',
127
+ stage: 'pending',
128
+ profilePath: temporaryRoot,
129
+ };
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 });
133
+ const server = await options.browserType.launchServer({
134
+ handleSIGINT: false,
135
+ ...launchOptions,
136
+ headless: options.headless,
137
+ ...(options.channel === undefined ? {} : { channel: options.channel }),
138
+ });
139
+ let registration: TestClaimProcess | undefined;
140
+ try {
141
+ const child = server.process();
142
+ if (!child.pid || child.exitCode !== null || child.signalCode !== null) {
143
+ throw new Error('Managed browser server has no live process identity');
144
+ }
145
+ const identity = await dependencies.inspectProcess(child.pid);
146
+ if (
147
+ child.exitCode !== null ||
148
+ child.signalCode !== null ||
149
+ identity.pid !== child.pid ||
150
+ identity.pgid !== child.pid ||
151
+ !identity.startTime ||
152
+ identity.state.startsWith('Z')
153
+ ) {
154
+ throw new Error('Managed browser has no independent live process group');
155
+ }
156
+ const profilePath = discoverBrowserProfile(child.spawnargs) ?? temporaryRoot;
157
+ 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;
169
+ const browser = await options.browserType.connect(server.wsEndpoint(), { slowMo });
170
+ await use(browser);
171
+ } finally {
172
+ // Never turn a pending launch into drainage proof, even if a local close
173
+ // succeeds: the runner may not have acknowledged its process identity.
174
+ await server.close();
175
+ if (registration) {
176
+ await dependencies.waitForGroupExit(registration.pgid!);
177
+ await dependencies.sendEvent(env, {
178
+ type: 'process',
179
+ process: { ...registration, stage: 'drained' },
180
+ });
181
+ }
182
+ }
183
+ }
184
+
185
+ // Unmanaged invocations keep Playwright's exact original fixtures, including
186
+ // remote connections, test options, and additional browser.newContext() calls.
187
+ export const test =
188
+ process.env.VONA_TEST_RUN_ID === undefined
189
+ ? baseTest
190
+ : baseTest.extend({
191
+ browser: [
192
+ async (
193
+ { playwright, browserName, launchOptions, headless, channel, connectOptions },
194
+ use,
195
+ ) => {
196
+ await useManagedBrowser(
197
+ {
198
+ browserType: playwright[browserName],
199
+ launchOptions,
200
+ headless,
201
+ channel,
202
+ connectOptions,
203
+ },
204
+ use,
205
+ );
206
+ },
207
+ { scope: 'worker', timeout: 0 },
208
+ ],
209
+ });
210
+ export { expect };
@@ -0,0 +1,74 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { relative, resolve } from 'node:path';
5
+ import test from 'node:test';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ import {
9
+ assertManagedTestImports,
10
+ managedTestImportViolations as violations,
11
+ } from './managedTestImports.ts';
12
+
13
+ const fixturePath = fileURLToPath(new URL('./managedTest.ts', import.meta.url));
14
+ const specRoot = fileURLToPath(new URL('../specs', import.meta.url));
15
+
16
+ test('all discovered application specs route runtime Playwright imports through the managed fixture', async () => {
17
+ await assertManagedTestImports();
18
+ });
19
+
20
+ test('fixture import gate allows type-only imports and catches runtime bypass forms', () => {
21
+ const spec = resolve(specRoot, 'example.spec.ts');
22
+ const managed = "import { test, expect } from '../scripts/managedTest.ts';\n";
23
+ for (const allowed of [
24
+ "import type { Page } from '@playwright/test';",
25
+ "import { type Page, type Browser } from '@playwright/test';",
26
+ "type Response = import('@playwright/test').Response;",
27
+ "export type { Page } from '@playwright/test';",
28
+ "export { type Page } from '@playwright/test';",
29
+ ])
30
+ assert.deepEqual(violations(managed + allowed, spec), []);
31
+ for (const bypass of [
32
+ "import { test } from '@playwright/test';",
33
+ "import { type Page, expect } from '@playwright/test';",
34
+ "import * as playwright from '@playwright/test';",
35
+ "import '@playwright/test';",
36
+ "export { test } from '@playwright/test';",
37
+ "export * from '@playwright/test';",
38
+ "const pw = await import('@playwright/test');",
39
+ "const pw = require('@playwright/test');",
40
+ "import pw = require('@playwright/test');",
41
+ "import { chromium } from 'playwright';",
42
+ "import { chromium } from 'playwright-core';",
43
+ ])
44
+ assert.equal(violations(managed + bypass, spec).length, 1, bypass);
45
+ assert.deepEqual(violations("import type { test } from '../scripts/managedTest.ts';", spec), [
46
+ 'application spec must import test from managedTest',
47
+ ]);
48
+ assert.deepEqual(
49
+ violations("import { test as appTest } from '../scripts/managedTest.ts';", spec),
50
+ [],
51
+ );
52
+ });
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-'));
56
+ try {
57
+ await assert.rejects(assertManagedTestImports(root), /no specs/);
58
+ const nested = resolve(root, 'nested');
59
+ await mkdir(nested);
60
+ const spec = resolve(nested, 'application.spec.ts');
61
+ await writeFile(spec, "import { test } from '@playwright/test';");
62
+ await assert.rejects(
63
+ assertManagedTestImports(root),
64
+ /fixture import violation.*application\.spec\.ts/,
65
+ );
66
+ const managedPath = relative(nested, fixturePath).replaceAll('\\', '/');
67
+ await writeFile(spec, `import { test } from ${JSON.stringify(managedPath)};`);
68
+ await assertManagedTestImports(root);
69
+ await symlink(spec, resolve(root, 'linked.spec.ts'));
70
+ await assert.rejects(assertManagedTestImports(root), /cannot contain symlinks/);
71
+ } finally {
72
+ await rm(root, { recursive: true, force: true });
73
+ }
74
+ });