cabloy 5.1.204 → 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 (117) hide show
  1. package/.cabloy-version +1 -1
  2. package/.gitignore +2 -0
  3. package/CHANGELOG.md +11 -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 +2 -3
  8. package/repo-agent-governance/managed-assets.json +6 -6
  9. package/repo-agent-governance/policies/repository.md +3 -2
  10. package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +2 -0
  11. package/repo-docs/backend/migration-and-changes.md +13 -12
  12. package/repo-docs/backend/runtime-and-flavors.md +2 -1
  13. package/repo-docs/backend/unit-testing.md +12 -8
  14. package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/index.md +9 -9
  15. package/repo-docs/frontend/scripts.md +1 -1
  16. package/repo-docs/fullstack/parallel-worktree-environment.md +6 -6
  17. package/repo-docs/fullstack/ssr-entry-modes.md +19 -0
  18. package/repo-docs/reference/repo-scripts.md +22 -1
  19. package/repo-e2e/config/playwright.config.ts +32 -14
  20. package/repo-e2e/scripts/e2e.test.ts +39 -0
  21. package/repo-e2e/scripts/e2e.ts +19 -1
  22. package/repo-e2e/scripts/managedTest.test.ts +389 -0
  23. package/repo-e2e/scripts/managedTest.ts +210 -0
  24. package/repo-e2e/scripts/managedTestImports.test.ts +74 -0
  25. package/repo-e2e/scripts/managedTestImports.ts +110 -0
  26. package/repo-e2e/scripts/runE2e.ts +12 -1
  27. package/repo-e2e/scripts/startE2eVona.ts +37 -5
  28. package/repo-e2e/specs/a-commerce.spec.ts +1 -1
  29. package/repo-e2e/specs/cabloy-basic.spec.ts +1 -1
  30. package/repo-e2e/specs/controller-boundary.spec.ts +1 -1
  31. package/repo-e2e/specs/home-user-account.spec.ts +2 -1
  32. package/repo-e2e/specs/markdown.spec.ts +1 -1
  33. package/repo-e2e/specs/routed-dialog.spec.ts +1 -1
  34. package/scripts/runIsolatedTest.test.ts +426 -0
  35. package/scripts/runIsolatedTest.ts +454 -0
  36. package/scripts/testClaimCleanup.test.ts +346 -0
  37. package/scripts/testClaimCleanup.ts +375 -0
  38. package/scripts/testClaimProcesses.test.ts +106 -0
  39. package/scripts/testClaimProcesses.ts +129 -0
  40. package/scripts/testProcessGuardian.ts +163 -0
  41. package/scripts/upgrade.ts +1 -16
  42. package/vona/oxfmt.config.ts +2 -0
  43. package/vona/oxlint.config.ts +2 -0
  44. package/vona/package.original.json +2 -2
  45. package/vona/packages-cli/cli/package.json +1 -1
  46. package/vona/packages-cli/cli/src/play.test.ts +37 -0
  47. package/vona/packages-cli/cli/src/play.ts +23 -1
  48. package/vona/packages-cli/cli-set-api/package.json +1 -1
  49. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dbReset.ts +16 -7
  50. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +13 -7
  51. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.play.ts +5 -1
  52. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.test.ts +12 -7
  53. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/configUtils.ts +21 -2
  54. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/generateVonaMeta.ts +10 -0
  55. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.test.ts +153 -0
  56. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.ts +141 -0
  57. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.test.ts +148 -0
  58. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.ts +127 -0
  59. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.test.ts +47 -0
  60. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.ts +139 -0
  61. package/vona/packages-cli/cli-set-api/src/lib/utils.ts +2 -3
  62. package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_project.json +1 -0
  63. package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_source.json +1 -0
  64. package/vona/packages-cli/cli-set-api/test/binDevMode.test.ts +18 -0
  65. package/vona/packages-cli/cli-set-api/toolsIsolate/dbReset.ts +15 -2
  66. package/vona/packages-cli/cli-set-api/tsconfig.build.json +2 -1
  67. package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +1 -1
  68. package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.test.ts +16 -0
  69. package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.ts +3 -2
  70. package/vona/packages-vona/vona/package.json +1 -1
  71. package/vona/packages-vona/vona-core/package.json +1 -1
  72. package/vona/packages-vona/vona-core/src/lib/addon/sqlite3.ts +1 -1
  73. package/vona/packages-vona/vona-core/src/lib/core/config.ts +44 -6
  74. package/vona/packages-vona/vona-core/src/lib/core/configCleanup.test.ts +40 -0
  75. package/vona/packages-vona/vona-core/src/lib/core/index.ts +3 -0
  76. package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.test.ts +301 -0
  77. package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.ts +312 -0
  78. package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +577 -0
  79. package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +985 -0
  80. package/vona/packages-vona/vona-core/src/lib/core/testIsolation.ts +129 -0
  81. package/vona/packages-vona/vona-core/src/lib/framework/createApp.ts +2 -0
  82. package/vona/packages-vona/vona-core/src/lib/framework/start.ts +4 -0
  83. package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +5 -1
  84. package/vona/packages-vona/vona-core/src/types/application/app.ts +2 -0
  85. package/vona/packages-vona/vona-core/src/types/utils/env.ts +1 -0
  86. package/vona/packages-vona/vona-mock/package.json +1 -1
  87. package/vona/pnpm-lock.yaml +1 -1
  88. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentIndexes.test.ts +73 -10
  89. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  90. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  91. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  92. package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
  93. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +15 -1
  94. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +24 -0
  95. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +11 -0
  96. package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
  97. package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +15 -0
  98. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  99. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/extend/schemaBuilder.ts +11 -4
  100. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/databaseClient_.ts +6 -1
  101. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/package.json +1 -1
  102. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.betterSqlite3.ts +41 -1
  103. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.mysql.ts +4 -1
  104. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.pg.ts +2 -1
  105. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  106. package/vona/src/suite-vendor/a-vona/modules/a-redis/package.json +1 -1
  107. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/lib/redis.ts +8 -0
  108. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redis.ts +23 -6
  109. package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redisClient_.ts +18 -1
  110. package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
  111. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +43 -15
  112. package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
  113. package/vona/src/suite-vendor/a-vona/modules/a-version/src/lib/databaseTestName.ts +3 -37
  114. package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +93 -15
  115. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  116. package/vona/tsconfig.json +1 -0
  117. package/scripts/initTestData.ts +0 -25
