cabloy 5.1.204 → 5.1.206
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/.gitignore +2 -0
- package/CHANGELOG.md +21 -0
- package/lint-staged.config.mjs +2 -0
- package/oxfmt.config.ts +2 -0
- package/oxlint.config.ts +2 -0
- package/package.json +2 -3
- package/repo-agent-governance/managed-assets.json +6 -6
- package/repo-agent-governance/policies/repository.md +3 -2
- package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +2 -0
- package/repo-docs/backend/migration-and-changes.md +13 -12
- package/repo-docs/backend/runtime-and-flavors.md +2 -1
- package/repo-docs/backend/unit-testing.md +12 -8
- package/repo-docs/blogs/ai-coding-one-prompt-crud-ssr-multitasking/index.md +9 -9
- package/repo-docs/frontend/scripts.md +1 -1
- package/repo-docs/fullstack/parallel-worktree-environment.md +6 -6
- package/repo-docs/fullstack/ssr-entry-modes.md +19 -0
- package/repo-docs/reference/repo-scripts.md +22 -1
- package/repo-e2e/config/playwright.config.ts +32 -14
- package/repo-e2e/scripts/e2e.test.ts +39 -0
- package/repo-e2e/scripts/e2e.ts +19 -1
- package/repo-e2e/scripts/managedTest.test.ts +389 -0
- package/repo-e2e/scripts/managedTest.ts +210 -0
- package/repo-e2e/scripts/managedTestImports.test.ts +74 -0
- package/repo-e2e/scripts/managedTestImports.ts +110 -0
- package/repo-e2e/scripts/runE2e.ts +12 -1
- package/repo-e2e/scripts/startE2eVona.ts +37 -5
- package/repo-e2e/specs/a-commerce.spec.ts +1 -1
- package/repo-e2e/specs/cabloy-basic.spec.ts +1 -1
- package/repo-e2e/specs/controller-boundary.spec.ts +1 -1
- package/repo-e2e/specs/home-user-account.spec.ts +2 -1
- package/repo-e2e/specs/markdown.spec.ts +1 -1
- package/repo-e2e/specs/routed-dialog.spec.ts +1 -1
- package/scripts/runIsolatedTest.test.ts +447 -0
- package/scripts/runIsolatedTest.ts +462 -0
- package/scripts/testClaimCleanup.test.ts +346 -0
- package/scripts/testClaimCleanup.ts +375 -0
- package/scripts/testClaimProcesses.test.ts +106 -0
- package/scripts/testClaimProcesses.ts +129 -0
- package/scripts/testProcessGuardian.ts +163 -0
- package/scripts/upgrade.ts +1 -16
- package/vona/oxfmt.config.ts +2 -0
- package/vona/oxlint.config.ts +2 -0
- package/vona/package.original.json +2 -2
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli/src/play.test.ts +37 -0
- package/vona/packages-cli/cli/src/play.ts +23 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dbReset.ts +16 -7
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +13 -7
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.play.ts +5 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.test.ts +12 -7
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/configUtils.ts +21 -2
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/generateVonaMeta.ts +10 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.test.ts +153 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/resolveTestCleanupConfig.ts +141 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.test.ts +148 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupAdapters.ts +127 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.test.ts +47 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/testCleanupProcess.ts +139 -0
- package/vona/packages-cli/cli-set-api/src/lib/utils.ts +2 -3
- package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_project.json +1 -0
- package/vona/packages-cli/cli-set-api/templates/config/_tsconfig_source.json +1 -0
- package/vona/packages-cli/cli-set-api/test/binDevMode.test.ts +18 -0
- package/vona/packages-cli/cli-set-api/toolsIsolate/dbReset.ts +15 -2
- package/vona/packages-cli/cli-set-api/tsconfig.build.json +2 -1
- package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +1 -1
- package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.test.ts +16 -0
- package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.ts +3 -2
- 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/addon/sqlite3.ts +1 -1
- package/vona/packages-vona/vona-core/src/lib/core/config.ts +44 -6
- package/vona/packages-vona/vona-core/src/lib/core/configCleanup.test.ts +40 -0
- package/vona/packages-vona/vona-core/src/lib/core/index.ts +3 -0
- package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.test.ts +301 -0
- package/vona/packages-vona/vona-core/src/lib/core/isolationGuard.ts +312 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +577 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +985 -0
- package/vona/packages-vona/vona-core/src/lib/core/testIsolation.ts +129 -0
- package/vona/packages-vona/vona-core/src/lib/framework/createApp.ts +2 -0
- package/vona/packages-vona/vona-core/src/lib/framework/start.ts +4 -0
- package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +5 -1
- package/vona/packages-vona/vona-core/src/types/application/app.ts +2 -0
- package/vona/packages-vona/vona-core/src/types/utils/env.ts +1 -0
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentIndexes.test.ts +73 -10
- 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-logger/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +15 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +24 -0
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +11 -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 +15 -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/extend/schemaBuilder.ts +11 -4
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/databaseClient_.ts +6 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.betterSqlite3.ts +41 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.mysql.ts +4 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/bean/databaseDialect.pg.ts +2 -1
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-redis/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-redis/src/lib/redis.ts +8 -0
- package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redis.ts +23 -6
- package/vona/src/suite-vendor/a-vona/modules/a-redis/src/service/redisClient_.ts +18 -1
- package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +43 -15
- package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-version/src/lib/databaseTestName.ts +3 -37
- package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +93 -15
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/vona/tsconfig.json +1 -0
- package/scripts/initTestData.ts +0 -25
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
5
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { createServer } from 'node:net';
|
|
7
|
+
import { dirname, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
createTestClaim,
|
|
12
|
+
hasTestClaimTransport,
|
|
13
|
+
sendTestClaimEvent,
|
|
14
|
+
startTestClaimBroker,
|
|
15
|
+
} from '../vona/packages-vona/vona-core/src/lib/core/testClaim.ts';
|
|
16
|
+
import {
|
|
17
|
+
cleanTestClaim,
|
|
18
|
+
parseTestClaimTtlSeconds,
|
|
19
|
+
reapExpiredTestClaims,
|
|
20
|
+
} from './testClaimCleanup.ts';
|
|
21
|
+
import { inspectTestProcess, isTestClaimInactive } from './testClaimProcesses.ts';
|
|
22
|
+
|
|
23
|
+
export const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const OWNER_FILE = '.vona-test-owner';
|
|
25
|
+
const GUARDIAN_FILE = resolve(ROOT_DIR, 'scripts/testProcessGuardian.ts');
|
|
26
|
+
|
|
27
|
+
export function createOwned(root: string, runId: string) {
|
|
28
|
+
try {
|
|
29
|
+
mkdirSync(root);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
32
|
+
throw new Error(`Isolated test path already exists: ${root}`, { cause: error });
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
writeFileSync(resolve(root, OWNER_FILE), `${runId}\n`, { flag: 'wx' });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createTestRunId(date = new Date()) {
|
|
40
|
+
const timestamp = date.toISOString().slice(0, 19).replace(/[-:T]/g, '');
|
|
41
|
+
return `${timestamp}-${randomBytes(4).toString('hex')}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function reserveTestRun(
|
|
45
|
+
vonaDir: string,
|
|
46
|
+
runId: string,
|
|
47
|
+
kind: 'unit' | 'cov' | 'e2e' = 'unit',
|
|
48
|
+
) {
|
|
49
|
+
const identity = await inspectTestProcess(process.pid);
|
|
50
|
+
const { claimPath } = await createTestClaim(vonaDir, runId, kind, {
|
|
51
|
+
pid: identity.pid,
|
|
52
|
+
startTime: identity.startTime,
|
|
53
|
+
host: identity.host,
|
|
54
|
+
});
|
|
55
|
+
return claimPath;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function allocateTestPort(): Promise<number> {
|
|
59
|
+
const server = createServer();
|
|
60
|
+
return await new Promise((resolvePromise, reject) => {
|
|
61
|
+
server.once('error', reject);
|
|
62
|
+
server.listen(0, '127.0.0.1', () => {
|
|
63
|
+
const address = server.address();
|
|
64
|
+
if (!address || typeof address === 'string') {
|
|
65
|
+
server.close();
|
|
66
|
+
reject(new Error('Could not allocate an isolated listener'));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
server.close(error => (error ? reject(error) : resolvePromise(address.port)));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toError(value: unknown) {
|
|
75
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface GuardianResult {
|
|
79
|
+
type: 'drained';
|
|
80
|
+
result?: { code: number | null; signal: NodeJS.Signals | null };
|
|
81
|
+
failed: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface OwnedProcess {
|
|
85
|
+
wait: () => Promise<GuardianResult>;
|
|
86
|
+
stop: () => Promise<GuardianResult>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function startOwnedProcess(
|
|
90
|
+
command: string,
|
|
91
|
+
args: string[],
|
|
92
|
+
cwd: string,
|
|
93
|
+
env: NodeJS.ProcessEnv,
|
|
94
|
+
): Promise<OwnedProcess> {
|
|
95
|
+
if (process.platform === 'win32') {
|
|
96
|
+
throw new Error('Isolated test process-tree cleanup requires POSIX process groups');
|
|
97
|
+
}
|
|
98
|
+
const processId = randomUUID();
|
|
99
|
+
const managed = hasTestClaimTransport(env);
|
|
100
|
+
const registration = { id: processId, role: 'guardian', stage: 'pending' as const };
|
|
101
|
+
await sendTestClaimEvent(env, { type: 'process', process: registration });
|
|
102
|
+
const guardian: ChildProcess = spawn(process.execPath, [GUARDIAN_FILE], {
|
|
103
|
+
cwd: ROOT_DIR,
|
|
104
|
+
env,
|
|
105
|
+
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
|
106
|
+
detached: true,
|
|
107
|
+
});
|
|
108
|
+
let drained: GuardianResult | undefined;
|
|
109
|
+
let startResolve: (() => void) | undefined;
|
|
110
|
+
let startReject: ((error: Error) => void) | undefined;
|
|
111
|
+
const started = new Promise<void>((resolvePromise, reject) => {
|
|
112
|
+
startResolve = resolvePromise;
|
|
113
|
+
startReject = reject;
|
|
114
|
+
});
|
|
115
|
+
let stopping = false;
|
|
116
|
+
const onInterrupt = () => {
|
|
117
|
+
stopping = true;
|
|
118
|
+
if (guardian.connected) guardian.send({ type: 'stop' });
|
|
119
|
+
startReject?.(new Error('Isolated test guardian startup interrupted'));
|
|
120
|
+
};
|
|
121
|
+
process.on('SIGINT', onInterrupt);
|
|
122
|
+
process.on('SIGTERM', onInterrupt);
|
|
123
|
+
const startupTimeout = setTimeout(() => {
|
|
124
|
+
stopping = true;
|
|
125
|
+
if (guardian.connected) guardian.send({ type: 'stop' });
|
|
126
|
+
startReject?.(new Error('Isolated test guardian did not start its child in time'));
|
|
127
|
+
}, 30_000);
|
|
128
|
+
let closeObserved = false;
|
|
129
|
+
let startRequested = false;
|
|
130
|
+
const closed = new Promise<GuardianResult>((resolvePromise, reject) => {
|
|
131
|
+
guardian.on('message', message => {
|
|
132
|
+
if (!message || typeof message !== 'object' || !('type' in message)) return;
|
|
133
|
+
if (message.type === 'ready' && !startRequested && !stopping) {
|
|
134
|
+
startRequested = true;
|
|
135
|
+
void (async () => {
|
|
136
|
+
let identity;
|
|
137
|
+
if (managed) {
|
|
138
|
+
identity = await inspectTestProcess(guardian.pid!);
|
|
139
|
+
if (identity.pgid !== guardian.pid) throw new Error('Guardian is not its group leader');
|
|
140
|
+
await sendTestClaimEvent(env, {
|
|
141
|
+
type: 'process',
|
|
142
|
+
process: {
|
|
143
|
+
...registration,
|
|
144
|
+
stage: 'registered',
|
|
145
|
+
pid: identity.pid,
|
|
146
|
+
pgid: identity.pgid,
|
|
147
|
+
startTime: identity.startTime,
|
|
148
|
+
host: identity.host,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (stopping || !guardian.connected) throw new Error('Guardian startup interrupted');
|
|
153
|
+
guardian.send(
|
|
154
|
+
{
|
|
155
|
+
type: 'start',
|
|
156
|
+
command,
|
|
157
|
+
args,
|
|
158
|
+
cwd,
|
|
159
|
+
env,
|
|
160
|
+
processId: managed ? processId : undefined,
|
|
161
|
+
identity,
|
|
162
|
+
},
|
|
163
|
+
error => {
|
|
164
|
+
if (error) startReject?.(error);
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
})().catch(error => startReject?.(toError(error)));
|
|
168
|
+
}
|
|
169
|
+
if (message.type === 'started') startResolve?.();
|
|
170
|
+
if (message.type === 'drained') {
|
|
171
|
+
drained = message as GuardianResult;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
guardian.once('error', error => {
|
|
175
|
+
startReject?.(error);
|
|
176
|
+
reject(error);
|
|
177
|
+
});
|
|
178
|
+
guardian.once('close', (code, signal) => {
|
|
179
|
+
closeObserved = true;
|
|
180
|
+
if (drained && code === 0 && !signal) {
|
|
181
|
+
resolvePromise(drained);
|
|
182
|
+
} else {
|
|
183
|
+
const error = new Error(
|
|
184
|
+
'Isolated test guardian exited without proving process-tree shutdown',
|
|
185
|
+
);
|
|
186
|
+
startReject?.(error);
|
|
187
|
+
reject(error);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
try {
|
|
192
|
+
await started;
|
|
193
|
+
if (stopping) throw new Error('Isolated test guardian startup interrupted');
|
|
194
|
+
} catch (error) {
|
|
195
|
+
stopping = true;
|
|
196
|
+
if (guardian.connected) guardian.send({ type: 'stop' });
|
|
197
|
+
// IPC loss is not evidence that descendants exited; never signal a former PID.
|
|
198
|
+
try {
|
|
199
|
+
await Promise.race([
|
|
200
|
+
closed,
|
|
201
|
+
new Promise<never>((_, reject) => {
|
|
202
|
+
setTimeout(
|
|
203
|
+
() =>
|
|
204
|
+
reject(
|
|
205
|
+
new Error('Isolated test guardian did not prove shutdown after startup failure'),
|
|
206
|
+
),
|
|
207
|
+
25_000,
|
|
208
|
+
);
|
|
209
|
+
}),
|
|
210
|
+
]);
|
|
211
|
+
} catch (shutdownError) {
|
|
212
|
+
throw new AggregateError([error, shutdownError], 'Isolated test startup and shutdown failed');
|
|
213
|
+
}
|
|
214
|
+
throw error;
|
|
215
|
+
} finally {
|
|
216
|
+
clearTimeout(startupTimeout);
|
|
217
|
+
process.off('SIGINT', onInterrupt);
|
|
218
|
+
process.off('SIGTERM', onInterrupt);
|
|
219
|
+
}
|
|
220
|
+
const wait = () => closed;
|
|
221
|
+
const stop = async () => {
|
|
222
|
+
stopping = true;
|
|
223
|
+
if (guardian.connected && !closeObserved) guardian.send({ type: 'stop' });
|
|
224
|
+
return await Promise.race([
|
|
225
|
+
closed,
|
|
226
|
+
new Promise<never>((_, reject) => {
|
|
227
|
+
setTimeout(
|
|
228
|
+
() => reject(new Error('Isolated test guardian did not prove shutdown in time')),
|
|
229
|
+
25_000,
|
|
230
|
+
).unref();
|
|
231
|
+
}),
|
|
232
|
+
]);
|
|
233
|
+
};
|
|
234
|
+
return { wait, stop };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function runIsolatedProcess(
|
|
238
|
+
command: string,
|
|
239
|
+
args: string[],
|
|
240
|
+
cwd: string,
|
|
241
|
+
env: NodeJS.ProcessEnv,
|
|
242
|
+
) {
|
|
243
|
+
const child = await startOwnedProcess(command, args, cwd, env);
|
|
244
|
+
let interrupted = false;
|
|
245
|
+
const onInterrupt = () => {
|
|
246
|
+
interrupted = true;
|
|
247
|
+
void child.stop().catch(console.error);
|
|
248
|
+
};
|
|
249
|
+
process.on('SIGINT', onInterrupt);
|
|
250
|
+
process.on('SIGTERM', onInterrupt);
|
|
251
|
+
try {
|
|
252
|
+
const result = await child.wait();
|
|
253
|
+
if (result.failed || interrupted || result.result?.code !== 0) {
|
|
254
|
+
throw new Error(`Isolated test exited: ${result.result?.signal ?? result.result?.code}`);
|
|
255
|
+
}
|
|
256
|
+
} finally {
|
|
257
|
+
process.off('SIGINT', onInterrupt);
|
|
258
|
+
process.off('SIGTERM', onInterrupt);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function verifyReadyProof(challenge: string, key: string, proof: string | null): boolean {
|
|
263
|
+
if (!proof || !/^[a-f0-9]{64}$/.test(proof)) return false;
|
|
264
|
+
const expected = createHmac('sha256', key).update(challenge).digest();
|
|
265
|
+
return timingSafeEqual(expected, Buffer.from(proof, 'hex'));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function waitForReady(port: number, server: OwnedProcess, key: string) {
|
|
269
|
+
const deadline = Date.now() + 180_000;
|
|
270
|
+
const url = `http://127.0.0.1:${port}/health/ready`;
|
|
271
|
+
const exited = server.wait().then(() => {
|
|
272
|
+
throw new Error('Isolated E2E server exited before readiness');
|
|
273
|
+
});
|
|
274
|
+
while (Date.now() < deadline) {
|
|
275
|
+
const challenge = randomBytes(16).toString('hex');
|
|
276
|
+
let response: Response;
|
|
277
|
+
try {
|
|
278
|
+
response = await Promise.race([
|
|
279
|
+
fetch(url, {
|
|
280
|
+
headers: { 'x-vona-test-challenge': challenge },
|
|
281
|
+
signal: AbortSignal.timeout(1500),
|
|
282
|
+
}),
|
|
283
|
+
exited,
|
|
284
|
+
]);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error instanceof Error && error.message.includes('exited before readiness')) throw error;
|
|
287
|
+
await Promise.race([new Promise(resolvePromise => setTimeout(resolvePromise, 200)), exited]);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (!verifyReadyProof(challenge, key, response.headers.get('x-vona-test-proof'))) {
|
|
291
|
+
throw new Error('Isolated E2E listener does not belong to this test run');
|
|
292
|
+
}
|
|
293
|
+
if (response.ok && ((await response.json()) as { status?: string }).status === 'ok') return;
|
|
294
|
+
await Promise.race([new Promise(resolvePromise => setTimeout(resolvePromise, 200)), exited]);
|
|
295
|
+
}
|
|
296
|
+
throw new Error('Isolated E2E server did not become ready with a valid ownership proof');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function getUnitTestCommands(args: string[]) {
|
|
300
|
+
const vonaDir = resolve(ROOT_DIR, 'vona');
|
|
301
|
+
return [
|
|
302
|
+
{ command: 'npm', args: ['run', 'prerun'], cwd: vonaDir },
|
|
303
|
+
{
|
|
304
|
+
command: process.execPath,
|
|
305
|
+
args: [
|
|
306
|
+
'./packages-cli/cli/src/bin/vona.ts',
|
|
307
|
+
':bin:test',
|
|
308
|
+
'--flavor=normal',
|
|
309
|
+
...args.map(arg => (arg === '--ci' ? '--ci=true' : arg)),
|
|
310
|
+
],
|
|
311
|
+
cwd: vonaDir,
|
|
312
|
+
},
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function resolveCabloyEdition(rootDir = ROOT_DIR): 'basic' | 'start' {
|
|
317
|
+
const hasBasic = existsSync(resolve(rootDir, '__CABLOY_BASIC__'));
|
|
318
|
+
const hasStart = existsSync(resolve(rootDir, '__CABLOY_START__'));
|
|
319
|
+
if (hasBasic === hasStart) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
hasBasic
|
|
322
|
+
? 'Ambiguous Cabloy edition: both __CABLOY_BASIC__ and __CABLOY_START__ markers are present'
|
|
323
|
+
: 'Not a Cabloy project: no __CABLOY_BASIC__ or __CABLOY_START__ marker found',
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
return hasBasic ? 'basic' : 'start';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function runIsolatedTest(kind: 'unit' | 'e2e', args: string[]) {
|
|
330
|
+
resolveCabloyEdition();
|
|
331
|
+
if (process.env.E2E_BASE_URL !== undefined) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
'E2E_BASE_URL is unsupported; the E2E runner manages a local test-mode target.',
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
const vonaDir = resolve(ROOT_DIR, 'vona');
|
|
337
|
+
await reapExpiredTestClaims(vonaDir, {
|
|
338
|
+
ttlSeconds: parseTestClaimTtlSeconds(),
|
|
339
|
+
now: Date.now(),
|
|
340
|
+
isInactive: claim => isTestClaimInactive(claim),
|
|
341
|
+
warn: message => console.error(message),
|
|
342
|
+
});
|
|
343
|
+
const runId = createTestRunId();
|
|
344
|
+
const dataRoot = resolve(vonaDir, `.app-test-${runId}`);
|
|
345
|
+
const generatedRoot = resolve(vonaDir, `.vona-test-${runId}`);
|
|
346
|
+
const port = await allocateTestPort();
|
|
347
|
+
const claimPath = await reserveTestRun(
|
|
348
|
+
vonaDir,
|
|
349
|
+
runId,
|
|
350
|
+
kind === 'unit' && args.includes('--coverage') ? 'cov' : kind,
|
|
351
|
+
);
|
|
352
|
+
let broker: Awaited<ReturnType<typeof startTestClaimBroker>> | undefined;
|
|
353
|
+
let completed = false;
|
|
354
|
+
try {
|
|
355
|
+
broker = await startTestClaimBroker(claimPath);
|
|
356
|
+
createOwned(dataRoot, runId);
|
|
357
|
+
await broker.update({ type: 'local', kind: 'data', stage: 'owned' });
|
|
358
|
+
createOwned(generatedRoot, runId);
|
|
359
|
+
await broker.update({ type: 'local', kind: 'generated', stage: 'owned' });
|
|
360
|
+
mkdirSync(resolve(dataRoot, 'tmp'));
|
|
361
|
+
const env: NodeJS.ProcessEnv = {
|
|
362
|
+
...process.env,
|
|
363
|
+
...broker.env,
|
|
364
|
+
META_MODE: 'test',
|
|
365
|
+
VONA_DB_RESET: undefined,
|
|
366
|
+
VONA_TEST_RUN_ID: runId,
|
|
367
|
+
VONA_TEST_READY_KEY: undefined,
|
|
368
|
+
VONA_TEST_SERVER_ORIGIN: undefined,
|
|
369
|
+
APP_NAME: `cabtest${runId}`,
|
|
370
|
+
SERVER_LISTEN_PORT: String(port),
|
|
371
|
+
SERVER_LISTEN_HOSTNAME: '127.0.0.1',
|
|
372
|
+
SERVER_SERVE_PROTOCOL: undefined,
|
|
373
|
+
SERVER_SERVE_HOST: undefined,
|
|
374
|
+
TMPDIR: resolve(dataRoot, 'tmp'),
|
|
375
|
+
};
|
|
376
|
+
await broker.update({ type: 'status', status: 'running' });
|
|
377
|
+
if (kind === 'unit') {
|
|
378
|
+
for (const { command, args: commandArgs, cwd } of getUnitTestCommands(args)) {
|
|
379
|
+
await runIsolatedProcess(command, commandArgs, cwd, env);
|
|
380
|
+
}
|
|
381
|
+
} else {
|
|
382
|
+
let e2eError: unknown;
|
|
383
|
+
const readyKey = randomBytes(32).toString('hex');
|
|
384
|
+
const server = await startOwnedProcess(
|
|
385
|
+
process.execPath,
|
|
386
|
+
[
|
|
387
|
+
'./packages-cli/cli/src/bin/vona.ts',
|
|
388
|
+
':bin:dev',
|
|
389
|
+
'--workers=1',
|
|
390
|
+
'--flavor=normal',
|
|
391
|
+
'--mode=test',
|
|
392
|
+
],
|
|
393
|
+
resolve(ROOT_DIR, 'vona'),
|
|
394
|
+
{
|
|
395
|
+
...env,
|
|
396
|
+
VONA_TEST_READY_KEY: readyKey,
|
|
397
|
+
VONA_TEST_SERVER_ORIGIN: 'true',
|
|
398
|
+
SERVER_SERVE_PROTOCOL: 'http',
|
|
399
|
+
SERVER_SERVE_HOST: `127.0.0.1:${port}`,
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
let interrupted = false;
|
|
403
|
+
let serverError: unknown;
|
|
404
|
+
const onInterrupt = () => {
|
|
405
|
+
interrupted = true;
|
|
406
|
+
void server.stop().catch(console.error);
|
|
407
|
+
};
|
|
408
|
+
process.on('SIGINT', onInterrupt);
|
|
409
|
+
process.on('SIGTERM', onInterrupt);
|
|
410
|
+
try {
|
|
411
|
+
await waitForReady(port, server, readyKey);
|
|
412
|
+
await runIsolatedProcess(
|
|
413
|
+
process.execPath,
|
|
414
|
+
['repo-e2e/scripts/runE2e.ts', ...args],
|
|
415
|
+
ROOT_DIR,
|
|
416
|
+
env,
|
|
417
|
+
);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
e2eError = error;
|
|
420
|
+
} finally {
|
|
421
|
+
process.off('SIGINT', onInterrupt);
|
|
422
|
+
process.off('SIGTERM', onInterrupt);
|
|
423
|
+
try {
|
|
424
|
+
const result = await server.stop();
|
|
425
|
+
if (result.failed || interrupted) {
|
|
426
|
+
serverError = new Error('Isolated E2E server did not stop cleanly');
|
|
427
|
+
}
|
|
428
|
+
} catch (error) {
|
|
429
|
+
serverError = error;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (serverError) throw toError(serverError);
|
|
433
|
+
if (e2eError) throw toError(e2eError);
|
|
434
|
+
}
|
|
435
|
+
completed = true;
|
|
436
|
+
} catch (error) {
|
|
437
|
+
await broker?.update({ type: 'status', status: 'failed' }).catch(() => {
|
|
438
|
+
console.error('Could not record failed test status; claim retained');
|
|
439
|
+
});
|
|
440
|
+
throw error;
|
|
441
|
+
} finally {
|
|
442
|
+
if (!completed) {
|
|
443
|
+
console.error(`Isolated test claim and resources retained for inspection: ${claimPath}`);
|
|
444
|
+
}
|
|
445
|
+
await broker?.close();
|
|
446
|
+
}
|
|
447
|
+
const result = await cleanTestClaim(claimPath, {
|
|
448
|
+
projectPath: vonaDir,
|
|
449
|
+
isInactive: claim => isTestClaimInactive(claim, true),
|
|
450
|
+
warn: message => console.error(message),
|
|
451
|
+
});
|
|
452
|
+
if (result.status !== 'cleaned') {
|
|
453
|
+
console.error(`Isolated test claim retained for inspection: ${claimPath}`);
|
|
454
|
+
throw new Error('Isolated test succeeded but resource cleanup did not complete');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
459
|
+
const [kind, ...args] = process.argv.slice(2);
|
|
460
|
+
if (kind !== 'unit' && kind !== 'e2e') throw new Error('Expected unit or e2e test mode');
|
|
461
|
+
await runIsolatedTest(kind, args);
|
|
462
|
+
}
|