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.
- package/.cabloy-version +1 -1
- package/.github/workflows/create-cabloy.yml +137 -0
- package/.github/workflows/playwright-e2e.yml +163 -13
- package/.github/workflows/vona-test-sqlite3.yml +77 -7
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +4 -2
- package/package.json +1 -1
- package/repo-docs/frontend/scripts.md +2 -0
- package/repo-docs/reference/repo-scripts.md +27 -1
- package/repo-e2e/scripts/e2e.test.ts +1 -1
- package/repo-e2e/scripts/managedTest.test.ts +115 -3
- package/repo-e2e/scripts/managedTest.ts +69 -20
- package/repo-e2e/scripts/managedTestImports.test.ts +55 -5
- package/repo-e2e/scripts/runE2e.test.ts +109 -0
- package/repo-e2e/scripts/runE2e.ts +58 -39
- package/repo-e2e/scripts/runE2eArgs.test.ts +16 -1
- package/repo-e2e/scripts/runE2eArgs.ts +5 -0
- package/repo-e2e/specs/a-commerce.spec.ts +10 -2
- package/repo-e2e/specs/home-user-account.spec.ts +4 -0
- package/repo-e2e/specs/markdown.spec.ts +14 -3
- package/scripts/generateOpenapiIsolated.test.ts +5 -1
- package/scripts/runIsolatedTest.test.ts +403 -36
- package/scripts/runIsolatedTest.ts +139 -12
- package/scripts/testBrowserGuardianWindows.test.ts +327 -0
- package/scripts/testClaimCleanup.test.ts +25 -9
- package/scripts/testClaimCleanup.ts +14 -45
- package/scripts/testClaimProcesses.test.ts +81 -2
- package/scripts/testClaimProcesses.ts +46 -10
- package/scripts/testProcessGuardianWindows.test.ts +602 -0
- package/scripts/testProcessGuardianWindows.ts +155 -0
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.build.ts +4 -1
- package/vona/packages-cli/cli-set-api/test/binBuildEntries.test.ts +159 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +298 -17
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +205 -97
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.test.ts +247 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.ts +224 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.cs +229 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ps1 +20 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ts +200 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.cs +336 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ps1 +20 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ts +531 -0
- package/vona/packages-vona/vona-core/src/lib/framework/bootstrap.test.ts +61 -0
- package/vona/packages-vona/vona-core/src/lib/framework/index.ts +1 -0
- package/vona/packages-vona/vona-core/src/lib/framework/processWorker.test.ts +59 -0
- package/vona/packages-vona/vona-core/src/lib/framework/processWorker.ts +8 -5
- package/vona/packages-vona/vona-core/src/lib/framework/useApp.test.ts +84 -0
- package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +2 -2
- package/vona/packages-vona/vona-core/src/lib/utils/util.ts +1 -1
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +42 -181
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/bean/bean.executor.ts +2 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +16 -0
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +6 -3
- package/vona/src/suite-vendor/a-vona/modules/a-logger/test/middlewareSystem.httpLog.test.ts +80 -0
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +73 -2
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/test/middlewareSystem.health.test.ts +309 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transactionAsyncLocalStorage_.ts +3 -3
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-static/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-static/src/bean/middlewareSystem.static.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-static/test/middlewareSystem.static.test.ts +101 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/package.original.json +1 -1
- package/zova/packages-cli/cli/package.json +2 -2
- package/zova/packages-cli/cli-set-front/package.json +4 -4
- package/zova/packages-utils/zova-jsx/package.json +2 -2
- package/zova/packages-utils/zova-vite/package.json +2 -2
- package/zova/packages-utils/zova-vite/src/configUtils.ts +1 -1
- package/zova/packages-utils/zova-vite/test/configUtils.test.ts +55 -0
- package/zova/packages-zova/zova/package.json +3 -3
- package/zova/packages-zova/zova-core/package.json +2 -2
- package/zova/pnpm-lock.yaml +324 -63
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +8 -4
- package/zova/src/suite/a-commerce/modules/commerce-trade/test/lib/cartModel.test.ts +71 -0
- package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
- package/zova/src/suite-vendor/a-zova/package.json +2 -2
|
@@ -3,8 +3,9 @@ import type { ChildProcess } from 'node:child_process';
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
5
5
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { realpathSync } from 'node:fs';
|
|
6
7
|
import { createServer } from 'node:net';
|
|
7
|
-
import { dirname, resolve } from 'node:path';
|
|
8
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
9
10
|
|
|
10
11
|
import {
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
reapExpiredTestClaims,
|
|
20
21
|
} from './testClaimCleanup.ts';
|
|
21
22
|
import { inspectTestProcess, isTestClaimInactive } from './testClaimProcesses.ts';
|
|
23
|
+
import { startWindowsOwnedProcess } from './testProcessGuardianWindows.ts';
|
|
22
24
|
|
|
23
25
|
export const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
26
|
const OWNER_FILE = '.vona-test-owner';
|
|
@@ -92,9 +94,7 @@ export async function startOwnedProcess(
|
|
|
92
94
|
cwd: string,
|
|
93
95
|
env: NodeJS.ProcessEnv,
|
|
94
96
|
): Promise<OwnedProcess> {
|
|
95
|
-
if (process.platform === 'win32')
|
|
96
|
-
throw new Error('Isolated test process-tree cleanup requires POSIX process groups');
|
|
97
|
-
}
|
|
97
|
+
if (process.platform === 'win32') return startWindowsOwnedProcess(command, args, cwd, env);
|
|
98
98
|
const processId = randomUUID();
|
|
99
99
|
const managed = hasTestClaimTransport(env);
|
|
100
100
|
const registration = { id: processId, role: 'guardian', stage: 'pending' as const };
|
|
@@ -255,11 +255,85 @@ export function getIsolatedVonaCommand(port: number, env: NodeJS.ProcessEnv, rea
|
|
|
255
255
|
};
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
interface IsolatedVonaDependencies {
|
|
259
|
+
start: typeof startOwnedProcess;
|
|
260
|
+
ready: typeof waitForReady;
|
|
261
|
+
platform?: NodeJS.Platform;
|
|
262
|
+
requestStop?: typeof requestIsolatedVonaStop;
|
|
263
|
+
shutdownTimeoutMs?: number;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function requestIsolatedVonaStop(
|
|
267
|
+
port: number,
|
|
268
|
+
runId: string | undefined,
|
|
269
|
+
key: string,
|
|
270
|
+
timeoutMs = 1500,
|
|
271
|
+
) {
|
|
272
|
+
if (!runId || !/^\d{14}-[a-f0-9]{8}$/.test(runId) || !/^[a-f0-9]{64}$/.test(key)) {
|
|
273
|
+
throw new Error('Invalid isolated Vona shutdown identity');
|
|
274
|
+
}
|
|
275
|
+
const nonce = randomBytes(16).toString('hex');
|
|
276
|
+
const payload = `${runId}\nPOST\n/health/test-stop\n${nonce}`;
|
|
277
|
+
const response = await fetch(`http://127.0.0.1:${port}/health/test-stop`, {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
redirect: 'error',
|
|
280
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
281
|
+
headers: {
|
|
282
|
+
'x-vona-test-stop-nonce': nonce,
|
|
283
|
+
'x-vona-test-stop-authorization': createHmac('sha256', key)
|
|
284
|
+
.update(`vona-test-stop-request-v1\n${payload}`)
|
|
285
|
+
.digest('hex'),
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
const proof = response.headers.get('x-vona-test-stop-proof');
|
|
289
|
+
const expected = createHmac('sha256', key).update(`vona-test-stop-ack-v1\n${payload}`).digest();
|
|
290
|
+
if (
|
|
291
|
+
response.status !== 200 ||
|
|
292
|
+
!proof ||
|
|
293
|
+
!/^[a-f0-9]{64}$/.test(proof) ||
|
|
294
|
+
!timingSafeEqual(expected, Buffer.from(proof, 'hex')) ||
|
|
295
|
+
((await response.json()) as { status?: string }).status !== 'stopping'
|
|
296
|
+
) {
|
|
297
|
+
throw new Error('Isolated Vona shutdown refused or acknowledgment proof was invalid');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function stopVonaCooperatively(
|
|
302
|
+
port: number,
|
|
303
|
+
env: NodeJS.ProcessEnv,
|
|
304
|
+
readyKey: string,
|
|
305
|
+
server: OwnedProcess,
|
|
306
|
+
dependencies: IsolatedVonaDependencies,
|
|
307
|
+
) {
|
|
308
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
309
|
+
try {
|
|
310
|
+
return await Promise.race([
|
|
311
|
+
(async () => {
|
|
312
|
+
await (dependencies.requestStop ?? requestIsolatedVonaStop)(
|
|
313
|
+
port,
|
|
314
|
+
env.VONA_TEST_RUN_ID,
|
|
315
|
+
readyKey,
|
|
316
|
+
);
|
|
317
|
+
return await server.wait();
|
|
318
|
+
})(),
|
|
319
|
+
new Promise<never>((_, reject) => {
|
|
320
|
+
timeout = setTimeout(
|
|
321
|
+
() =>
|
|
322
|
+
reject(new Error('Isolated Vona cooperative shutdown did not prove drainage in time')),
|
|
323
|
+
dependencies.shutdownTimeoutMs ?? 20_000,
|
|
324
|
+
);
|
|
325
|
+
}),
|
|
326
|
+
]);
|
|
327
|
+
} finally {
|
|
328
|
+
clearTimeout(timeout);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
258
332
|
export async function withIsolatedVona<T>(
|
|
259
333
|
port: number,
|
|
260
334
|
env: NodeJS.ProcessEnv,
|
|
261
335
|
callback: () => Promise<T>,
|
|
262
|
-
dependencies = { start: startOwnedProcess, ready: waitForReady },
|
|
336
|
+
dependencies: IsolatedVonaDependencies = { start: startOwnedProcess, ready: waitForReady },
|
|
263
337
|
): Promise<T> {
|
|
264
338
|
const readyKey = randomBytes(32).toString('hex');
|
|
265
339
|
const command = getIsolatedVonaCommand(port, env, readyKey);
|
|
@@ -284,8 +358,43 @@ export async function withIsolatedVona<T>(
|
|
|
284
358
|
let operationError: Error | undefined;
|
|
285
359
|
let shutdownError: Error | undefined;
|
|
286
360
|
let result: T | undefined;
|
|
361
|
+
let ready = false;
|
|
362
|
+
const windows = (dependencies.platform ?? process.platform) === 'win32';
|
|
363
|
+
const shutdown = async () => {
|
|
364
|
+
const cooperative = windows && ready && !interrupted && !exitedEarly;
|
|
365
|
+
try {
|
|
366
|
+
const stopped = cooperative
|
|
367
|
+
? await stopVonaCooperatively(port, env, readyKey, server, dependencies)
|
|
368
|
+
: await server.stop();
|
|
369
|
+
if (
|
|
370
|
+
stopped.failed ||
|
|
371
|
+
interrupted ||
|
|
372
|
+
exitedEarly ||
|
|
373
|
+
stopped.result?.code !== 0 ||
|
|
374
|
+
(windows && !!stopped.result?.signal)
|
|
375
|
+
) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
'Isolated Vona server did not stop cleanly, exited early, or run was interrupted',
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
} catch (error) {
|
|
381
|
+
// Force-stop remains a failed-run fallback, never successful Windows teardown.
|
|
382
|
+
if (cooperative) {
|
|
383
|
+
try {
|
|
384
|
+
await server.stop();
|
|
385
|
+
} catch (fallbackError) {
|
|
386
|
+
throw new AggregateError(
|
|
387
|
+
[error, fallbackError],
|
|
388
|
+
'Isolated Vona cooperative shutdown and failure-stop failed',
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
};
|
|
287
395
|
try {
|
|
288
396
|
await dependencies.ready(port, server, readyKey);
|
|
397
|
+
ready = true;
|
|
289
398
|
if (interrupted) throw new Error('Isolated Vona run interrupted');
|
|
290
399
|
result = await callback();
|
|
291
400
|
} catch (error) {
|
|
@@ -293,12 +402,7 @@ export async function withIsolatedVona<T>(
|
|
|
293
402
|
} finally {
|
|
294
403
|
stopping = true;
|
|
295
404
|
try {
|
|
296
|
-
|
|
297
|
-
if (stopped.failed || interrupted || exitedEarly || stopped.result?.code !== 0) {
|
|
298
|
-
shutdownError = new Error(
|
|
299
|
-
'Isolated Vona server did not stop cleanly, exited early, or run was interrupted',
|
|
300
|
-
);
|
|
301
|
-
}
|
|
405
|
+
await shutdown();
|
|
302
406
|
} catch (error) {
|
|
303
407
|
shutdownError = toError(error);
|
|
304
408
|
} finally {
|
|
@@ -384,10 +488,27 @@ export async function waitForReady(
|
|
|
384
488
|
throw new Error('Isolated Vona server did not become ready with a valid ownership proof');
|
|
385
489
|
}
|
|
386
490
|
|
|
491
|
+
export function resolveNpmCli(
|
|
492
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
493
|
+
execPath = process.execPath,
|
|
494
|
+
): string {
|
|
495
|
+
const candidates = [
|
|
496
|
+
env.npm_execpath,
|
|
497
|
+
resolve(dirname(execPath), 'node_modules/npm/bin/npm-cli.js'),
|
|
498
|
+
resolve(dirname(execPath), '../lib/node_modules/npm/bin/npm-cli.js'),
|
|
499
|
+
];
|
|
500
|
+
for (const candidate of candidates) {
|
|
501
|
+
if (!candidate || !existsSync(candidate)) continue;
|
|
502
|
+
const file = realpathSync(candidate);
|
|
503
|
+
if (basename(file) === 'npm-cli.js' && basename(dirname(dirname(file))) === 'npm') return file;
|
|
504
|
+
}
|
|
505
|
+
throw new Error('Cannot resolve npm CLI; run through npm or install npm beside Node.js');
|
|
506
|
+
}
|
|
507
|
+
|
|
387
508
|
export function getUnitTestCommands(args: string[]) {
|
|
388
509
|
const vonaDir = resolve(ROOT_DIR, 'vona');
|
|
389
510
|
return [
|
|
390
|
-
{ command:
|
|
511
|
+
{ command: process.execPath, args: [resolveNpmCli(), 'run', 'prerun'], cwd: vonaDir },
|
|
391
512
|
{
|
|
392
513
|
command: process.execPath,
|
|
393
514
|
args: [
|
|
@@ -468,6 +589,12 @@ export async function withIsolatedRun<T>(
|
|
|
468
589
|
SERVER_SERVE_HOST: undefined,
|
|
469
590
|
TMPDIR: resolve(dataRoot, 'tmp'),
|
|
470
591
|
};
|
|
592
|
+
if (process.platform === 'win32') {
|
|
593
|
+
for (const key of Object.keys(env)) {
|
|
594
|
+
if (['TEMP', 'TMP', 'TMPDIR'].includes(key.toUpperCase())) delete env[key];
|
|
595
|
+
}
|
|
596
|
+
env.TEMP = env.TMP = env.TMPDIR = resolve(dataRoot, 'tmp');
|
|
597
|
+
}
|
|
471
598
|
await broker.update({ type: 'status', status: 'running' });
|
|
472
599
|
result = await callback({
|
|
473
600
|
runId,
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
|
+
|
|
21
|
+
import { loadTestClaim } from '../vona/packages-vona/vona-core/src/lib/core/testClaim.ts';
|
|
22
|
+
import {
|
|
23
|
+
inspectWindowsProcess,
|
|
24
|
+
inspectWindowsProcesses,
|
|
25
|
+
} from '../vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ts';
|
|
26
|
+
import { isTestClaimInactive } from './testClaimProcesses.ts';
|
|
27
|
+
|
|
28
|
+
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const nativeOptions = {
|
|
31
|
+
skip:
|
|
32
|
+
process.platform !== 'win32'
|
|
33
|
+
? 'Requires native Windows, PowerShell 7 and installed Playwright Chromium'
|
|
34
|
+
: false,
|
|
35
|
+
timeout: 180_000,
|
|
36
|
+
};
|
|
37
|
+
const moduleUrl = (name: string) => new URL(name, import.meta.url).href;
|
|
38
|
+
const claimUrl = moduleUrl('../vona/packages-vona/vona-core/src/lib/core/testClaim.ts');
|
|
39
|
+
const nativeUrl = moduleUrl(
|
|
40
|
+
'../vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ts',
|
|
41
|
+
);
|
|
42
|
+
const guardianUrl = moduleUrl('./testProcessGuardianWindows.ts');
|
|
43
|
+
const managedUrl = moduleUrl('../repo-e2e/scripts/managedTest.ts');
|
|
44
|
+
|
|
45
|
+
async function until<T>(
|
|
46
|
+
check: () => T | undefined | Promise<T | undefined>,
|
|
47
|
+
timeout = 60_000,
|
|
48
|
+
): Promise<T> {
|
|
49
|
+
const deadline = Date.now() + timeout;
|
|
50
|
+
for (;;) {
|
|
51
|
+
const value = await check();
|
|
52
|
+
if (value !== undefined) return value;
|
|
53
|
+
if (Date.now() >= deadline) throw new Error('Windows Chromium containment fixture timed out');
|
|
54
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function json<T>(file: string): T | undefined {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(readFileSync(file, 'utf8')) as T;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT' || error instanceof SyntaxError)
|
|
62
|
+
return undefined;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function exited(
|
|
67
|
+
identities: { pid: number; startTime: string }[],
|
|
68
|
+
inspect = inspectWindowsProcesses,
|
|
69
|
+
) {
|
|
70
|
+
return await until(async () => {
|
|
71
|
+
const snapshots = await inspect(identities.map(item => item.pid));
|
|
72
|
+
return identities.every(
|
|
73
|
+
identity =>
|
|
74
|
+
!snapshots.some(item => item.pid === identity.pid && item.startTime === identity.startTime),
|
|
75
|
+
)
|
|
76
|
+
? snapshots
|
|
77
|
+
: undefined;
|
|
78
|
+
}, 30_000);
|
|
79
|
+
}
|
|
80
|
+
test('exit observation accepts PID reuse but returns the occupied snapshot for cleanup checks', async () => {
|
|
81
|
+
const identities = [{ pid: 100, startTime: '134000000000000000' }];
|
|
82
|
+
const snapshots = [
|
|
83
|
+
{
|
|
84
|
+
pid: 100,
|
|
85
|
+
startTime: '134000000000000001',
|
|
86
|
+
host: 'test-host',
|
|
87
|
+
state: 'running' as const,
|
|
88
|
+
args: '',
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
let inspections = 0;
|
|
92
|
+
const observed = await exited(identities, async pids => {
|
|
93
|
+
assert.deepEqual(pids, [100]);
|
|
94
|
+
inspections++;
|
|
95
|
+
return inspections === 1 ? [{ ...snapshots[0], ...identities[0] }] : snapshots;
|
|
96
|
+
});
|
|
97
|
+
assert.equal(observed, snapshots);
|
|
98
|
+
assert.equal(inspections, 2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function closePromise(child: ChildProcess) {
|
|
102
|
+
return new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
|
103
|
+
child.once('error', reject);
|
|
104
|
+
child.once('close', (code, signal) => resolve({ code, signal }));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const mode of ['normal', 'cancel', 'controller-death', 'helper-death'] as const) {
|
|
109
|
+
test(
|
|
110
|
+
`real Playwright worker/Chromium inherited private Job: ${mode}`,
|
|
111
|
+
nativeOptions,
|
|
112
|
+
async () => {
|
|
113
|
+
// Deliberately resolve the public package/CLI. Missing browser binaries are a Windows gate failure, not a skip.
|
|
114
|
+
const playwrightUrl = pathToFileURL(require.resolve('@playwright/test')).href;
|
|
115
|
+
const playwrightCli = require.resolve('@playwright/test/cli');
|
|
116
|
+
const root = mkdtempSync(join(realpathSync(tmpdir()), 'cabloy chromium job 空格 '));
|
|
117
|
+
const project = join(root, 'vona');
|
|
118
|
+
mkdirSync(project);
|
|
119
|
+
const runId = `20261018010203-${randomUUID().replaceAll('-', '').slice(0, 8)}`;
|
|
120
|
+
const dataRoot = join(project, `.app-test-${runId}`);
|
|
121
|
+
const temporaryRoot = join(dataRoot, 'tmp');
|
|
122
|
+
mkdirSync(temporaryRoot, { recursive: true });
|
|
123
|
+
writeFileSync(join(dataRoot, '.vona-test-owner'), `${runId}\n`);
|
|
124
|
+
const configPath = join(root, 'playwright.config.mjs');
|
|
125
|
+
const specPath = join(root, 'contained.spec.mjs');
|
|
126
|
+
const controllerPath = join(root, 'controller.mjs');
|
|
127
|
+
const browserPath = join(root, 'browser.json');
|
|
128
|
+
const releasePath = join(root, 'release');
|
|
129
|
+
const pageReadyPath = join(root, 'page-ready');
|
|
130
|
+
const resultPath = join(root, 'result.json');
|
|
131
|
+
const claimPath = join(project, '.vona-test-claim', `${runId}.json`);
|
|
132
|
+
writeFileSync(
|
|
133
|
+
configPath,
|
|
134
|
+
`export default {testDir:${JSON.stringify(root)},testMatch:'contained.spec.mjs',workers:1,retries:0,timeout:120000,reporter:'line',outputDir:${JSON.stringify(join(dataRoot, 'test-results'))}};`,
|
|
135
|
+
);
|
|
136
|
+
writeFileSync(
|
|
137
|
+
specPath,
|
|
138
|
+
`
|
|
139
|
+
import playwrightTest from ${JSON.stringify(playwrightUrl)};
|
|
140
|
+
import {existsSync, writeFileSync} from 'node:fs';
|
|
141
|
+
import {useManagedBrowser} from ${JSON.stringify(managedUrl)};
|
|
142
|
+
import {assertTestClaimWindowsJob, sendTestClaimEvent} from ${JSON.stringify(claimUrl)};
|
|
143
|
+
import {inspectTestProcess, waitForTestProcessGroupExit} from ${JSON.stringify(moduleUrl('./testClaimProcesses.ts'))};
|
|
144
|
+
const {test:base,expect}=playwrightTest;
|
|
145
|
+
const test=base.extend({browser:[async({playwright},use)=>{
|
|
146
|
+
let child;
|
|
147
|
+
const chromium={launchServer:async options=>{const server=await playwright.chromium.launchServer(options);child=server.process();return server;},
|
|
148
|
+
connect:(...args)=>playwright.chromium.connect(...args)};
|
|
149
|
+
await useManagedBrowser({browserType:chromium,launchOptions:{},headless:true,channel:undefined},async browser=>{
|
|
150
|
+
const worker=await inspectTestProcess(process.pid), browserIdentity=await inspectTestProcess(child.pid);
|
|
151
|
+
await assertTestClaimWindowsJob(process.env,worker);
|
|
152
|
+
await assertTestClaimWindowsJob(process.env,browserIdentity);
|
|
153
|
+
const session=await browser.newBrowserCDPSession();
|
|
154
|
+
const rows=await session.send('SystemInfo.getProcessInfo');
|
|
155
|
+
const identities=[];
|
|
156
|
+
for(const row of rows.processInfo){
|
|
157
|
+
const identity=await inspectTestProcess(row.id);
|
|
158
|
+
await assertTestClaimWindowsJob(process.env,identity);
|
|
159
|
+
identities.push(identity);
|
|
160
|
+
}
|
|
161
|
+
await session.detach();
|
|
162
|
+
writeFileSync(${JSON.stringify(browserPath)},JSON.stringify({worker,browser:browserIdentity,processes:identities,locator:process.env.VONA_TEST_WINDOWS_GUARDIAN_ID}));
|
|
163
|
+
await use(browser);
|
|
164
|
+
},{env:process.env,projectPath:${JSON.stringify(project)},sendEvent:sendTestClaimEvent,inspectProcess:inspectTestProcess,waitForGroupExit:waitForTestProcessGroupExit});
|
|
165
|
+
},{scope:'worker',timeout:0}]});
|
|
166
|
+
test('actual Chromium worker membership',async({page})=>{
|
|
167
|
+
await page.setContent('<title>contained Chromium</title><h1>private Job</h1>');
|
|
168
|
+
await expect(page).toHaveTitle('contained Chromium');
|
|
169
|
+
writeFileSync(${JSON.stringify(pageReadyPath)},'ready');
|
|
170
|
+
while(!existsSync(${JSON.stringify(releasePath)})) await new Promise(resolve=>setTimeout(resolve,50));
|
|
171
|
+
});
|
|
172
|
+
`,
|
|
173
|
+
);
|
|
174
|
+
writeFileSync(
|
|
175
|
+
controllerPath,
|
|
176
|
+
`
|
|
177
|
+
import {writeFileSync} from 'node:fs';
|
|
178
|
+
import {PassThrough} from 'node:stream';
|
|
179
|
+
import {createTestClaim,startTestClaimBroker} from ${JSON.stringify(claimUrl)};
|
|
180
|
+
import {inspectWindowsProcess} from ${JSON.stringify(nativeUrl)};
|
|
181
|
+
import {startWindowsOwnedProcess} from ${JSON.stringify(guardianUrl)};
|
|
182
|
+
let guardian,helper,broker;let stopping=false;let diagnostics='';
|
|
183
|
+
process.on('message',message=>{
|
|
184
|
+
if(message==='stop'){stopping=true;if(guardian)void guardian.stop().catch(()=>{});}
|
|
185
|
+
if(message==='kill-helper'){if(!helper)throw Error('No live helper');helper.kill('SIGKILL');}
|
|
186
|
+
});
|
|
187
|
+
const stdout=new PassThrough(),stderr=new PassThrough();
|
|
188
|
+
stdout.on('data',value=>{diagnostics=(diagnostics+value).slice(-32768)});
|
|
189
|
+
stderr.on('data',value=>{diagnostics=(diagnostics+value).slice(-32768)});
|
|
190
|
+
try {
|
|
191
|
+
const owner=await inspectWindowsProcess(process.pid);
|
|
192
|
+
await createTestClaim(${JSON.stringify(project)},${JSON.stringify(runId)},'e2e',{pid:owner.pid,startTime:owner.startTime,host:owner.host});
|
|
193
|
+
broker=await startTestClaimBroker(${JSON.stringify(claimPath)});
|
|
194
|
+
const env={...process.env,...broker.env,META_MODE:'test',VONA_TEST_RUN_ID:${JSON.stringify(runId)}};
|
|
195
|
+
for(const key of Object.keys(env))if(['TEMP','TMP','TMPDIR','VONA_TEST_WINDOWS_GUARDIAN_ID'].includes(key.toUpperCase()))delete env[key];
|
|
196
|
+
Object.assign(env,{TEMP:${JSON.stringify(temporaryRoot)},TMP:${JSON.stringify(temporaryRoot)},TMPDIR:${JSON.stringify(temporaryRoot)},vona_test_windows_guardian_id:'inherited-spoof'});
|
|
197
|
+
guardian=await startWindowsOwnedProcess(process.execPath,[${JSON.stringify(playwrightCli)},'test','--config',${JSON.stringify(configPath)}],${JSON.stringify(rootDir)},env,{stdout,stderr,onHelperSpawn:value=>{helper=value}});
|
|
198
|
+
if(stopping)void guardian.stop().catch(()=>{});
|
|
199
|
+
const result=await guardian.wait();
|
|
200
|
+
writeFileSync(${JSON.stringify(resultPath)},JSON.stringify({result,diagnostics}));
|
|
201
|
+
} catch(error) {
|
|
202
|
+
writeFileSync(${JSON.stringify(resultPath)},JSON.stringify({error:String(error),diagnostics}));
|
|
203
|
+
} finally {await broker?.close();process.disconnect();}
|
|
204
|
+
`,
|
|
205
|
+
);
|
|
206
|
+
const sentinel = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], {
|
|
207
|
+
stdio: 'ignore',
|
|
208
|
+
});
|
|
209
|
+
const sentinelClosed = closePromise(sentinel);
|
|
210
|
+
const controller = spawn(process.execPath, [controllerPath], {
|
|
211
|
+
cwd: rootDir,
|
|
212
|
+
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
213
|
+
});
|
|
214
|
+
const controllerClosed = closePromise(controller);
|
|
215
|
+
let diagnostics = '';
|
|
216
|
+
controller.stdout!.on('data', value => {
|
|
217
|
+
diagnostics += value.toString();
|
|
218
|
+
});
|
|
219
|
+
controller.stderr!.on('data', value => {
|
|
220
|
+
diagnostics += value.toString();
|
|
221
|
+
});
|
|
222
|
+
try {
|
|
223
|
+
const sentinelIdentity = await inspectWindowsProcess(sentinel.pid!);
|
|
224
|
+
assert.ok(sentinelIdentity);
|
|
225
|
+
const record = await until(() => {
|
|
226
|
+
const value = json<{
|
|
227
|
+
worker: { pid: number; startTime: string };
|
|
228
|
+
browser: { pid: number; startTime: string };
|
|
229
|
+
processes: { pid: number; startTime: string }[];
|
|
230
|
+
locator: string;
|
|
231
|
+
}>(browserPath);
|
|
232
|
+
if (value && existsSync(pageReadyPath)) return value;
|
|
233
|
+
const result = json<{ error?: string; diagnostics: string }>(resultPath);
|
|
234
|
+
if (result || controller.exitCode !== null) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Chromium did not reach membership gate: ${JSON.stringify(result)} ${diagnostics}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}, 90_000);
|
|
241
|
+
const manifest = await loadTestClaim(claimPath);
|
|
242
|
+
assert.equal(manifest.kind, 'e2e');
|
|
243
|
+
assert.equal(manifest.version, 1);
|
|
244
|
+
assert.equal(manifest.processes.length, 1);
|
|
245
|
+
const registered = manifest.processes[0];
|
|
246
|
+
assert.equal(registered.stage, 'registered');
|
|
247
|
+
assert.equal(registered.containment, 'windows-job');
|
|
248
|
+
assert.equal(record.locator, registered.id);
|
|
249
|
+
assert.notEqual(record.locator, 'inherited-spoof');
|
|
250
|
+
assert.notEqual(record.worker.pid, record.browser.pid);
|
|
251
|
+
assert.ok(record.processes.some(item => item.pid === record.browser.pid));
|
|
252
|
+
const ownedIdentities = [record.worker, record.browser, ...record.processes];
|
|
253
|
+
if (mode === 'normal') writeFileSync(releasePath, 'close');
|
|
254
|
+
else if (mode === 'cancel') controller.send('stop');
|
|
255
|
+
else if (mode === 'controller-death') controller.kill('SIGKILL');
|
|
256
|
+
else controller.send('kill-helper');
|
|
257
|
+
await Promise.race([
|
|
258
|
+
controllerClosed,
|
|
259
|
+
new Promise<never>((_, reject) => {
|
|
260
|
+
const timer = setTimeout(
|
|
261
|
+
() => reject(new Error(`Controller shutdown timed out: ${diagnostics}`)),
|
|
262
|
+
40_000,
|
|
263
|
+
);
|
|
264
|
+
timer.unref();
|
|
265
|
+
void controllerClosed.finally(() => clearTimeout(timer)).catch(() => {});
|
|
266
|
+
}),
|
|
267
|
+
]);
|
|
268
|
+
// Compare exact identities: a later native inspector can reuse an exited PID itself.
|
|
269
|
+
const snapshots = await exited([
|
|
270
|
+
...ownedIdentities,
|
|
271
|
+
{ pid: registered.pid!, startTime: registered.startTime! },
|
|
272
|
+
manifest.owner,
|
|
273
|
+
]);
|
|
274
|
+
assert.deepEqual(await inspectWindowsProcess(sentinel.pid!), sentinelIdentity);
|
|
275
|
+
const retained = await loadTestClaim(claimPath);
|
|
276
|
+
assert.equal(retained.processes.length, 1); // No per-browser intents/registration/drainage on Windows.
|
|
277
|
+
if (mode === 'normal' || mode === 'cancel') {
|
|
278
|
+
const completed = json<{
|
|
279
|
+
result?: { failed: boolean; result: { code: number } };
|
|
280
|
+
error?: string;
|
|
281
|
+
diagnostics: string;
|
|
282
|
+
}>(resultPath);
|
|
283
|
+
assert.ok(completed?.result, JSON.stringify(completed));
|
|
284
|
+
assert.equal(completed.result.failed, mode !== 'normal', completed.diagnostics);
|
|
285
|
+
if (mode === 'normal')
|
|
286
|
+
assert.equal(completed.result.result.code, 0, completed.diagnostics);
|
|
287
|
+
assert.equal(retained.processes[0].stage, 'drained');
|
|
288
|
+
// Cleanup deliberately blocks reused PIDs too. Evaluate the same native snapshot
|
|
289
|
+
// used above, not a second inspector that can change PID occupancy between checks.
|
|
290
|
+
const inactivity = await isTestClaimInactive(retained, false, async () => snapshots);
|
|
291
|
+
const occupied = snapshots.some(item =>
|
|
292
|
+
[retained.owner.pid, retained.processes[0].pid].includes(item.pid),
|
|
293
|
+
);
|
|
294
|
+
assert.deepEqual(
|
|
295
|
+
inactivity,
|
|
296
|
+
occupied
|
|
297
|
+
? {
|
|
298
|
+
inactive: false,
|
|
299
|
+
reason: snapshots.some(item => item.pid === retained.owner.pid)
|
|
300
|
+
? 'claim owner PID is still present'
|
|
301
|
+
: 'registered Windows job owner is still present',
|
|
302
|
+
}
|
|
303
|
+
: { inactive: true },
|
|
304
|
+
JSON.stringify({ snapshots, owner: retained.owner, guardian: retained.processes[0] }),
|
|
305
|
+
);
|
|
306
|
+
} else {
|
|
307
|
+
assert.equal(retained.processes[0].stage, 'registered');
|
|
308
|
+
assert.equal((await isTestClaimInactive(retained)).inactive, false);
|
|
309
|
+
if (mode === 'helper-death') {
|
|
310
|
+
assert.match(
|
|
311
|
+
json<{ error: string }>(resultPath)!.error,
|
|
312
|
+
/without proving Job shutdown/,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} finally {
|
|
317
|
+
if (controller.exitCode === null && controller.signalCode === null)
|
|
318
|
+
controller.kill('SIGKILL');
|
|
319
|
+
await controllerClosed.catch(() => {});
|
|
320
|
+
sentinel.kill();
|
|
321
|
+
await sentinelClosed;
|
|
322
|
+
// Retention assertions precede removal of this test's own temporary fixture.
|
|
323
|
+
if (existsSync(root)) rmSync(root, { recursive: true, force: true });
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
2
3
|
import {
|
|
3
4
|
chmod,
|
|
4
5
|
lstat,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
loadTestClaim,
|
|
23
24
|
writeTestClaim,
|
|
24
25
|
} from '../vona/packages-vona/vona-core/src/lib/core/testClaim.ts';
|
|
26
|
+
import { createTestClaimFile } from '../vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.ts';
|
|
25
27
|
import {
|
|
26
28
|
getTestAppName,
|
|
27
29
|
getDatabaseTestPrefix,
|
|
@@ -204,7 +206,11 @@ test('live, missing inspector, drifted config, malformed manifest and unmarked r
|
|
|
204
206
|
if (reason === 'unmarked') await rm(path.join(f.manifest.locals[0].path, '.vona-test-owner'));
|
|
205
207
|
if (reason === 'symlink') {
|
|
206
208
|
await rm(f.manifest.locals[0].path, { recursive: true });
|
|
207
|
-
await symlink(
|
|
209
|
+
await symlink(
|
|
210
|
+
path.join(f.project, '.app'),
|
|
211
|
+
f.manifest.locals[0].path,
|
|
212
|
+
process.platform === 'win32' ? 'junction' : 'dir',
|
|
213
|
+
);
|
|
208
214
|
}
|
|
209
215
|
const result = await cleanTestClaim(f.claimPath, {
|
|
210
216
|
isInactive: reason === 'inspector' ? undefined : async () => reason !== 'live',
|
|
@@ -250,10 +256,9 @@ test('exclusive cleaners skip contenders; orphaned old lock is never taken over'
|
|
|
250
256
|
}
|
|
251
257
|
const second = await fixture();
|
|
252
258
|
try {
|
|
253
|
-
await
|
|
259
|
+
await createTestClaimFile(
|
|
254
260
|
`${second.claimPath}.cleanup.lock`,
|
|
255
261
|
JSON.stringify({ pid: 999999, startTime: 'ancient', token: 'orphan' }),
|
|
256
|
-
{ mode: 0o600 },
|
|
257
262
|
);
|
|
258
263
|
const result = await reapExpiredTestClaims(second.project, {
|
|
259
264
|
now: Date.parse(second.manifest.createdAt) + 10000,
|
|
@@ -271,12 +276,23 @@ test('exclusive cleaners skip contenders; orphaned old lock is never taken over'
|
|
|
271
276
|
test('unsafe shared directories fail invocation; legacy directories warn and remain', async () => {
|
|
272
277
|
const f = await fixture();
|
|
273
278
|
try {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
279
|
+
if (process.platform === 'win32') {
|
|
280
|
+
execFileSync('icacls.exe', [path.dirname(f.claimPath), '/grant', '*S-1-1-0:(R)'], {
|
|
281
|
+
windowsHide: true,
|
|
282
|
+
stdio: 'pipe',
|
|
283
|
+
});
|
|
284
|
+
} else {
|
|
285
|
+
await chmod(path.dirname(f.claimPath), 0o777);
|
|
286
|
+
}
|
|
287
|
+
await assert.rejects(reapExpiredTestClaims(f.project, { isInactive: inactive }), /Unsafe/);
|
|
288
|
+
if (process.platform === 'win32') {
|
|
289
|
+
execFileSync('icacls.exe', [path.dirname(f.claimPath), '/remove:g', '*S-1-1-0'], {
|
|
290
|
+
windowsHide: true,
|
|
291
|
+
stdio: 'pipe',
|
|
292
|
+
});
|
|
293
|
+
} else {
|
|
294
|
+
await chmod(path.dirname(f.claimPath), 0o700);
|
|
295
|
+
}
|
|
280
296
|
const legacy = path.join(f.project, `.vona-test-claim-${runId}`);
|
|
281
297
|
await mkdir(legacy);
|
|
282
298
|
const warnings: string[] = [];
|