@@ -0,0 +1,375 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { lstat, open, readdir, rm, unlink } from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import type {
10
+ TestClaimDatabase,
11
+ TestClaimManifest,
12
+ TestClaimRedis,
13
+ } from '../vona/packages-vona/vona-core/src/lib/core/testClaim.ts';
14
+
15
+ import {
16
+ assertTestClaimSafePath,
17
+ loadTestClaim,
18
+ TEST_CLAIM_DIRECTORY,
19
+ writeTestClaim,
20
+ } from '../vona/packages-vona/vona-core/src/lib/core/testClaim.ts';
21
+ import { assertTestRunOwner } from '../vona/packages-vona/vona-core/src/lib/core/testIsolation.ts';
22
+
23
+ export interface TestClaimCleanupSession {
24
+ dropDatabase(database: TestClaimDatabase, name: string): Promise<void>;
25
+ clearRedis(resource: TestClaimRedis): Promise<void>;
26
+ close(): Promise<void>;
27
+ }
28
+ export interface TestClaimCleanupOptions {
29
+ projectPath?: string;
30
+ isInactive?: (
31
+ claim: TestClaimManifest,
32
+ ) => Promise<boolean | { inactive: boolean; reason?: string }>;
33
+ warn?: (message: string) => void;
34
+ prepare?: (claim: TestClaimManifest, claimPath: string) => Promise<TestClaimCleanupSession>;
35
+ }
36
+ export interface TestClaimReapOptions extends TestClaimCleanupOptions {
37
+ ttlSeconds?: number;
38
+ now?: number;
39
+ }
40
+ export interface TestClaimCleanupResult {
41
+ status: 'cleaned' | 'skipped' | 'failed';
42
+ reason?: string;
43
+ }
44
+
45
+ export function parseTestClaimTtlSeconds(
46
+ value: string | undefined = process.env.VONA_TEST_CLAIM_TTL_SECONDS,
47
+ ): number {
48
+ if (value === undefined) return 86400;
49
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) <= 0) {
50
+ throw new Error('VONA_TEST_CLAIM_TTL_SECONDS must be a positive safe integer');
51
+ }
52
+ return Number(value);
53
+ }
54
+
55
+ async function assertSharedDirectory(directory: string, missingAllowed = true) {
56
+ assertTestClaimSafePath(directory, missingAllowed);
57
+ let stat;
58
+ try {
59
+ stat = await lstat(directory);
60
+ } catch (error) {
61
+ if (missingAllowed && (error as NodeJS.ErrnoException).code === 'ENOENT') return false;
62
+ throw error;
63
+ }
64
+ if (
65
+ !stat.isDirectory() ||
66
+ (stat.mode & 0o077) !== 0 ||
67
+ (process.getuid && stat.uid !== process.getuid())
68
+ ) {
69
+ throw new Error('Unsafe shared test claim directory');
70
+ }
71
+ return true;
72
+ }
73
+ async function exists(file: string) {
74
+ try {
75
+ await lstat(file);
76
+ return true;
77
+ } catch (error) {
78
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
79
+ throw error;
80
+ }
81
+ }
82
+ async function validateLocalRoots(claim: TestClaimManifest) {
83
+ for (const local of claim.locals) {
84
+ assertTestClaimSafePath(local.path);
85
+ if (!(await exists(local.path))) continue;
86
+ if (local.stage === 'released') throw new Error('Released local test resource reappeared');
87
+ const marker = await lstat(path.join(local.path, '.vona-test-owner'));
88
+ if (marker.isSymbolicLink() || !marker.isFile() || marker.nlink !== 1)
89
+ throw new Error('Unsafe test owner marker');
90
+ assertTestRunOwner(local.path, claim.runId);
91
+ }
92
+ }
93
+
94
+ export async function cleanTestClaim(
95
+ claimPath: string,
96
+ options: TestClaimCleanupOptions = {},
97
+ ): Promise<TestClaimCleanupResult> {
98
+ claimPath = path.resolve(claimPath);
99
+ // An unsafe shared namespace is fatal to the invocation, not a skippable claim.
100
+ await assertSharedDirectory(path.dirname(claimPath), false);
101
+ const warn = options.warn ?? (() => {});
102
+ const skip = (reason: string): TestClaimCleanupResult => {
103
+ warn(`Retained test claim ${path.basename(claimPath)}: ${reason}`);
104
+ return { status: 'skipped', reason };
105
+ };
106
+ let claim: TestClaimManifest;
107
+ try {
108
+ claim = await loadTestClaim(claimPath);
109
+ if (options.projectPath && claim.projectPath !== path.resolve(options.projectPath))
110
+ return skip('checkout mismatch');
111
+ } catch {
112
+ return skip('malformed or unsafe manifest');
113
+ }
114
+ const lockPath = `${claimPath}.cleanup.lock`;
115
+ let lock;
116
+ try {
117
+ assertTestClaimSafePath(lockPath);
118
+ lock = await open(
119
+ lockPath,
120
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
121
+ 0o600,
122
+ );
123
+ } catch (error) {
124
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST')
125
+ return skip('cleanup lock present; inspect manually, age never permits takeover');
126
+ return skip('cannot acquire safe cleanup lock');
127
+ }
128
+ const token = randomBytes(24).toString('hex');
129
+ let session: TestClaimCleanupSession | undefined;
130
+ let sequenceStarted = false;
131
+ try {
132
+ await lock.writeFile(
133
+ `${JSON.stringify({ pid: process.pid, hostname: os.hostname(), startTime: String(Date.now() - process.uptime() * 1000), token })}\n`,
134
+ );
135
+ await lock.sync();
136
+ // Re-read after locking; the runner must have closed its single inventory broker.
137
+ claim = await loadTestClaim(claimPath);
138
+ await validateLocalRoots(claim);
139
+ if (claim.databases.length || claim.redis.length)
140
+ session = await (options.prepare ?? prepareCleanupProcess)(claim, claimPath);
141
+ if (!options.isInactive) return skip('process inactivity inspector unavailable');
142
+ const evidence = await options.isInactive(claim);
143
+ if (!(typeof evidence === 'boolean' ? evidence : evidence.inactive))
144
+ return skip('live or uncertain process ownership');
145
+ sequenceStarted = true;
146
+ claim.status = 'cleaning';
147
+ claim.cleanup.processes = true;
148
+ await writeTestClaim(claimPath, claim);
149
+ for (const db of claim.databases) {
150
+ for (const creation of db.creations) {
151
+ if (creation.stage === 'released') continue;
152
+ // SQLite files and sidecars are released with the entire marked data root,
153
+ // and must not be journaled as gone before that deletion completes.
154
+ if (db.endpoint.dialect === 'better-sqlite3') continue;
155
+ await session!.dropDatabase(db, creation.name);
156
+ creation.stage = 'released';
157
+ await writeTestClaim(claimPath, claim);
158
+ }
159
+ }
160
+ claim.cleanup.databases = claim.databases.every(database =>
161
+ database.creations.every(creation => creation.stage === 'released'),
162
+ );
163
+ await writeTestClaim(claimPath, claim);
164
+ for (const resource of claim.redis) {
165
+ if (resource.released) continue;
166
+ await session!.clearRedis(resource);
167
+ resource.released = true;
168
+ await writeTestClaim(claimPath, claim);
169
+ }
170
+ claim.cleanup.redis = true;
171
+ await writeTestClaim(claimPath, claim);
172
+ // No connections can outlive local deletion. A failed close retains the journal.
173
+ if (session) {
174
+ await session.close();
175
+ session = undefined;
176
+ }
177
+ for (const local of claim.locals) {
178
+ if (local.stage === 'released') continue;
179
+ assertTestClaimSafePath(local.path);
180
+ if (await exists(local.path)) {
181
+ assertTestRunOwner(local.path, claim.runId);
182
+ await rm(local.path, { recursive: true, force: false });
183
+ }
184
+ local.stage = 'released';
185
+ await writeTestClaim(claimPath, claim);
186
+ }
187
+ for (const database of claim.databases) {
188
+ if (database.endpoint.dialect === 'better-sqlite3') {
189
+ for (const creation of database.creations) creation.stage = 'released';
190
+ }
191
+ }
192
+ claim.cleanup.databases = true;
193
+ claim.cleanup.locals = true;
194
+ await writeTestClaim(claimPath, claim);
195
+ // Validate once more before releasing the inventory, always last.
196
+ await loadTestClaim(claimPath);
197
+ await unlink(claimPath);
198
+ return { status: 'cleaned' };
199
+ } catch {
200
+ const reason = sequenceStarted
201
+ ? 'cleanup failed; remaining resources journaled for retry'
202
+ : 'unsafe roots, configuration drift, or inspection failure';
203
+ warn(`Retained test claim ${path.basename(claimPath)}: ${reason}`);
204
+ return { status: sequenceStarted ? 'failed' : 'skipped', reason };
205
+ } finally {
206
+ if (session) await session.close().catch(() => {});
207
+ await lock.close();
208
+ // Do not remove a replacement lock. No automatic orphan-lock takeover.
209
+ try {
210
+ const handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
211
+ try {
212
+ const value = JSON.parse(await handle.readFile('utf8'));
213
+ if (value.token === token) await unlink(lockPath);
214
+ } finally {
215
+ await handle.close();
216
+ }
217
+ } catch {
218
+ warn(`Retained cleanup lock ${path.basename(lockPath)}: manual inspection required`);
219
+ }
220
+ }
221
+ }
222
+
223
+ export async function reapExpiredTestClaims(
224
+ projectPath: string,
225
+ options: TestClaimReapOptions = {},
226
+ ): Promise<TestClaimCleanupResult[]> {
227
+ projectPath = path.resolve(projectPath);
228
+ const ttl = options.ttlSeconds ?? parseTestClaimTtlSeconds();
229
+ if (!Number.isSafeInteger(ttl) || ttl <= 0)
230
+ throw new Error('Test claim TTL must be a positive safe integer');
231
+ const now = options.now ?? Date.now();
232
+ if (!Number.isFinite(now)) throw new Error('Invalid test claim cleanup clock');
233
+ const warn = options.warn ?? (() => {});
234
+ const directory = path.join(projectPath, TEST_CLAIM_DIRECTORY);
235
+ // Legacy roots cannot prove remote ownership and are never adopted/deleted.
236
+ for (const item of await readdir(projectPath, { withFileTypes: true })) {
237
+ if (item.name.startsWith('.vona-test-claim-'))
238
+ warn(`Legacy inventory-less test claim retained: ${item.name}`);
239
+ const legacyRun = /^(?:\.vona-test-|\.app-test-)(\d{14}-[a-f0-9]{8})$/.exec(item.name);
240
+ if (legacyRun && !(await exists(path.join(directory, `${legacyRun[1]}.json`))))
241
+ warn(`Legacy inventory-less test root retained: ${item.name}`);
242
+ }
243
+ if (!(await assertSharedDirectory(directory))) return [];
244
+ const results: TestClaimCleanupResult[] = [];
245
+ for (const name of await readdir(directory)) {
246
+ if (name.endsWith('.cleanup.lock')) {
247
+ warn(`Cleanup lock retained for manual inspection: ${name}`);
248
+ continue;
249
+ }
250
+ if (!name.endsWith('.json')) continue;
251
+ const claimPath = path.join(directory, name);
252
+ let claim: TestClaimManifest;
253
+ try {
254
+ claim = await loadTestClaim(claimPath);
255
+ } catch {
256
+ warn(`Malformed or unsafe test claim retained: ${name}`);
257
+ results.push({ status: 'skipped', reason: 'malformed or unsafe manifest' });
258
+ continue;
259
+ }
260
+ if (claim.projectPath !== projectPath) {
261
+ warn(`Checkout-mismatched test claim retained: ${name}`);
262
+ continue;
263
+ }
264
+ if ((now - Date.parse(claim.createdAt)) / 1000 <= ttl) continue;
265
+ results.push(await cleanTestClaim(claimPath, { ...options, projectPath }));
266
+ }
267
+ return results;
268
+ }
269
+
270
+ export async function prepareCleanupProcess(
271
+ claim: TestClaimManifest,
272
+ claimPath: string,
273
+ ): Promise<TestClaimCleanupSession> {
274
+ const helper = fileURLToPath(
275
+ new URL(
276
+ '../vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.ts',
277
+ import.meta.url,
278
+ ),
279
+ );
280
+ // Config and credentials stay inside the child. Ignore stdout/stderr completely:
281
+ // source config and driver diagnostics must never become secret-bearing logs.
282
+ const child = spawn(process.execPath, [helper], {
283
+ cwd: claim.projectPath,
284
+ env: {
285
+ ...process.env,
286
+ VONA_TEST_RUN_ID: claim.runId,
287
+ META_MODE: 'test',
288
+ META_FLAVOR: 'normal',
289
+ APP_NAME: `cabtest${claim.runId}`,
290
+ SERVER_LISTEN_PORT: '1',
291
+ },
292
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
293
+ });
294
+ let nextId = 0;
295
+ let closed = false;
296
+ let exited = false;
297
+ const exitPromise = new Promise<void>(resolve => {
298
+ child.once('exit', () => {
299
+ exited = true;
300
+ resolve();
301
+ });
302
+ child.once('error', () => {
303
+ exited = true;
304
+ resolve();
305
+ });
306
+ });
307
+ const pending = new Map<
308
+ number,
309
+ { resolve: () => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }
310
+ >();
311
+ const fail = () => {
312
+ for (const operation of pending.values()) {
313
+ clearTimeout(operation.timer);
314
+ operation.reject(new Error('Cleanup helper failed'));
315
+ }
316
+ pending.clear();
317
+ };
318
+ child.on('error', () => {
319
+ exited = true;
320
+ fail();
321
+ });
322
+ child.on('exit', fail);
323
+ child.on('message', (value: any) => {
324
+ if (!value || typeof value.id !== 'number' || typeof value.ok !== 'boolean') return;
325
+ const operation = pending.get(value.id);
326
+ if (!operation) return;
327
+ pending.delete(value.id);
328
+ clearTimeout(operation.timer);
329
+ if (value.ok) operation.resolve();
330
+ else operation.reject(new Error('Cleanup helper rejected operation'));
331
+ });
332
+ const request = (command: string, args: object = {}) =>
333
+ new Promise<void>((resolve, reject) => {
334
+ const id = ++nextId;
335
+ const timer = setTimeout(() => {
336
+ pending.delete(id);
337
+ reject(new Error('Cleanup helper timed out'));
338
+ child.kill('SIGTERM');
339
+ }, 30000);
340
+ pending.set(id, { resolve, reject, timer });
341
+ child.send({ id, command, ...args }, error => {
342
+ if (error) fail();
343
+ });
344
+ });
345
+ const close = async () => {
346
+ if (closed) return;
347
+ closed = true;
348
+ try {
349
+ if (child.connected && !exited) await request('close');
350
+ } finally {
351
+ if (child.connected) child.disconnect();
352
+ if (!exited) child.kill('SIGTERM');
353
+ const timer = setTimeout(() => {
354
+ if (!exited) child.kill('SIGKILL');
355
+ }, 3000);
356
+ try {
357
+ await exitPromise;
358
+ } finally {
359
+ clearTimeout(timer);
360
+ }
361
+ }
362
+ };
363
+ try {
364
+ await request('prepare', { claimPath });
365
+ } catch (error) {
366
+ await close().catch(() => {});
367
+ throw error;
368
+ }
369
+ return {
370
+ dropDatabase: (database, name) =>
371
+ request('database', { clientName: database.clientName, scope: database.scope, name }),
372
+ clearRedis: resource => request('redis', { clientName: resource.clientName }),
373
+ close,
374
+ };
375
+ }
@@ -0,0 +1,106 @@
1
+ import assert from 'node:assert/strict';
2
+ import { hostname } from 'node:os';
3
+ import { test } from 'node:test';
4
+
5
+ import {
6
+ inspectTestProcess,
7
+ isTestClaimInactive,
8
+ parseTestProcesses,
9
+ waitForTestProcessGroupExit,
10
+ } from './testClaimProcesses.ts';
11
+
12
+ test('parses POSIX start identities and complete command arguments', () => {
13
+ const [process] = parseTestProcesses(
14
+ ' 123 123 S Tue Oct 13 00:06:04 2026 /bin/node /path with spaces/test.ts\n',
15
+ );
16
+ assert.equal(process.pid, 123);
17
+ assert.equal(process.pgid, 123);
18
+ assert.equal(process.startTime, 'Tue Oct 13 00:06:04 2026');
19
+ assert.equal(process.args, '/bin/node /path with spaces/test.ts');
20
+ assert.throws(() => parseTestProcesses('unknown output'), /Cannot parse/);
21
+ });
22
+
23
+ test('inactivity fails closed for active, reused, pending and uninspectable processes', async () => {
24
+ const owner = { pid: 100, startTime: 'owner-start', host: hostname() };
25
+ const registered = {
26
+ id: 'browser',
27
+ role: 'browser',
28
+ stage: 'registered' as const,
29
+ pid: 200,
30
+ pgid: 200,
31
+ startTime: 'browser-start',
32
+ host: hostname(),
33
+ profilePath: '/owned/tmp/browser',
34
+ };
35
+ const claim = { owner, processes: [registered] };
36
+ const snapshot = (pid: number, pgid: number, args = 'node', startTime = 'reused-start') => ({
37
+ pid,
38
+ pgid,
39
+ args,
40
+ startTime,
41
+ host: hostname(),
42
+ state: 'S',
43
+ });
44
+ assert.equal((await isTestClaimInactive(claim, false, async () => [])).inactive, true);
45
+ for (const processes of [
46
+ [snapshot(100, 100)],
47
+ [snapshot(200, 300)],
48
+ [snapshot(201, 200)],
49
+ [snapshot(202, 202, 'browser --profile /owned/tmp/browser')],
50
+ ]) {
51
+ assert.equal((await isTestClaimInactive(claim, false, async () => processes)).inactive, false);
52
+ }
53
+ assert.equal(
54
+ (
55
+ await isTestClaimInactive(
56
+ { owner, processes: [{ id: 'pending', role: 'browser', stage: 'pending' }] },
57
+ false,
58
+ async () => [],
59
+ )
60
+ ).inactive,
61
+ false,
62
+ );
63
+ assert.equal(
64
+ (
65
+ await isTestClaimInactive(
66
+ { ...claim, owner: { ...owner, host: 'different-host' } },
67
+ false,
68
+ async () => [],
69
+ )
70
+ ).inactive,
71
+ false,
72
+ );
73
+ await assert.rejects(
74
+ isTestClaimInactive(claim, false, async () => {
75
+ throw new Error('inspection failed');
76
+ }),
77
+ /inspection failed/,
78
+ );
79
+ const current = { ...owner, pid: process.pid };
80
+ const currentSnapshot = snapshot(process.pid, process.pid, 'node', owner.startTime);
81
+ assert.equal(
82
+ (
83
+ await isTestClaimInactive({ owner: current, processes: [] }, true, async () => [
84
+ currentSnapshot,
85
+ ])
86
+ ).inactive,
87
+ true,
88
+ );
89
+ assert.equal(
90
+ (
91
+ await isTestClaimInactive({ owner: current, processes: [] }, false, async () => [
92
+ currentSnapshot,
93
+ ])
94
+ ).inactive,
95
+ false,
96
+ );
97
+ });
98
+
99
+ test('identifies the current process without publishing environment values', async () => {
100
+ const identity = await inspectTestProcess(process.pid);
101
+ assert.equal(identity.pid, process.pid);
102
+ assert.ok(identity.pgid > 0);
103
+ assert.ok(identity.startTime);
104
+ assert.ok(identity.host);
105
+ await assert.rejects(waitForTestProcessGroupExit(identity.pgid, 0), /did not drain/);
106
+ });
@@ -0,0 +1,129 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { hostname } from 'node:os';
3
+ import { promisify } from 'node:util';
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export interface TestProcessIdentity {
8
+ pid: number;
9
+ pgid: number;
10
+ startTime: string;
11
+ host: string;
12
+ }
13
+
14
+ export interface TestProcessSnapshot extends TestProcessIdentity {
15
+ state: string;
16
+ args: string;
17
+ }
18
+
19
+ export function parseTestProcesses(output: string): TestProcessSnapshot[] {
20
+ const processes: TestProcessSnapshot[] = [];
21
+ for (const line of output.split('\n')) {
22
+ if (!line.trim()) continue;
23
+ const match =
24
+ /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+\s+\S+\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.*)$/.exec(
25
+ line,
26
+ );
27
+ if (!match) throw new Error('Cannot parse isolated test process inspection');
28
+ processes.push({
29
+ pid: Number(match[1]),
30
+ pgid: Number(match[2]),
31
+ state: match[3],
32
+ startTime: match[4].replace(/\s+/g, ' '),
33
+ host: hostname(),
34
+ args: match[5],
35
+ });
36
+ }
37
+ return processes;
38
+ }
39
+
40
+ export async function inspectTestProcesses(): Promise<TestProcessSnapshot[]> {
41
+ if (process.platform === 'win32') {
42
+ throw new Error('Isolated test process inspection requires POSIX');
43
+ }
44
+ const { stdout } = await execFileAsync(
45
+ 'ps',
46
+ ['-A', '-o', 'pid=', '-o', 'pgid=', '-o', 'state=', '-o', 'lstart=', '-o', 'args='],
47
+ { env: { ...process.env, LC_ALL: 'C' }, timeout: 5000, maxBuffer: 16 * 1024 * 1024 },
48
+ );
49
+ return parseTestProcesses(stdout);
50
+ }
51
+
52
+ export async function inspectTestProcess(pid: number): Promise<TestProcessSnapshot> {
53
+ const item = (await inspectTestProcesses()).find(
54
+ item => item.pid === pid && !item.state.startsWith('Z'),
55
+ );
56
+ if (!item) throw new Error('Cannot identify isolated test process');
57
+ return item;
58
+ }
59
+
60
+ export interface TestClaimProcessRecord {
61
+ id: string;
62
+ role: string;
63
+ stage: 'pending' | 'registered' | 'drained';
64
+ pid?: number;
65
+ pgid?: number;
66
+ startTime?: string;
67
+ host?: string;
68
+ profilePath?: string;
69
+ }
70
+
71
+ export async function isTestClaimInactive(
72
+ claim: {
73
+ owner: { pid: number; startTime: string; host?: string };
74
+ processes: TestClaimProcessRecord[];
75
+ },
76
+ allowCurrentOwner = false,
77
+ inspect: () => Promise<TestProcessSnapshot[]> = inspectTestProcesses,
78
+ ): Promise<{ inactive: boolean; reason?: string }> {
79
+ if (claim.owner.host !== hostname()) {
80
+ return { inactive: false, reason: 'claim belongs to another or unidentified host' };
81
+ }
82
+ const processes = (await inspect()).filter(item => !item.state.startsWith('Z'));
83
+ const owner = processes.find(item => item.pid === claim.owner.pid);
84
+ if (
85
+ owner &&
86
+ !(allowCurrentOwner && owner.pid === process.pid && owner.startTime === claim.owner.startTime)
87
+ ) {
88
+ return { inactive: false, reason: 'claim owner PID is still present' };
89
+ }
90
+ for (const registered of claim.processes) {
91
+ if (registered.stage === 'pending') {
92
+ return { inactive: false, reason: 'process launch registration is incomplete' };
93
+ }
94
+ if (
95
+ !registered.pid ||
96
+ !registered.pgid ||
97
+ !registered.startTime ||
98
+ registered.host !== hostname()
99
+ ) {
100
+ return {
101
+ inactive: false,
102
+ reason: 'process identity is incomplete or belongs to another host',
103
+ };
104
+ }
105
+ if (processes.some(item => item.pid === registered.pid || item.pgid === registered.pgid)) {
106
+ return { inactive: false, reason: 'registered process or group is still present' };
107
+ }
108
+ if (
109
+ registered.profilePath &&
110
+ processes.some(item => item.args.includes(registered.profilePath!))
111
+ ) {
112
+ return { inactive: false, reason: 'browser profile remains in use' };
113
+ }
114
+ }
115
+ return { inactive: true };
116
+ }
117
+
118
+ export async function waitForTestProcessGroupExit(pgid: number, timeoutMs = 10_000) {
119
+ if (!Number.isSafeInteger(pgid) || pgid <= 0) throw new Error('Invalid isolated process group');
120
+ const deadline = Date.now() + timeoutMs;
121
+ while (true) {
122
+ const live = (await inspectTestProcesses()).some(
123
+ item => item.pgid === pgid && !item.state.startsWith('Z'),
124
+ );
125
+ if (!live) return;
126
+ if (Date.now() >= deadline) throw new Error('Isolated browser process group did not drain');
127
+ await new Promise(resolvePromise => setTimeout(resolvePromise, 100));
128
+ }
129
+ }