vona-core 5.1.41 → 5.1.44
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/dist/index.js +1021 -22
- package/dist/index.js.map +1 -1
- package/dist/lib/core/application.d.ts.map +1 -1
- package/dist/lib/core/config.d.ts.map +1 -1
- package/dist/lib/core/configCleanup.test.d.ts +2 -0
- package/dist/lib/core/configCleanup.test.d.ts.map +1 -0
- package/dist/lib/core/index.d.ts +3 -0
- package/dist/lib/core/index.d.ts.map +1 -1
- package/dist/lib/core/isolationGuard.d.ts +12 -0
- package/dist/lib/core/isolationGuard.d.ts.map +1 -0
- package/dist/lib/core/isolationGuard.test.d.ts +2 -0
- package/dist/lib/core/isolationGuard.test.d.ts.map +1 -0
- package/dist/lib/core/testClaim.d.ts +165 -0
- package/dist/lib/core/testClaim.d.ts.map +1 -0
- package/dist/lib/core/testClaim.test.d.ts +2 -0
- package/dist/lib/core/testClaim.test.d.ts.map +1 -0
- package/dist/lib/core/testIsolation.d.ts +21 -0
- package/dist/lib/core/testIsolation.d.ts.map +1 -0
- package/dist/lib/framework/createApp.d.ts.map +1 -1
- package/dist/lib/framework/start.d.ts.map +1 -1
- package/dist/lib/framework/useApp.d.ts.map +1 -1
- package/dist/lib/utils/util.d.ts.map +1 -1
- package/dist/types/application/app.d.ts +2 -0
- package/dist/types/application/app.d.ts.map +1 -1
- package/dist/types/utils/env.d.ts +1 -0
- package/dist/types/utils/env.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/lib/addon/sqlite3.ts +1 -1
- package/src/lib/core/application.ts +5 -6
- package/src/lib/core/config.ts +44 -6
- package/src/lib/core/configCleanup.test.ts +40 -0
- package/src/lib/core/index.ts +3 -0
- package/src/lib/core/isolationGuard.test.ts +301 -0
- package/src/lib/core/isolationGuard.ts +312 -0
- package/src/lib/core/testClaim.test.ts +577 -0
- package/src/lib/core/testClaim.ts +985 -0
- package/src/lib/core/testIsolation.ts +129 -0
- package/src/lib/framework/createApp.ts +2 -0
- package/src/lib/framework/start.ts +4 -0
- package/src/lib/framework/useApp.ts +5 -1
- package/src/lib/utils/util.ts +7 -2
- package/src/types/application/app.ts +2 -0
- package/src/types/utils/env.ts +1 -0
package/dist/index.js
CHANGED
|
@@ -2,16 +2,18 @@ import '@cabloy/json5';
|
|
|
2
2
|
import { combineApiPathControllerAndActionRaw, combineApiPathControllerAndAction, combineApiPath, combineResourceName, forEach, forEachSync, sleep, isUndefined, isClass, isNilOrEmptyString, evaluateExpressions, catchErrorSync, isEmptyObject, catchError, hashkey } from '@cabloy/utils';
|
|
3
3
|
import { toLowerCaseFirstChar, splitWords } from '@cabloy/word-utils';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import fse from 'fs-extra';
|
|
6
|
+
import crypto, { createHash as createHash$1, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
7
|
+
import fs, { lstatSync, constants } from 'node:fs';
|
|
5
8
|
import { compose as compose$1 } from '@cabloy/compose';
|
|
6
9
|
import { extend } from '@cabloy/extend';
|
|
7
|
-
import fse from 'fs-extra';
|
|
8
|
-
import crypto from 'node:crypto';
|
|
9
10
|
import { createRequire } from 'node:module';
|
|
10
11
|
import { pathToFileURL } from 'node:url';
|
|
11
12
|
import * as uuid from 'uuid';
|
|
12
|
-
import fs from 'node:fs';
|
|
13
13
|
import { getPlatformArch as getPlatformArch$1 } from 'vona';
|
|
14
14
|
import os from 'node:os';
|
|
15
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
16
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
15
17
|
import { setLocaleErrors, setLocaleAdapter, translateError } from '@cabloy/zod-errors-custom';
|
|
16
18
|
import { ZodMetadata } from '@cabloy/zod-openapi';
|
|
17
19
|
import { setParseAdapter } from '@cabloy/zod-query';
|
|
@@ -20,11 +22,12 @@ import { parseModuleName as parseModuleName$1, parseModuleFile as parseModuleFil
|
|
|
20
22
|
import * as localeutil from '@cabloy/localeutil';
|
|
21
23
|
import { getOnionMetasMeta } from '@cabloy/module-info';
|
|
22
24
|
import KoaApplication from 'koa';
|
|
23
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
24
25
|
import chalk from 'chalk';
|
|
25
26
|
import * as Winston from 'winston';
|
|
26
27
|
import DailyRotateFile from 'winston-daily-rotate-file';
|
|
27
28
|
import { MESSAGE, LEVEL } from 'triple-beam';
|
|
29
|
+
import { mkdir, open, rename, unlink } from 'node:fs/promises';
|
|
30
|
+
import { createServer } from 'node:http';
|
|
28
31
|
import cluster from 'node:cluster';
|
|
29
32
|
import React from 'react';
|
|
30
33
|
import { extendZodWithOpenApi } from '@cabloy/zod-to-openapi';
|
|
@@ -48,6 +51,92 @@ class BeanSimple {
|
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
const TEST_RUN_ID_ENV = 'VONA_TEST_RUN_ID';
|
|
55
|
+
const TEST_RUN_OWNER_FILE = '.vona-test-owner';
|
|
56
|
+
const DATABASE_NAME_MAX_BYTES = 63;
|
|
57
|
+
const APP_NAME_MAX_BYTES = 13;
|
|
58
|
+
const HASH_LENGTH = 10;
|
|
59
|
+
const TIMESTAMP_LENGTH = 15;
|
|
60
|
+
function getDatabaseTestPrefix(appName, scope) {
|
|
61
|
+
const readablePrefix = scope === 'share' ? `vona-test-${appName}-s-` : `vona-test-${appName}-i-${scope.slice('isolate-'.length)}-`;
|
|
62
|
+
if (Buffer.byteLength(readablePrefix, 'utf8') + TIMESTAMP_LENGTH <= DATABASE_NAME_MAX_BYTES) {
|
|
63
|
+
return readablePrefix;
|
|
64
|
+
}
|
|
65
|
+
let appHead = '';
|
|
66
|
+
for (const char of appName) {
|
|
67
|
+
if (Buffer.byteLength(`${appHead}${char}`, 'utf8') > APP_NAME_MAX_BYTES) break;
|
|
68
|
+
appHead += char;
|
|
69
|
+
}
|
|
70
|
+
const scopeKind = scope === 'share' ? 's' : 'i';
|
|
71
|
+
const namespaceHash = createHash$1('sha256').update(`${appName}\0${scope}`).digest('base64url').slice(0, HASH_LENGTH);
|
|
72
|
+
return `vona-test-${appHead}-${scopeKind}-${namespaceHash}-`;
|
|
73
|
+
}
|
|
74
|
+
function assertDisposableDatabaseName(databaseName, databasePrefix) {
|
|
75
|
+
if (typeof databaseName !== 'string' || !databaseName.startsWith(databasePrefix) || !/^\d{8}-\d{6}$/.test(databaseName.slice(databasePrefix.length)) || Buffer.byteLength(databaseName, 'utf8') > DATABASE_NAME_MAX_BYTES) {
|
|
76
|
+
throw new Error(`Database is outside the disposable namespace: ${databaseName}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function resolveTestRunId(mode, runId = process.env[TEST_RUN_ID_ENV]) {
|
|
80
|
+
if (runId === undefined) return undefined;
|
|
81
|
+
const match = /^(\d{14})-[a-f0-9]{8}$/.exec(runId);
|
|
82
|
+
const timestamp = match?.[1];
|
|
83
|
+
const date = timestamp ? new Date(`${timestamp.slice(0, 4)}-${timestamp.slice(4, 6)}-${timestamp.slice(6, 8)}T${timestamp.slice(8, 10)}:${timestamp.slice(10, 12)}:${timestamp.slice(12, 14)}Z`) : undefined;
|
|
84
|
+
if (mode !== 'test' || !timestamp || !date || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 19).replace(/[-:T]/g, '') !== timestamp) {
|
|
85
|
+
throw new Error(`${TEST_RUN_ID_ENV} requires test mode and a UTC timestamp plus eight hex digits`);
|
|
86
|
+
}
|
|
87
|
+
return runId;
|
|
88
|
+
}
|
|
89
|
+
function getTestAppName(runId) {
|
|
90
|
+
resolveTestRunId('test', runId);
|
|
91
|
+
return `cabtest${runId}`;
|
|
92
|
+
}
|
|
93
|
+
function assertTestAppIdentity(mode, runId, appName) {
|
|
94
|
+
const id = resolveTestRunId(mode, runId);
|
|
95
|
+
if (id && appName !== getTestAppName(id)) {
|
|
96
|
+
throw new Error('Isolated test APP_NAME does not match its run ID');
|
|
97
|
+
}
|
|
98
|
+
return id;
|
|
99
|
+
}
|
|
100
|
+
function getTestRunIdFromEnv(env) {
|
|
101
|
+
return resolveTestRunId(env.META_MODE, env[TEST_RUN_ID_ENV]);
|
|
102
|
+
}
|
|
103
|
+
function assertTestRunOwner(root, runId) {
|
|
104
|
+
resolveTestRunId('test', runId);
|
|
105
|
+
let current = path.resolve(root);
|
|
106
|
+
while (true) {
|
|
107
|
+
try {
|
|
108
|
+
if (lstatSync(current).isSymbolicLink()) {
|
|
109
|
+
throw new Error(`Isolated test path traverses a symlink: ${current}`);
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error.code !== 'ENOENT') throw error;
|
|
113
|
+
}
|
|
114
|
+
const parent = path.dirname(current);
|
|
115
|
+
if (parent === current) break;
|
|
116
|
+
current = parent;
|
|
117
|
+
}
|
|
118
|
+
const marker = path.join(root, TEST_RUN_OWNER_FILE);
|
|
119
|
+
if (!fse.lstatSync(marker).isFile() || fse.readFileSync(marker, 'utf8') !== `${runId}\n`) {
|
|
120
|
+
throw new Error(`Isolated test directory has no matching owner: ${root}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function resolveVonaLocalPaths(projectPath, mode, runId = process.env[TEST_RUN_ID_ENV]) {
|
|
124
|
+
const id = resolveTestRunId(mode, runId);
|
|
125
|
+
const generatedDir = id ? `.vona-test-${id}` : '.vona';
|
|
126
|
+
const dataDir = id ? `.app-test-${id}` : '.app';
|
|
127
|
+
return {
|
|
128
|
+
runId: id,
|
|
129
|
+
generatedDir,
|
|
130
|
+
dataDir,
|
|
131
|
+
generatedRoot: path.join(projectPath, generatedDir),
|
|
132
|
+
dataRoot: path.join(projectPath, dataDir),
|
|
133
|
+
publicRoot: path.join(projectPath, dataDir, 'public'),
|
|
134
|
+
runtimeRoot: path.join(projectPath, dataDir, 'runtime'),
|
|
135
|
+
logsRoot: path.join(projectPath, dataDir, 'logs'),
|
|
136
|
+
sqliteRoot: path.join(projectPath, dataDir, 'sqlite3')
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
51
140
|
const EnumAppEvent = {
|
|
52
141
|
AppStarted: 'eb:event:appStarted',
|
|
53
142
|
AppStartError: 'eb:event:appStartError'
|
|
@@ -91,6 +180,183 @@ function _matchSharpLibvipsFileName(fileName) {
|
|
|
91
180
|
return /\.so(?:\..+)?$/.test(fileName);
|
|
92
181
|
}
|
|
93
182
|
|
|
183
|
+
const databaseDiscovery = new AsyncLocalStorage();
|
|
184
|
+
async function withIsolatedTestDatabaseDiscovery(app, clientName, client, fn) {
|
|
185
|
+
if (!app.meta.env.VONA_TEST_RUN_ID) return fn();
|
|
186
|
+
if (app.meta.appReady) throw new Error('Isolated test cannot discover databases after startup');
|
|
187
|
+
const config = app.config;
|
|
188
|
+
const instanceClients = Object.values(config.instance.instances).flatMap(instance => instance && instance.isolate && instance.isolateClient ? [instance.isolateClient] : []);
|
|
189
|
+
if (!client || clientName !== config.database.defaultClient && !instanceClients.includes(clientName) || config.database.clients[clientName]?.client !== client.client || !isDeepStrictEqual(client.connection, config.database.clients[clientName].connection) || !client.connection || typeof client.connection !== 'object' || Array.isArray(client.connection)) {
|
|
190
|
+
throw new Error(`Isolated test cannot discover an unconfigured database client: ${clientName}`);
|
|
191
|
+
}
|
|
192
|
+
return databaseDiscovery.run({
|
|
193
|
+
app,
|
|
194
|
+
clientName,
|
|
195
|
+
client
|
|
196
|
+
}, async () => {
|
|
197
|
+
assertIsolatedTestDatabaseClient(app, clientName, client);
|
|
198
|
+
return await fn();
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
function assertIsolatedTestDatabaseClient(app, clientName, client) {
|
|
202
|
+
const id = assertTestAppIdentity(app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID, app.name);
|
|
203
|
+
if (!id) return;
|
|
204
|
+
const paths = resolveVonaLocalPaths(app.projectPath, 'test', id);
|
|
205
|
+
assertTestRunOwner(paths.dataRoot, id);
|
|
206
|
+
if (client.connectionPool !== undefined) {
|
|
207
|
+
throw new Error(`Isolated test database cannot use an external pool: ${clientName}`);
|
|
208
|
+
}
|
|
209
|
+
if (!client.connection || typeof client.connection !== 'object' || Array.isArray(client.connection)) {
|
|
210
|
+
throw new Error(`Isolated test database requires an explicit connection target: ${clientName}`);
|
|
211
|
+
}
|
|
212
|
+
const connection = client.connection;
|
|
213
|
+
if (connection.connectionString !== undefined || connection.uri !== undefined) {
|
|
214
|
+
throw new Error(`Isolated test database cannot use a connection URL: ${clientName}`);
|
|
215
|
+
}
|
|
216
|
+
if (!['better-sqlite3', 'pg', 'mysql', 'mysql2'].includes(client.client)) {
|
|
217
|
+
throw new Error(`Isolated test database client is unsupported: ${clientName}`);
|
|
218
|
+
}
|
|
219
|
+
if (client.client === 'better-sqlite3' ? connection.database !== undefined : connection.filename !== undefined) {
|
|
220
|
+
throw new Error(`Isolated test database has conflicting connection targets: ${clientName}`);
|
|
221
|
+
}
|
|
222
|
+
const config = app.config;
|
|
223
|
+
const configured = config.database;
|
|
224
|
+
const discovering = databaseDiscovery.getStore();
|
|
225
|
+
const target = client.client === 'better-sqlite3' ? connection.filename : connection.database;
|
|
226
|
+
if (discovering?.app === app && discovering.clientName === clientName && !app.meta.appReady && configured.clients[clientName]?.client === client.client && client.client === discovering.client.client && isDeepStrictEqual(connection, discovering.client.connection) && isDeepStrictEqual(connection, configured.clients[clientName].connection)) {
|
|
227
|
+
const discoveryTarget = client.client === 'better-sqlite3' ? '' : client.client === 'pg' ? 'postgres' : 'mysql';
|
|
228
|
+
if (target === discoveryTarget || client.client === 'better-sqlite3' && target === ':memory:') {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const scopes = new Set(['share']);
|
|
233
|
+
for (const [instanceName, instance] of Object.entries(config.instance.instances)) {
|
|
234
|
+
if (instance && instance.isolate && instance.isolateClient) {
|
|
235
|
+
scopes.add(`isolate-${instanceName}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (typeof target !== 'string') {
|
|
239
|
+
throw new TypeError(`Isolated test database requires a disposable target: ${clientName}`);
|
|
240
|
+
}
|
|
241
|
+
let matched = false;
|
|
242
|
+
for (const scope of scopes) {
|
|
243
|
+
const prefix = getDatabaseTestPrefix(app.name, scope);
|
|
244
|
+
const databaseName = client.client === 'better-sqlite3' ? path.basename(target, '.db') : target;
|
|
245
|
+
try {
|
|
246
|
+
assertDisposableDatabaseName(databaseName, prefix);
|
|
247
|
+
matched = true;
|
|
248
|
+
break;
|
|
249
|
+
} catch {
|
|
250
|
+
// Try the next owned namespace.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!matched) throw new Error(`Isolated test database is outside its namespace: ${clientName}`);
|
|
254
|
+
if (client.client === 'better-sqlite3') {
|
|
255
|
+
if (!path.isAbsolute(target) || path.extname(target) !== '.db') {
|
|
256
|
+
throw new Error(`Isolated test SQLite target must be an owned .db file: ${clientName}`);
|
|
257
|
+
}
|
|
258
|
+
assertIsolatedTestPath(target, path.join(paths.sqliteRoot, path.basename(target)));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function assertIsolatedTestPath(actual, expected) {
|
|
262
|
+
if (path.resolve(actual) !== path.resolve(expected)) {
|
|
263
|
+
throw new Error(`Isolated test path escapes its owned root: ${actual}`);
|
|
264
|
+
}
|
|
265
|
+
let current = path.resolve(actual);
|
|
266
|
+
while (true) {
|
|
267
|
+
try {
|
|
268
|
+
if (lstatSync(current).isSymbolicLink()) {
|
|
269
|
+
throw new Error(`Isolated test path traverses a symlink: ${current}`);
|
|
270
|
+
}
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error.code !== 'ENOENT') throw error;
|
|
273
|
+
}
|
|
274
|
+
const parent = path.dirname(current);
|
|
275
|
+
if (parent === current) break;
|
|
276
|
+
current = parent;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function assertIsolatedTestConfig(app) {
|
|
280
|
+
const env = app.meta.env;
|
|
281
|
+
const id = assertTestAppIdentity(env.META_MODE, env.VONA_TEST_RUN_ID, app.name);
|
|
282
|
+
if (!id) return;
|
|
283
|
+
const paths = resolveVonaLocalPaths(app.projectPath, env.META_MODE, id);
|
|
284
|
+
assertTestRunOwner(paths.dataRoot, id);
|
|
285
|
+
assertTestRunOwner(paths.generatedRoot, id);
|
|
286
|
+
assertIsolatedTestPath(app.config.server.publicDir, paths.publicRoot);
|
|
287
|
+
assertIsolatedTestPath(app.config.logger.baseDir, paths.logsRoot);
|
|
288
|
+
assertIsolatedTestPath(paths.runtimeRoot, paths.runtimeRoot);
|
|
289
|
+
assertIsolatedTestPath(paths.sqliteRoot, paths.sqliteRoot);
|
|
290
|
+
if (app.config.server.workers !== 1) throw new Error('Isolated test requires one Vona worker');
|
|
291
|
+
const config = app.config;
|
|
292
|
+
if (process.env.VONA_DB_RESET !== undefined) {
|
|
293
|
+
throw new Error('Isolated test cannot run a development database reset');
|
|
294
|
+
}
|
|
295
|
+
if (!config.database.testDatabase) {
|
|
296
|
+
throw new Error('Isolated test requires disposable test database configuration');
|
|
297
|
+
}
|
|
298
|
+
if (typeof config.database.defaultClient !== 'string') {
|
|
299
|
+
throw new TypeError('Isolated test requires a static default database client');
|
|
300
|
+
}
|
|
301
|
+
if (config.database.base.connection !== undefined || config.database.base.connectionPool !== undefined) {
|
|
302
|
+
throw new Error('Isolated test cannot inherit a database base connection target or pool');
|
|
303
|
+
}
|
|
304
|
+
const clientNames = new Set([config.database.defaultClient]);
|
|
305
|
+
const instances = config.instance.instances;
|
|
306
|
+
for (const instance of Object.values(instances)) {
|
|
307
|
+
if (!instance || !instance.isolate) continue;
|
|
308
|
+
if (!instance.isolateClient) throw new Error('Isolated test instance requires an isolateClient');
|
|
309
|
+
clientNames.add(instance.isolateClient);
|
|
310
|
+
}
|
|
311
|
+
for (const clientName of clientNames) {
|
|
312
|
+
if (!config.database.clients[clientName]) {
|
|
313
|
+
throw new Error(`Isolated test has no configured database client: ${clientName}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const [clientName, client] of Object.entries(config.database.clients)) {
|
|
317
|
+
if (client.connectionPool !== undefined) {
|
|
318
|
+
throw new Error(`Isolated test database client has an external target or pool: ${clientName}`);
|
|
319
|
+
}
|
|
320
|
+
// Optional, unused clients may intentionally remain empty in local configuration.
|
|
321
|
+
// Any configured connection is still validated, even when that client is not selected.
|
|
322
|
+
if (client.connection === undefined && !clientNames.has(clientName)) continue;
|
|
323
|
+
if (typeof client.connection !== 'object' || client.connection === null || Array.isArray(client.connection) || client.connection.connectionString !== undefined || client.connection.uri !== undefined) {
|
|
324
|
+
throw new Error(`Isolated test database client has an external target or pool: ${clientName}`);
|
|
325
|
+
}
|
|
326
|
+
if (!clientNames.has(clientName)) continue;
|
|
327
|
+
if (!['better-sqlite3', 'pg', 'mysql', 'mysql2'].includes(client.client)) {
|
|
328
|
+
throw new Error(`Isolated test has an unsupported database client: ${clientName}`);
|
|
329
|
+
}
|
|
330
|
+
const connection = client.connection;
|
|
331
|
+
const expected = client.client === 'better-sqlite3' ? '' : client.client === 'pg' ? 'postgres' : 'mysql';
|
|
332
|
+
const target = client.client === 'better-sqlite3' ? connection.filename : connection.database;
|
|
333
|
+
if (target !== expected && !(client.client === 'better-sqlite3' && target === ':memory:')) {
|
|
334
|
+
throw new Error(`Isolated test requires an administrative discovery connection: ${clientName}`);
|
|
335
|
+
}
|
|
336
|
+
if (client.client === 'better-sqlite3' ? connection.database !== undefined : connection.filename !== undefined) {
|
|
337
|
+
throw new Error(`Isolated test discovery connection has conflicting targets: ${clientName}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const redis = config.redis;
|
|
341
|
+
if (redis.base.keyPrefix !== undefined) {
|
|
342
|
+
throw new Error('Isolated test cannot inherit a Redis base keyPrefix');
|
|
343
|
+
}
|
|
344
|
+
for (const [clientName, client] of Object.entries(redis.clients)) {
|
|
345
|
+
const special = ['queue', 'broadcast'].includes(clientName) || clientName.startsWith('redlock');
|
|
346
|
+
if (client.sentinels || client.nodes) {
|
|
347
|
+
throw new Error(`Isolated test cannot use a Redis sentinel or cluster client: ${clientName}`);
|
|
348
|
+
}
|
|
349
|
+
if (special ? client.keyPrefix !== undefined : client.keyPrefix !== true) {
|
|
350
|
+
throw new Error(`Isolated test requires a framework-managed Redis prefix: ${clientName}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const expectedPort = Number(env.SERVER_LISTEN_PORT);
|
|
354
|
+
const requiresServerOrigin = process.env.VONA_TEST_SERVER_ORIGIN === 'true';
|
|
355
|
+
if (!Number.isInteger(expectedPort) || expectedPort <= 0 || expectedPort > 65535 || app.config.server.listen.port !== expectedPort || app.config.server.listen.hostname !== '127.0.0.1' || requiresServerOrigin && (app.config.server.serve.protocol !== 'http' || app.config.server.serve.host !== `127.0.0.1:${expectedPort}`)) {
|
|
356
|
+
throw new Error(requiresServerOrigin ? 'Isolated test listener and server origin must match the assigned loopback port' : 'Isolated test listener must match the assigned loopback port');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
94
360
|
async function combineConfigDefault(app, configDefault, configDev, configProd, configTest) {
|
|
95
361
|
let config = await configDefault(app);
|
|
96
362
|
const mode = app.meta.env.META_MODE;
|
|
@@ -107,35 +373,50 @@ function getLoggerPathPhysicalRoot(app) {
|
|
|
107
373
|
const mode = app.meta.env.META_MODE;
|
|
108
374
|
let loggerDir;
|
|
109
375
|
if (mode === 'test' || mode === 'dev') {
|
|
110
|
-
loggerDir =
|
|
376
|
+
loggerDir = resolveVonaLocalPaths(app.projectPath, mode, app.meta.env.VONA_TEST_RUN_ID).logsRoot;
|
|
111
377
|
} else {
|
|
112
378
|
loggerDir = path.join(getHomeVonaAppDir(app), 'logs');
|
|
113
379
|
}
|
|
114
|
-
|
|
380
|
+
if (!app.options.configResolutionOnly) {
|
|
381
|
+
assertOwnedLocalPath(app, loggerDir, 'logsRoot');
|
|
382
|
+
fse.ensureDirSync(loggerDir);
|
|
383
|
+
}
|
|
115
384
|
return loggerDir;
|
|
116
385
|
}
|
|
117
386
|
function getPublicPathPhysicalRoot(app) {
|
|
118
387
|
const mode = app.meta.env.META_MODE;
|
|
119
388
|
let publicDir;
|
|
120
389
|
if (mode === 'test' || mode === 'dev') {
|
|
121
|
-
publicDir =
|
|
390
|
+
publicDir = resolveVonaLocalPaths(app.projectPath, mode, app.meta.env.VONA_TEST_RUN_ID).publicRoot;
|
|
122
391
|
} else {
|
|
123
392
|
publicDir = path.join(getHomeVonaAppDir(app), 'public');
|
|
124
393
|
}
|
|
125
|
-
|
|
394
|
+
if (!app.options.configResolutionOnly) {
|
|
395
|
+
assertOwnedLocalPath(app, publicDir, 'publicRoot');
|
|
396
|
+
fse.ensureDirSync(publicDir);
|
|
397
|
+
}
|
|
126
398
|
return publicDir;
|
|
127
399
|
}
|
|
128
400
|
function getRuntimePathPhysicalRoot(app) {
|
|
129
401
|
const mode = app.meta.env.META_MODE;
|
|
130
402
|
let runtimeDir;
|
|
131
403
|
if (mode === 'test' || mode === 'dev') {
|
|
132
|
-
runtimeDir =
|
|
404
|
+
runtimeDir = resolveVonaLocalPaths(app.options.projectPath, mode, app.meta.env.VONA_TEST_RUN_ID).runtimeRoot;
|
|
133
405
|
} else {
|
|
134
406
|
runtimeDir = path.join(getHomeVonaAppDir(app), 'runtime');
|
|
135
407
|
}
|
|
136
|
-
|
|
408
|
+
if (!app.options.configResolutionOnly) {
|
|
409
|
+
assertOwnedLocalPath(app, runtimeDir, 'runtimeRoot');
|
|
410
|
+
fse.ensureDirSync(runtimeDir);
|
|
411
|
+
}
|
|
137
412
|
return runtimeDir;
|
|
138
413
|
}
|
|
414
|
+
function assertOwnedLocalPath(app, actual, leaf) {
|
|
415
|
+
const paths = resolveVonaLocalPaths(app.projectPath, app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID);
|
|
416
|
+
if (!paths.runId) return;
|
|
417
|
+
assertTestRunOwner(paths.dataRoot, paths.runId);
|
|
418
|
+
assertIsolatedTestPath(actual, paths[leaf]);
|
|
419
|
+
}
|
|
139
420
|
function getHomeVonaAppDir(app) {
|
|
140
421
|
return path.join(os.homedir(), '.vona', app.name);
|
|
141
422
|
}
|
|
@@ -144,7 +425,7 @@ function getSqlite3DatabaseNameDefault(app) {
|
|
|
144
425
|
const mode = app.meta.env.META_MODE;
|
|
145
426
|
if (mode !== 'prod') return '';
|
|
146
427
|
const dbPath = path.join(getHomeVonaAppDir(app), 'sqlite3');
|
|
147
|
-
fse.ensureDirSync(dbPath);
|
|
428
|
+
if (!app.options.configResolutionOnly) fse.ensureDirSync(dbPath);
|
|
148
429
|
return path.join(dbPath, `${app.name}.db`);
|
|
149
430
|
}
|
|
150
431
|
function resolverSqlite3(projectPath) {
|
|
@@ -234,11 +515,16 @@ class AppUtil extends BeanSimple {
|
|
|
234
515
|
}
|
|
235
516
|
get protocol() {
|
|
236
517
|
const config = this.ctx ? this.ctx.config : this.app.config;
|
|
237
|
-
return config.server.serve.protocol || this.ctx?.protocol;
|
|
518
|
+
return config.server.serve.protocol || this.ctx?.protocol || 'http';
|
|
238
519
|
}
|
|
239
520
|
get host() {
|
|
240
521
|
const config = this.ctx ? this.ctx.config : this.app.config;
|
|
241
|
-
|
|
522
|
+
let host = config.server.serve.host || this.ctx?.host;
|
|
523
|
+
if (!host) {
|
|
524
|
+
const port = this.app.meta.env.SERVER_LISTEN_PORT;
|
|
525
|
+
host = port ? `localhost:${port}` : 'localhost';
|
|
526
|
+
}
|
|
527
|
+
return host;
|
|
242
528
|
}
|
|
243
529
|
getAbsoluteUrl(path) {
|
|
244
530
|
const prefix = this.host ? `${this.protocol}://${this.host}` : '';
|
|
@@ -529,7 +815,7 @@ async function createGeneralApp(projectPath, envRuntime) {
|
|
|
529
815
|
const testInstance = await import(pathToHref(testFile));
|
|
530
816
|
return await testInstance.appBootstrap;
|
|
531
817
|
} else {
|
|
532
|
-
const testFile = path.join(projectPath, '
|
|
818
|
+
const testFile = path.join(resolveVonaLocalPaths(projectPath, process.env.META_MODE).generatedRoot, 'app.ts');
|
|
533
819
|
const testInstance = await import(pathToHref(testFile));
|
|
534
820
|
return await testInstance.createSingleApp();
|
|
535
821
|
}
|
|
@@ -1915,7 +2201,7 @@ function $makeLocaleMagic(str, ...args) {
|
|
|
1915
2201
|
}
|
|
1916
2202
|
|
|
1917
2203
|
// not use : ILocaleMagic
|
|
1918
|
-
function text(strings, ...expressions) {
|
|
2204
|
+
function text$1(strings, ...expressions) {
|
|
1919
2205
|
return {
|
|
1920
2206
|
toString() {
|
|
1921
2207
|
let result = '';
|
|
@@ -2678,10 +2964,9 @@ class VonaApplication extends KoaApplication {
|
|
|
2678
2964
|
if (req) {
|
|
2679
2965
|
request = req;
|
|
2680
2966
|
} else {
|
|
2681
|
-
const
|
|
2682
|
-
const
|
|
2683
|
-
const
|
|
2684
|
-
const host = (this.config ? this.util.host : undefined) || hostFallback;
|
|
2967
|
+
const protocol = 'http';
|
|
2968
|
+
const port = this.meta.env.SERVER_LISTEN_PORT;
|
|
2969
|
+
const host = port ? `localhost:${port}` : 'localhost';
|
|
2685
2970
|
const hostname = new URL(`${protocol}://${host}`).hostname;
|
|
2686
2971
|
request = {
|
|
2687
2972
|
headers: {
|
|
@@ -2695,12 +2980,12 @@ class VonaApplication extends KoaApplication {
|
|
|
2695
2980
|
host,
|
|
2696
2981
|
hostname,
|
|
2697
2982
|
protocol,
|
|
2698
|
-
secure:
|
|
2983
|
+
secure: false,
|
|
2699
2984
|
method: 'POST',
|
|
2700
2985
|
url: '',
|
|
2701
2986
|
path: '',
|
|
2702
2987
|
socket: {
|
|
2703
|
-
encrypted:
|
|
2988
|
+
encrypted: false,
|
|
2704
2989
|
remoteAddress: '127.0.0.1',
|
|
2705
2990
|
remotePort: 7001
|
|
2706
2991
|
}
|
|
@@ -2891,6 +3176,717 @@ function __formatLoggerFilterCheckInfo(info) {
|
|
|
2891
3176
|
return info;
|
|
2892
3177
|
}
|
|
2893
3178
|
|
|
3179
|
+
const TEST_CLAIM_URL_ENV = 'VONA_TEST_CLAIM_URL';
|
|
3180
|
+
const TEST_CLAIM_TOKEN_ENV = 'VONA_TEST_CLAIM_TOKEN';
|
|
3181
|
+
const TEST_CLAIM_DIRECTORY = '.vona-test-claim';
|
|
3182
|
+
function getTestClaimPaths(projectPath, runId) {
|
|
3183
|
+
resolveTestRunId('test', runId);
|
|
3184
|
+
const project = path.resolve(projectPath);
|
|
3185
|
+
const local = resolveVonaLocalPaths(project, 'test', runId);
|
|
3186
|
+
const claimDirectory = path.join(project, TEST_CLAIM_DIRECTORY);
|
|
3187
|
+
return {
|
|
3188
|
+
...local,
|
|
3189
|
+
claimDirectory,
|
|
3190
|
+
claimPath: path.join(claimDirectory, `${runId}.json`)
|
|
3191
|
+
};
|
|
3192
|
+
}
|
|
3193
|
+
function getTestClaimCreatedAt(runId) {
|
|
3194
|
+
resolveTestRunId('test', runId);
|
|
3195
|
+
const s = runId.slice(0, 14);
|
|
3196
|
+
return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(8, 10)}:${s.slice(10, 12)}:${s.slice(12, 14)}.000Z`;
|
|
3197
|
+
}
|
|
3198
|
+
function assertTestClaimSafePath(file, allowMissing = true) {
|
|
3199
|
+
let current = path.resolve(file);
|
|
3200
|
+
while (true) {
|
|
3201
|
+
try {
|
|
3202
|
+
const stat = lstatSync(current);
|
|
3203
|
+
if (stat.isSymbolicLink() || current !== path.resolve(file) && !stat.isDirectory()) {
|
|
3204
|
+
throw new Error('Unsafe test claim path');
|
|
3205
|
+
}
|
|
3206
|
+
} catch (error) {
|
|
3207
|
+
if (!allowMissing || error.code !== 'ENOENT') throw error;
|
|
3208
|
+
}
|
|
3209
|
+
const parent = path.dirname(current);
|
|
3210
|
+
if (parent === current) break;
|
|
3211
|
+
current = parent;
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
function record(value) {
|
|
3215
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid test claim object');
|
|
3216
|
+
}
|
|
3217
|
+
function keys(value, allowed) {
|
|
3218
|
+
record(value);
|
|
3219
|
+
if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error('Unknown test claim field');
|
|
3220
|
+
}
|
|
3221
|
+
function text(value) {
|
|
3222
|
+
if (typeof value !== 'string' || !value || value.length > 4096 || [...value].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) {
|
|
3223
|
+
throw new Error('Invalid test claim text');
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
function label(value) {
|
|
3227
|
+
text(value);
|
|
3228
|
+
if (!/^[\w.-]+$/.test(value)) throw new Error('Invalid test claim label');
|
|
3229
|
+
}
|
|
3230
|
+
function integer(value, min, max = Number.MAX_SAFE_INTEGER) {
|
|
3231
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error('Invalid test claim integer');
|
|
3232
|
+
}
|
|
3233
|
+
function oneOf(value, values) {
|
|
3234
|
+
if (!values.includes(value)) throw new Error('Invalid test claim state');
|
|
3235
|
+
}
|
|
3236
|
+
function bool(value) {
|
|
3237
|
+
if (typeof value !== 'boolean') throw new Error('Invalid test claim boolean');
|
|
3238
|
+
}
|
|
3239
|
+
function array(value) {
|
|
3240
|
+
if (!Array.isArray(value) || value.length > 10000) throw new Error('Invalid test claim array');
|
|
3241
|
+
}
|
|
3242
|
+
function unique(values) {
|
|
3243
|
+
if (new Set(values).size !== values.length) throw new Error('Duplicate test claim resource');
|
|
3244
|
+
}
|
|
3245
|
+
function validateEndpoint(value, redis) {
|
|
3246
|
+
keys(value, redis ? ['transport', 'host', 'port', 'socketPath', 'tls', 'db'] : ['dialect', 'transport', 'host', 'port', 'socketPath', 'tls', 'adminDatabase']);
|
|
3247
|
+
const endpoint = value;
|
|
3248
|
+
bool(endpoint.tls);
|
|
3249
|
+
oneOf(endpoint.transport, redis ? ['tcp', 'socket'] : ['tcp', 'socket', 'file']);
|
|
3250
|
+
if (redis) integer(endpoint.db, 0);else oneOf(endpoint.dialect, ['pg', 'mysql', 'mysql2', 'better-sqlite3']);
|
|
3251
|
+
if (endpoint.transport === 'tcp') {
|
|
3252
|
+
text(endpoint.host);
|
|
3253
|
+
if (/[\s/@?#\\]/.test(endpoint.host)) throw new Error('Invalid test claim host');
|
|
3254
|
+
integer(endpoint.port, 1, 65535);
|
|
3255
|
+
if (endpoint.socketPath !== undefined) throw new Error('Conflicting test claim endpoint');
|
|
3256
|
+
} else {
|
|
3257
|
+
text(endpoint.socketPath);
|
|
3258
|
+
if (!path.isAbsolute(endpoint.socketPath) || path.resolve(endpoint.socketPath) !== endpoint.socketPath || endpoint.host !== undefined) {
|
|
3259
|
+
throw new Error('Invalid test claim socket');
|
|
3260
|
+
}
|
|
3261
|
+
if (!redis && endpoint.dialect === 'pg' && endpoint.transport === 'socket') integer(endpoint.port, 1, 65535);else if (endpoint.port !== undefined) throw new Error('Invalid test claim socket port');
|
|
3262
|
+
}
|
|
3263
|
+
if (!redis) {
|
|
3264
|
+
if (endpoint.dialect === 'better-sqlite3') {
|
|
3265
|
+
if (endpoint.transport !== 'file' || endpoint.tls || endpoint.adminDatabase !== undefined) throw new Error('Invalid SQLite claim endpoint');
|
|
3266
|
+
} else if (endpoint.transport === 'file' || endpoint.adminDatabase !== (endpoint.dialect === 'pg' ? 'postgres' : 'mysql')) {
|
|
3267
|
+
throw new Error('Invalid database administration endpoint');
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
function validateTestClaim(value, claimPath) {
|
|
3272
|
+
keys(value, ['version', 'runId', 'createdAt', 'projectPath', 'kind', 'owner', 'status', 'locals', 'databases', 'redis', 'processes', 'cleanup']);
|
|
3273
|
+
const m = value;
|
|
3274
|
+
if (m.version !== 1) throw new Error('Unsupported test claim version');
|
|
3275
|
+
resolveTestRunId('test', m.runId);
|
|
3276
|
+
if (m.createdAt !== getTestClaimCreatedAt(m.runId)) throw new Error('Invalid test claim creation time');
|
|
3277
|
+
text(m.projectPath);
|
|
3278
|
+
if (!path.isAbsolute(m.projectPath) || path.resolve(m.projectPath) !== m.projectPath) throw new Error('Invalid test claim checkout');
|
|
3279
|
+
const p = getTestClaimPaths(m.projectPath, m.runId);
|
|
3280
|
+
if (claimPath && path.resolve(claimPath) !== p.claimPath) throw new Error('Test claim filename or checkout mismatch');
|
|
3281
|
+
oneOf(m.kind, ['unit', 'cov', 'e2e']);
|
|
3282
|
+
keys(m.owner, ['pid', 'startTime', 'host']);
|
|
3283
|
+
integer(m.owner.pid, 1);
|
|
3284
|
+
text(m.owner.startTime);
|
|
3285
|
+
text(m.owner.host);
|
|
3286
|
+
oneOf(m.status, ['allocated', 'running', 'failed', 'cleaning']);
|
|
3287
|
+
array(m.locals);
|
|
3288
|
+
unique(m.locals.map(item => item.kind));
|
|
3289
|
+
for (const item of m.locals) {
|
|
3290
|
+
keys(item, ['kind', 'path', 'stage']);
|
|
3291
|
+
oneOf(item.kind, ['generated', 'data']);
|
|
3292
|
+
oneOf(item.stage, ['intent', 'created', 'owned', 'released']);
|
|
3293
|
+
const expected = item.kind === 'generated' ? p.generatedRoot : p.dataRoot;
|
|
3294
|
+
if (item.path !== expected) throw new Error('Invalid test claim local root');
|
|
3295
|
+
}
|
|
3296
|
+
if (m.locals.length !== 2 || !m.locals.some(item => item.kind === 'data') || !m.locals.some(item => item.kind === 'generated')) {
|
|
3297
|
+
throw new Error('Missing test claim local roots');
|
|
3298
|
+
}
|
|
3299
|
+
array(m.databases);
|
|
3300
|
+
unique(m.databases.map(item => `${item.clientName}\0${item.scope}`));
|
|
3301
|
+
for (const db of m.databases) {
|
|
3302
|
+
keys(db, ['clientName', 'scope', 'prefix', 'endpoint', 'creations']);
|
|
3303
|
+
label(db.clientName);
|
|
3304
|
+
text(db.scope);
|
|
3305
|
+
if (db.scope !== 'share' && !/^isolate-[\w-]+$/.test(db.scope)) throw new Error('Invalid test claim database scope');
|
|
3306
|
+
if (db.prefix !== getDatabaseTestPrefix(getTestAppName(m.runId), db.scope)) throw new Error('Invalid test claim database prefix');
|
|
3307
|
+
validateEndpoint(db.endpoint, false);
|
|
3308
|
+
if (db.endpoint.dialect === 'better-sqlite3' && db.endpoint.socketPath !== p.sqliteRoot) throw new Error('Invalid test claim SQLite root');
|
|
3309
|
+
array(db.creations);
|
|
3310
|
+
unique(db.creations.map(item => item.name));
|
|
3311
|
+
for (const creation of db.creations) {
|
|
3312
|
+
keys(creation, ['name', 'stage']);
|
|
3313
|
+
oneOf(creation.stage, ['intent', 'created', 'released']);
|
|
3314
|
+
text(creation.name);
|
|
3315
|
+
let name = creation.name;
|
|
3316
|
+
if (db.endpoint.dialect === 'better-sqlite3') {
|
|
3317
|
+
if (name !== path.join(p.sqliteRoot, path.basename(name)) || path.extname(name) !== '.db') throw new Error('Invalid test claim SQLite target');
|
|
3318
|
+
name = path.basename(name, '.db');
|
|
3319
|
+
}
|
|
3320
|
+
assertDisposableDatabaseName(name, db.prefix);
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
array(m.redis);
|
|
3324
|
+
unique(m.redis.map(item => item.clientName));
|
|
3325
|
+
for (const item of m.redis) {
|
|
3326
|
+
keys(item, ['clientName', 'kind', 'prefix', 'endpoint', 'released']);
|
|
3327
|
+
label(item.clientName);
|
|
3328
|
+
oneOf(item.kind, ['client', 'queue', 'redlock']);
|
|
3329
|
+
const expected = getTestClaimRedisPrefix(m.runId, item.clientName);
|
|
3330
|
+
if (!expected || item.kind !== expected.kind || item.prefix !== expected.prefix) throw new Error('Invalid test claim Redis prefix');
|
|
3331
|
+
validateEndpoint(item.endpoint, true);
|
|
3332
|
+
bool(item.released);
|
|
3333
|
+
}
|
|
3334
|
+
array(m.processes);
|
|
3335
|
+
unique(m.processes.map(item => item.id));
|
|
3336
|
+
for (const proc of m.processes) {
|
|
3337
|
+
keys(proc, ['id', 'role', 'stage', 'pid', 'pgid', 'startTime', 'host', 'profilePath']);
|
|
3338
|
+
label(proc.id);
|
|
3339
|
+
oneOf(proc.role, ['guardian', 'browser', 'cleanup']);
|
|
3340
|
+
if (proc.host !== undefined) text(proc.host);
|
|
3341
|
+
oneOf(proc.stage, ['pending', 'registered', 'drained']);
|
|
3342
|
+
const hasIdentity = proc.pid !== undefined || proc.pgid !== undefined || proc.startTime !== undefined;
|
|
3343
|
+
if (hasIdentity || proc.stage !== 'pending') {
|
|
3344
|
+
integer(proc.pid, 1);
|
|
3345
|
+
integer(proc.pgid, 1);
|
|
3346
|
+
text(proc.startTime);
|
|
3347
|
+
}
|
|
3348
|
+
if (proc.profilePath !== undefined) {
|
|
3349
|
+
text(proc.profilePath);
|
|
3350
|
+
if (path.resolve(proc.profilePath) !== proc.profilePath || !proc.profilePath.startsWith(`${p.dataRoot}${path.sep}`)) {
|
|
3351
|
+
throw new Error('Invalid test claim process profile');
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
keys(m.cleanup, ['processes', 'databases', 'redis', 'locals']);
|
|
3356
|
+
for (const key of ['processes', 'databases', 'redis', 'locals']) bool(m.cleanup[key]);
|
|
3357
|
+
return m;
|
|
3358
|
+
}
|
|
3359
|
+
function assertPrivateFile(file) {
|
|
3360
|
+
assertTestClaimSafePath(file, false);
|
|
3361
|
+
const stat = lstatSync(file);
|
|
3362
|
+
if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || process.getuid && stat.uid !== process.getuid()) {
|
|
3363
|
+
throw new Error('Unsafe test claim file');
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
async function loadTestClaim(claimPath) {
|
|
3367
|
+
assertPrivateFile(claimPath);
|
|
3368
|
+
const handle = await open(claimPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
3369
|
+
try {
|
|
3370
|
+
const stat = await handle.stat();
|
|
3371
|
+
if (!stat.isFile() || stat.size > 4 * 1024 * 1024) throw new Error('Invalid test claim file');
|
|
3372
|
+
return validateTestClaim(parseTestClaimJson(await handle.readFile('utf8')), claimPath);
|
|
3373
|
+
} finally {
|
|
3374
|
+
await handle.close();
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
function parseTestClaimJson(source) {
|
|
3378
|
+
// Claim identities stay literal even when @cabloy/json5 revives ISO strings.
|
|
3379
|
+
return JSON.parse(source, function (key) {
|
|
3380
|
+
return this[key];
|
|
3381
|
+
});
|
|
3382
|
+
}
|
|
3383
|
+
async function syncDirectory(directory) {
|
|
3384
|
+
const handle = await open(directory, constants.O_RDONLY);
|
|
3385
|
+
try {
|
|
3386
|
+
await handle.sync();
|
|
3387
|
+
} finally {
|
|
3388
|
+
await handle.close();
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
async function createTestClaim(projectPath, runId, kind, owner) {
|
|
3392
|
+
const p = getTestClaimPaths(projectPath, runId);
|
|
3393
|
+
assertTestClaimSafePath(p.claimDirectory);
|
|
3394
|
+
await mkdir(p.claimDirectory, {
|
|
3395
|
+
mode: 0o700
|
|
3396
|
+
}).catch(error => {
|
|
3397
|
+
if (error.code !== 'EEXIST') throw error;
|
|
3398
|
+
});
|
|
3399
|
+
// The directory is shared across runs; an existing directory is valid, never an existing claim.
|
|
3400
|
+
return createClaimFile(p, runId, kind, owner);
|
|
3401
|
+
}
|
|
3402
|
+
async function createClaimFile(p, runId, kind, owner) {
|
|
3403
|
+
assertTestClaimSafePath(p.claimDirectory, false);
|
|
3404
|
+
const directoryStat = lstatSync(p.claimDirectory);
|
|
3405
|
+
if (!directoryStat.isDirectory() || (directoryStat.mode & 0o077) !== 0 || process.getuid && directoryStat.uid !== process.getuid()) {
|
|
3406
|
+
throw new Error('Unsafe shared test claim directory');
|
|
3407
|
+
}
|
|
3408
|
+
const manifest = {
|
|
3409
|
+
version: 1,
|
|
3410
|
+
runId,
|
|
3411
|
+
createdAt: getTestClaimCreatedAt(runId),
|
|
3412
|
+
projectPath: path.dirname(p.claimDirectory),
|
|
3413
|
+
kind,
|
|
3414
|
+
owner,
|
|
3415
|
+
status: 'allocated',
|
|
3416
|
+
locals: [{
|
|
3417
|
+
kind: 'generated',
|
|
3418
|
+
path: p.generatedRoot,
|
|
3419
|
+
stage: 'intent'
|
|
3420
|
+
}, {
|
|
3421
|
+
kind: 'data',
|
|
3422
|
+
path: p.dataRoot,
|
|
3423
|
+
stage: 'intent'
|
|
3424
|
+
}],
|
|
3425
|
+
databases: [],
|
|
3426
|
+
redis: [],
|
|
3427
|
+
processes: [],
|
|
3428
|
+
cleanup: {
|
|
3429
|
+
processes: false,
|
|
3430
|
+
databases: false,
|
|
3431
|
+
redis: false,
|
|
3432
|
+
locals: false
|
|
3433
|
+
}
|
|
3434
|
+
};
|
|
3435
|
+
validateTestClaim(manifest, p.claimPath);
|
|
3436
|
+
const handle = await open(p.claimPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
3437
|
+
try {
|
|
3438
|
+
await handle.writeFile(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
3439
|
+
await handle.sync();
|
|
3440
|
+
} finally {
|
|
3441
|
+
await handle.close();
|
|
3442
|
+
}
|
|
3443
|
+
await syncDirectory(p.claimDirectory);
|
|
3444
|
+
return {
|
|
3445
|
+
claimPath: p.claimPath,
|
|
3446
|
+
manifest
|
|
3447
|
+
};
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
// Only the live broker or a cleanup editor holding the exclusive cleanup lock may call this.
|
|
3451
|
+
async function writeTestClaim(claimPath, manifest) {
|
|
3452
|
+
validateTestClaim(manifest, claimPath);
|
|
3453
|
+
const previous = await loadTestClaim(claimPath);
|
|
3454
|
+
for (const key of ['version', 'runId', 'createdAt', 'projectPath', 'kind', 'owner']) {
|
|
3455
|
+
if (!isDeepStrictEqual(previous[key], manifest[key])) throw new Error('Test claim identity is immutable');
|
|
3456
|
+
}
|
|
3457
|
+
const temp = `${claimPath}.${randomBytes(12).toString('hex')}.tmp`;
|
|
3458
|
+
const handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
3459
|
+
try {
|
|
3460
|
+
try {
|
|
3461
|
+
await handle.writeFile(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
3462
|
+
await handle.sync();
|
|
3463
|
+
} finally {
|
|
3464
|
+
await handle.close();
|
|
3465
|
+
}
|
|
3466
|
+
assertPrivateFile(claimPath);
|
|
3467
|
+
await rename(temp, claimPath);
|
|
3468
|
+
await syncDirectory(path.dirname(claimPath));
|
|
3469
|
+
} finally {
|
|
3470
|
+
await unlink(temp).catch(error => {
|
|
3471
|
+
if (error.code !== 'ENOENT') throw error;
|
|
3472
|
+
});
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
function applyEvent(m, event) {
|
|
3476
|
+
record(event);
|
|
3477
|
+
switch (event.type) {
|
|
3478
|
+
case 'local':
|
|
3479
|
+
{
|
|
3480
|
+
keys(event, ['type', 'kind', 'stage']);
|
|
3481
|
+
const local = m.locals.find(item => item.kind === event.kind);
|
|
3482
|
+
if (!local) throw new Error('Unplanned test claim local root');
|
|
3483
|
+
const stages = ['intent', 'created', 'owned', 'released'];
|
|
3484
|
+
if (stages.indexOf(event.stage) < stages.indexOf(local.stage)) throw new Error('Test claim local stage regression');
|
|
3485
|
+
local.stage = event.stage;
|
|
3486
|
+
break;
|
|
3487
|
+
}
|
|
3488
|
+
case 'status':
|
|
3489
|
+
keys(event, ['type', 'status']);
|
|
3490
|
+
m.status = event.status;
|
|
3491
|
+
break;
|
|
3492
|
+
case 'process':
|
|
3493
|
+
{
|
|
3494
|
+
keys(event, ['type', 'process']);
|
|
3495
|
+
const old = m.processes.find(item => item.id === event.process.id);
|
|
3496
|
+
if (!old) {
|
|
3497
|
+
if (event.process.stage !== 'pending') throw new Error('Process needs a write-ahead intent');
|
|
3498
|
+
m.processes.push(event.process);
|
|
3499
|
+
} else {
|
|
3500
|
+
if (old.role !== event.process.role) throw new Error('Test claim process identity changed');
|
|
3501
|
+
if (event.process.profilePath !== undefined && old.profilePath !== event.process.profilePath) {
|
|
3502
|
+
if (old.stage !== 'pending' || event.process.stage !== 'registered' || !old.profilePath || !event.process.profilePath.startsWith(`${old.profilePath}${path.sep}`)) {
|
|
3503
|
+
throw new Error('Test claim process profile changed');
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
if (old.stage === 'drained' && event.process.stage !== 'drained' || old.stage === 'registered' && event.process.stage === 'pending') {
|
|
3507
|
+
throw new Error('Test claim process stage regression');
|
|
3508
|
+
}
|
|
3509
|
+
if (old.stage === 'pending' && event.process.stage === 'drained') throw new Error('Pending process cannot establish drainage');
|
|
3510
|
+
for (const key of ['pid', 'pgid', 'startTime', 'host']) {
|
|
3511
|
+
if (old[key] !== undefined && event.process[key] !== undefined && old[key] !== event.process[key]) {
|
|
3512
|
+
throw new Error('Test claim process identity changed');
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
Object.assign(old, event.process);
|
|
3516
|
+
}
|
|
3517
|
+
break;
|
|
3518
|
+
}
|
|
3519
|
+
case 'inventory':
|
|
3520
|
+
{
|
|
3521
|
+
keys(event, ['type', 'databases', 'redis']);
|
|
3522
|
+
for (const plan of event.databases) {
|
|
3523
|
+
keys(plan, ['clientName', 'scope', 'prefix', 'endpoint']);
|
|
3524
|
+
const old = m.databases.find(item => item.clientName === plan.clientName && item.scope === plan.scope);
|
|
3525
|
+
if (old) {
|
|
3526
|
+
const {
|
|
3527
|
+
creations: _,
|
|
3528
|
+
...oldPlan
|
|
3529
|
+
} = old;
|
|
3530
|
+
if (!isDeepStrictEqual(oldPlan, plan)) throw new Error('Database inventory drift');
|
|
3531
|
+
} else {
|
|
3532
|
+
m.databases.push({
|
|
3533
|
+
...plan,
|
|
3534
|
+
creations: []
|
|
3535
|
+
});
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
for (const resource of event.redis) {
|
|
3539
|
+
const old = m.redis.find(item => item.clientName === resource.clientName);
|
|
3540
|
+
if (old && !isDeepStrictEqual(old, resource)) throw new Error('Redis inventory drift');
|
|
3541
|
+
if (!old) m.redis.push(resource);
|
|
3542
|
+
}
|
|
3543
|
+
break;
|
|
3544
|
+
}
|
|
3545
|
+
case 'database':
|
|
3546
|
+
{
|
|
3547
|
+
keys(event, ['type', 'database', 'name', 'stage']);
|
|
3548
|
+
keys(event.database, ['clientName', 'scope', 'prefix', 'endpoint']);
|
|
3549
|
+
const db = m.databases.find(item => item.clientName === event.database.clientName && item.scope === event.database.scope);
|
|
3550
|
+
if (!db) throw new Error('Unplanned database creation');
|
|
3551
|
+
const {
|
|
3552
|
+
creations: _,
|
|
3553
|
+
...plan
|
|
3554
|
+
} = db;
|
|
3555
|
+
if (!isDeepStrictEqual(plan, event.database)) throw new Error('Database creation endpoint drift');
|
|
3556
|
+
const old = db.creations.find(item => item.name === event.name);
|
|
3557
|
+
if (!old) {
|
|
3558
|
+
if (event.stage !== 'intent') throw new Error('Database needs a write-ahead intent');
|
|
3559
|
+
db.creations.push({
|
|
3560
|
+
name: event.name,
|
|
3561
|
+
stage: event.stage
|
|
3562
|
+
});
|
|
3563
|
+
} else {
|
|
3564
|
+
if (['intent', 'created', 'released'].indexOf(event.stage) < ['intent', 'created', 'released'].indexOf(old.stage)) {
|
|
3565
|
+
throw new Error('Database creation stage regression');
|
|
3566
|
+
}
|
|
3567
|
+
old.stage = event.stage;
|
|
3568
|
+
}
|
|
3569
|
+
break;
|
|
3570
|
+
}
|
|
3571
|
+
case 'cleanup':
|
|
3572
|
+
keys(event, ['type', 'resource', 'complete']);
|
|
3573
|
+
if (!(event.resource in m.cleanup)) throw new Error('Unknown cleanup resource');
|
|
3574
|
+
m.cleanup[event.resource] = event.complete;
|
|
3575
|
+
break;
|
|
3576
|
+
default:
|
|
3577
|
+
throw new Error('Unknown test claim event');
|
|
3578
|
+
}
|
|
3579
|
+
validateTestClaim(m);
|
|
3580
|
+
}
|
|
3581
|
+
async function startTestClaimBroker(claimPath) {
|
|
3582
|
+
await loadTestClaim(claimPath);
|
|
3583
|
+
const token = randomBytes(32).toString('hex');
|
|
3584
|
+
let queue = Promise.resolve();
|
|
3585
|
+
let closing = false;
|
|
3586
|
+
const update = event => {
|
|
3587
|
+
if (closing) return Promise.reject(new Error('Test claim broker is closed'));
|
|
3588
|
+
const result = queue.then(async () => {
|
|
3589
|
+
const manifest = await loadTestClaim(claimPath);
|
|
3590
|
+
applyEvent(manifest, event);
|
|
3591
|
+
await writeTestClaim(claimPath, manifest);
|
|
3592
|
+
});
|
|
3593
|
+
queue = result.catch(() => {});
|
|
3594
|
+
return result;
|
|
3595
|
+
};
|
|
3596
|
+
const server = createServer(async (req, res) => {
|
|
3597
|
+
const auth = req.headers.authorization;
|
|
3598
|
+
const expected = `Bearer ${token}`;
|
|
3599
|
+
if (req.method !== 'POST' || req.url !== '/event' || typeof auth !== 'string' || Buffer.byteLength(auth) !== Buffer.byteLength(expected) || !timingSafeEqual(Buffer.from(auth), Buffer.from(expected))) {
|
|
3600
|
+
res.writeHead(403).end();
|
|
3601
|
+
req.resume();
|
|
3602
|
+
return;
|
|
3603
|
+
}
|
|
3604
|
+
try {
|
|
3605
|
+
let body = '';
|
|
3606
|
+
for await (const chunk of req) {
|
|
3607
|
+
body += chunk.toString('utf8');
|
|
3608
|
+
if (Buffer.byteLength(body) > 1024 * 1024) throw new Error('Test claim event too large');
|
|
3609
|
+
}
|
|
3610
|
+
await update(parseTestClaimJson(body));
|
|
3611
|
+
res.writeHead(204).end();
|
|
3612
|
+
} catch {
|
|
3613
|
+
res.writeHead(409).end('Test claim registration rejected');
|
|
3614
|
+
}
|
|
3615
|
+
});
|
|
3616
|
+
server.requestTimeout = 10000;
|
|
3617
|
+
server.headersTimeout = 10000;
|
|
3618
|
+
await new Promise((resolve, reject) => {
|
|
3619
|
+
server.once('error', reject);
|
|
3620
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
3621
|
+
});
|
|
3622
|
+
const address = server.address();
|
|
3623
|
+
if (!address || typeof address === 'string') throw new Error('Cannot bind test claim broker');
|
|
3624
|
+
return {
|
|
3625
|
+
env: {
|
|
3626
|
+
[TEST_CLAIM_URL_ENV]: `http://127.0.0.1:${address.port}/event`,
|
|
3627
|
+
[TEST_CLAIM_TOKEN_ENV]: token
|
|
3628
|
+
},
|
|
3629
|
+
update,
|
|
3630
|
+
async close() {
|
|
3631
|
+
closing = true;
|
|
3632
|
+
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
|
3633
|
+
await queue;
|
|
3634
|
+
}
|
|
3635
|
+
};
|
|
3636
|
+
}
|
|
3637
|
+
function hasTestClaimTransport(env = process.env) {
|
|
3638
|
+
const url = env[TEST_CLAIM_URL_ENV];
|
|
3639
|
+
const token = env[TEST_CLAIM_TOKEN_ENV];
|
|
3640
|
+
if (url === undefined && token === undefined) return false;
|
|
3641
|
+
if (!url || !token) throw new Error('Incomplete managed test claim transport');
|
|
3642
|
+
if (!resolveTestRunId(env.META_MODE, env.VONA_TEST_RUN_ID)) throw new Error('Managed test claim transport requires test identity');
|
|
3643
|
+
let parsed;
|
|
3644
|
+
try {
|
|
3645
|
+
parsed = new URL(url);
|
|
3646
|
+
} catch {
|
|
3647
|
+
throw new Error('Invalid managed test claim transport');
|
|
3648
|
+
}
|
|
3649
|
+
if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' || !parsed.port || parsed.pathname !== '/event' || parsed.username || parsed.password || parsed.search || parsed.hash || !/^[a-f0-9]{64}$/.test(token)) {
|
|
3650
|
+
throw new Error('Invalid managed test claim transport');
|
|
3651
|
+
}
|
|
3652
|
+
return true;
|
|
3653
|
+
}
|
|
3654
|
+
async function sendTestClaimEvent(env, event) {
|
|
3655
|
+
if (!hasTestClaimTransport(env)) return;
|
|
3656
|
+
try {
|
|
3657
|
+
const response = await fetch(env[TEST_CLAIM_URL_ENV], {
|
|
3658
|
+
method: 'POST',
|
|
3659
|
+
headers: {
|
|
3660
|
+
'authorization': `Bearer ${env[TEST_CLAIM_TOKEN_ENV]}`,
|
|
3661
|
+
'content-type': 'application/json'
|
|
3662
|
+
},
|
|
3663
|
+
body: JSON.stringify(event),
|
|
3664
|
+
signal: AbortSignal.timeout(10000),
|
|
3665
|
+
redirect: 'error'
|
|
3666
|
+
});
|
|
3667
|
+
if (response.status !== 204) throw new Error('Registration rejected');
|
|
3668
|
+
} catch {
|
|
3669
|
+
throw new Error('Managed test allocation registration failed');
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
function getTestClaimRedisPrefix(runId, clientName) {
|
|
3673
|
+
const appName = getTestAppName(runId);
|
|
3674
|
+
if (clientName === 'broadcast') return undefined;
|
|
3675
|
+
if (clientName === 'queue') return {
|
|
3676
|
+
kind: 'queue',
|
|
3677
|
+
prefix: `bull_${appName}_local:queue:`
|
|
3678
|
+
};
|
|
3679
|
+
if (clientName.startsWith('redlock')) return {
|
|
3680
|
+
kind: 'redlock',
|
|
3681
|
+
prefix: `redlock_${appName}_local:`
|
|
3682
|
+
};
|
|
3683
|
+
return {
|
|
3684
|
+
kind: 'client',
|
|
3685
|
+
prefix: `${clientName}_${appName}_local:`
|
|
3686
|
+
};
|
|
3687
|
+
}
|
|
3688
|
+
function assertNoTlsTargetOverrides(tls) {
|
|
3689
|
+
if (!tls) return;
|
|
3690
|
+
if (typeof tls === 'string') throw new Error('Unsupported managed TLS profile');
|
|
3691
|
+
if (typeof tls === 'object') {
|
|
3692
|
+
for (const key of ['host', 'port', 'path', 'socketPath', 'socket', 'stream', 'lookup', 'createConnection', 'servername']) {
|
|
3693
|
+
if (tls[key] !== undefined) throw new Error('Unsupported managed TLS target override');
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
// Only explicit connection fields are projected. Credentials, URLs and option snapshots never enter the journal.
|
|
3698
|
+
function normalizeTestClaimDatabaseEndpoint(client, projectPath, runId) {
|
|
3699
|
+
const dialect = client.client;
|
|
3700
|
+
record(client.connection);
|
|
3701
|
+
const c = client.connection;
|
|
3702
|
+
assertNoTlsTargetOverrides(c.ssl);
|
|
3703
|
+
if (c.connectionString !== undefined || c.uri !== undefined || client.connectionPool !== undefined || c.stream !== undefined || c.lookup !== undefined || client.driver !== undefined || client.dialect !== undefined || c.beforeConnect !== undefined) {
|
|
3704
|
+
throw new Error('Unsupported managed database target');
|
|
3705
|
+
}
|
|
3706
|
+
let endpoint;
|
|
3707
|
+
if (dialect === 'better-sqlite3') {
|
|
3708
|
+
endpoint = {
|
|
3709
|
+
dialect,
|
|
3710
|
+
transport: 'file',
|
|
3711
|
+
socketPath: getTestClaimPaths(projectPath, runId).sqliteRoot,
|
|
3712
|
+
tls: false
|
|
3713
|
+
};
|
|
3714
|
+
} else {
|
|
3715
|
+
if (!['pg', 'mysql', 'mysql2'].includes(dialect)) throw new Error('Unsupported managed database dialect');
|
|
3716
|
+
const socket = c.socketPath ?? (dialect === 'pg' && typeof c.host === 'string' && c.host.startsWith('/') ? c.host : undefined);
|
|
3717
|
+
const adminDatabase = dialect === 'pg' ? 'postgres' : 'mysql';
|
|
3718
|
+
if (socket !== undefined) {
|
|
3719
|
+
endpoint = {
|
|
3720
|
+
dialect,
|
|
3721
|
+
transport: 'socket',
|
|
3722
|
+
socketPath: socket,
|
|
3723
|
+
...(dialect === 'pg' ? {
|
|
3724
|
+
port: Number(c.port)
|
|
3725
|
+
} : {}),
|
|
3726
|
+
tls: Boolean(c.ssl),
|
|
3727
|
+
adminDatabase
|
|
3728
|
+
};
|
|
3729
|
+
} else {
|
|
3730
|
+
// pg otherwise consults mutable PGHOST/PGPORT environment defaults: fail closed.
|
|
3731
|
+
if (dialect === 'pg' && (c.host === undefined || c.port === undefined)) throw new Error('Managed PostgreSQL requires explicit host and port');
|
|
3732
|
+
endpoint = {
|
|
3733
|
+
dialect,
|
|
3734
|
+
transport: 'tcp',
|
|
3735
|
+
host: c.host ?? 'localhost',
|
|
3736
|
+
port: Number(c.port ?? 3306),
|
|
3737
|
+
tls: Boolean(c.ssl),
|
|
3738
|
+
adminDatabase
|
|
3739
|
+
};
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
validateEndpoint(endpoint, false);
|
|
3743
|
+
return endpoint;
|
|
3744
|
+
}
|
|
3745
|
+
function mergeClaimOptions(...sources) {
|
|
3746
|
+
const result = {};
|
|
3747
|
+
for (const source of sources) {
|
|
3748
|
+
for (const [key, value] of Object.entries(source ?? {})) {
|
|
3749
|
+
if (['__proto__', 'constructor', 'prototype'].includes(key)) throw new Error('Unsupported managed option field');
|
|
3750
|
+
if (value === undefined) continue;
|
|
3751
|
+
if (value && Object.getPrototypeOf(value) === Object.prototype) {
|
|
3752
|
+
const previous = result[key];
|
|
3753
|
+
result[key] = mergeClaimOptions(previous && Object.getPrototypeOf(previous) === Object.prototype ? previous : {}, value);
|
|
3754
|
+
} else {
|
|
3755
|
+
result[key] = value;
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
return result;
|
|
3760
|
+
}
|
|
3761
|
+
function mergeTestClaimDatabaseOptions(base, dialect, client) {
|
|
3762
|
+
return mergeClaimOptions(base, dialect ?? {}, client);
|
|
3763
|
+
}
|
|
3764
|
+
function mergeTestClaimRedisOptions(base, client) {
|
|
3765
|
+
// Match extend(true, ...) for connection/TLS objects and undefined values without importing runtime code.
|
|
3766
|
+
return mergeClaimOptions(base, client);
|
|
3767
|
+
}
|
|
3768
|
+
function normalizeTestClaimRedisEndpoint(options) {
|
|
3769
|
+
assertNoTlsTargetOverrides(options.tls);
|
|
3770
|
+
if (options.sentinels || options.nodes || options.url || options.connectionString || options.Connector !== undefined || options.connector !== undefined || options.stream !== undefined || options.dnsLookup !== undefined || options.natMap !== undefined) {
|
|
3771
|
+
throw new Error('Unsupported managed Redis target');
|
|
3772
|
+
}
|
|
3773
|
+
const endpoint = options.path !== undefined ? {
|
|
3774
|
+
transport: 'socket',
|
|
3775
|
+
socketPath: options.path,
|
|
3776
|
+
tls: Boolean(options.tls),
|
|
3777
|
+
db: Number(options.db ?? 0)
|
|
3778
|
+
} : {
|
|
3779
|
+
transport: 'tcp',
|
|
3780
|
+
host: options.host ?? 'localhost',
|
|
3781
|
+
port: Number(options.port ?? 6379),
|
|
3782
|
+
tls: Boolean(options.tls),
|
|
3783
|
+
db: Number(options.db ?? 0)
|
|
3784
|
+
};
|
|
3785
|
+
validateEndpoint(endpoint, true);
|
|
3786
|
+
return endpoint;
|
|
3787
|
+
}
|
|
3788
|
+
function buildTestClaimResourceInventory(app) {
|
|
3789
|
+
const id = resolveTestRunId(app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID);
|
|
3790
|
+
if (!id || app.name !== getTestAppName(id)) throw new Error('Managed resource inventory requires test identity');
|
|
3791
|
+
const database = app.config.database;
|
|
3792
|
+
const scopes = [['share', database.defaultClient]];
|
|
3793
|
+
for (const [name, instance] of Object.entries(app.config.instance.instances)) {
|
|
3794
|
+
const item = instance;
|
|
3795
|
+
if (item && item.isolate) scopes.push([`isolate-${name}`, item.isolateClient]);
|
|
3796
|
+
}
|
|
3797
|
+
const databases = scopes.map(([scope, clientName]) => {
|
|
3798
|
+
const client = database.clients[clientName];
|
|
3799
|
+
if (!client) throw new Error('Managed database client is missing');
|
|
3800
|
+
const options = mergeTestClaimDatabaseOptions(database.base, undefined, client);
|
|
3801
|
+
return {
|
|
3802
|
+
clientName,
|
|
3803
|
+
scope,
|
|
3804
|
+
prefix: getDatabaseTestPrefix(app.name, scope),
|
|
3805
|
+
endpoint: normalizeTestClaimDatabaseEndpoint(options, app.projectPath, id)
|
|
3806
|
+
};
|
|
3807
|
+
});
|
|
3808
|
+
const redis = [];
|
|
3809
|
+
for (const [clientName, client] of Object.entries(app.config.redis.clients)) {
|
|
3810
|
+
const prefix = getTestClaimRedisPrefix(id, clientName);
|
|
3811
|
+
if (!prefix) continue;
|
|
3812
|
+
const options = mergeTestClaimRedisOptions(app.config.redis.base, client);
|
|
3813
|
+
redis.push({
|
|
3814
|
+
clientName,
|
|
3815
|
+
...prefix,
|
|
3816
|
+
endpoint: normalizeTestClaimRedisEndpoint(options),
|
|
3817
|
+
released: false
|
|
3818
|
+
});
|
|
3819
|
+
}
|
|
3820
|
+
return {
|
|
3821
|
+
databases,
|
|
3822
|
+
redis
|
|
3823
|
+
};
|
|
3824
|
+
}
|
|
3825
|
+
const appInventories = new WeakMap();
|
|
3826
|
+
const appDatabaseIntents = new WeakMap();
|
|
3827
|
+
async function registerTestClaimResourceInventory(app) {
|
|
3828
|
+
if (!hasTestClaimTransport()) return;
|
|
3829
|
+
const inventory = buildTestClaimResourceInventory(app);
|
|
3830
|
+
await sendTestClaimEvent(process.env, {
|
|
3831
|
+
type: 'inventory',
|
|
3832
|
+
...inventory
|
|
3833
|
+
});
|
|
3834
|
+
appInventories.set(app, inventory);
|
|
3835
|
+
appDatabaseIntents.set(app, new Set());
|
|
3836
|
+
}
|
|
3837
|
+
function assertTestClaimDatabaseClient(app, clientName, config) {
|
|
3838
|
+
if (!hasTestClaimTransport()) return;
|
|
3839
|
+
const inventory = appInventories.get(app);
|
|
3840
|
+
const id = resolveTestRunId(app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID);
|
|
3841
|
+
const endpoint = normalizeTestClaimDatabaseEndpoint(config, app.projectPath, id);
|
|
3842
|
+
const matches = inventory?.databases.filter(item => isDeepStrictEqual(item.endpoint, endpoint));
|
|
3843
|
+
if (!matches?.length) throw new Error('Unregistered managed database endpoint');
|
|
3844
|
+
const target = config.connection[endpoint.dialect === 'better-sqlite3' ? 'filename' : 'database'];
|
|
3845
|
+
const admin = endpoint.dialect === 'better-sqlite3' ? ['', ':memory:'].includes(target) : target === endpoint.adminDatabase;
|
|
3846
|
+
if (admin) {
|
|
3847
|
+
if (!matches.some(item => item.clientName === clientName)) throw new Error('Unregistered managed database endpoint');
|
|
3848
|
+
} else if (!matches.some(item => appDatabaseIntents.get(app)?.has(`${item.clientName}\0${target}`))) {
|
|
3849
|
+
throw new Error('Unregistered managed database allocation');
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
async function registerTestClaimDatabaseCreation(app, config, databaseName, stage) {
|
|
3853
|
+
if (!hasTestClaimTransport()) return;
|
|
3854
|
+
const id = resolveTestRunId(app.meta.env.META_MODE, app.meta.env.VONA_TEST_RUN_ID);
|
|
3855
|
+
const endpoint = normalizeTestClaimDatabaseEndpoint(config, app.projectPath, id);
|
|
3856
|
+
const plan = appInventories.get(app)?.databases.find(item => {
|
|
3857
|
+
if (!isDeepStrictEqual(item.endpoint, endpoint)) return false;
|
|
3858
|
+
try {
|
|
3859
|
+
assertDisposableDatabaseName(databaseName, item.prefix);
|
|
3860
|
+
return true;
|
|
3861
|
+
} catch {
|
|
3862
|
+
return false;
|
|
3863
|
+
}
|
|
3864
|
+
});
|
|
3865
|
+
if (!plan) throw new Error('Unregistered managed database creation endpoint');
|
|
3866
|
+
const name = endpoint.dialect === 'better-sqlite3' ? path.join(getTestClaimPaths(app.projectPath, id).sqliteRoot, `${databaseName}.db`) : databaseName;
|
|
3867
|
+
await sendTestClaimEvent(process.env, {
|
|
3868
|
+
type: 'database',
|
|
3869
|
+
database: plan,
|
|
3870
|
+
name,
|
|
3871
|
+
stage
|
|
3872
|
+
});
|
|
3873
|
+
appDatabaseIntents.get(app).add(`${plan.clientName}\0${name}`);
|
|
3874
|
+
}
|
|
3875
|
+
function assertTestClaimRedisClient(app, clientName, options) {
|
|
3876
|
+
if (!hasTestClaimTransport()) return;
|
|
3877
|
+
// Broadcast carries subscriptions only, but may not drift to an unregistered endpoint either.
|
|
3878
|
+
const inventory = appInventories.get(app);
|
|
3879
|
+
const endpoint = normalizeTestClaimRedisEndpoint(options);
|
|
3880
|
+
const plan = inventory?.redis.find(item => item.clientName === clientName);
|
|
3881
|
+
if (clientName === 'broadcast') {
|
|
3882
|
+
const current = normalizeTestClaimRedisEndpoint(mergeTestClaimRedisOptions(app.config.redis.base, app.config.redis.clients.broadcast));
|
|
3883
|
+
if (!inventory || !isDeepStrictEqual(current, endpoint)) throw new Error('Unregistered managed Redis broadcast endpoint');
|
|
3884
|
+
return;
|
|
3885
|
+
}
|
|
3886
|
+
if (!plan || !isDeepStrictEqual(plan.endpoint, endpoint)) throw new Error('Unregistered managed Redis endpoint');
|
|
3887
|
+
if (plan.kind === 'client' ? options.keyPrefix !== plan.prefix : options.keyPrefix !== undefined) throw new Error('Unregistered managed Redis key prefix');
|
|
3888
|
+
}
|
|
3889
|
+
|
|
2894
3890
|
function jsxEnhance() {
|
|
2895
3891
|
const createElementOriginal = React.createElement;
|
|
2896
3892
|
React.createElement = function (...args) {
|
|
@@ -3154,6 +4150,8 @@ class Start {
|
|
|
3154
4150
|
// config
|
|
3155
4151
|
const appConfig = await __prepareConfig(app);
|
|
3156
4152
|
this.app.config = appConfig;
|
|
4153
|
+
assertIsolatedTestConfig(app);
|
|
4154
|
+
await registerTestClaimResourceInventory(app);
|
|
3157
4155
|
this.app.keys = appConfig.server.keys;
|
|
3158
4156
|
this.app.proxy = appConfig.proxy.enable;
|
|
3159
4157
|
this.app.subdomainOffset = appConfig.server.subdomainOffset;
|
|
@@ -3237,6 +4235,7 @@ function __createApp({
|
|
|
3237
4235
|
jsxEnhance();
|
|
3238
4236
|
// env
|
|
3239
4237
|
const env2 = prepareEnv(env);
|
|
4238
|
+
assertTestAppIdentity(env2.META_MODE, env2.VONA_TEST_RUN_ID, env2.APP_NAME ?? '');
|
|
3240
4239
|
// appInfo
|
|
3241
4240
|
const appInfo = prepareAppInfo(env2);
|
|
3242
4241
|
// options
|
|
@@ -3486,5 +4485,5 @@ async function retry(options, fn) {
|
|
|
3486
4485
|
|
|
3487
4486
|
zodExtendOpenApi();
|
|
3488
4487
|
|
|
3489
|
-
export { $Class, $localeScope, $makeLocaleMagic, $protocolKey, AppHmr, AppHmrDeps, AppLocale, AppLogger, AppMeta, AppMetadata, AppResource, AppUtil, BeanAopBase, BeanAopMethodBase, BeanBase, BeanBaseSimple, BeanContainer, BeanInfo, BeanScopeBase, BeanScopeContainer, BeanScopeError, BeanScopeErrorImpl, BeanScopeLocale, BeanScopeScene, BeanScopeUtil, BeanSimple, EnumAppEvent, ErrorClass, Global, LocaleModuleNameSeparator, PickClassInner, ProxyDisable, SymbolBeanContainerInstances, SymbolBeanFullName, SymbolBeanInstanceKey, SymbolBeanInstancePropsLazy, SymbolCacheAopChains, SymbolCacheAopChainsKey, SymbolDecoratorBeanFullName, SymbolDecoratorBeanInfo, SymbolDecoratorGlobal, SymbolDecoratorProxyDisable, SymbolDecoratorUse, SymbolDecoratorVirtual, SymbolHmrStateLoad, SymbolHmrStateSave, SymbolMappedClassMetadataKeys, SymbolModuleBelong, SymbolModuleName, Use, Virtual, VonaApplication, __prepareInjectSelectorInfo, appHmrDeps, appMetadata, appResource, beanFullNameFromOnionName, bootstrap, cast, closeApp, combineConfigDefault, combineFilePathSafe, compose, copyAddon, copyMetadataOfClasses, copyProperties, copyPropertiesOfClasses, createApp, createAppMaster, createBeanDecorator, createGeneralApp, createHash, deepExtend, disposeInstance, errorsInternal, filterHeaders, formatLoggerAxiosError, formatLoggerConsole, formatLoggerCtx, formatLoggerDummy, formatLoggerErrors, formatLoggerFilter, functionNoop, getHomeVonaAppDir, getLoggerPathPhysicalRoot, getMappedClassMetadataKeys, getPlatformArch, getPublicPathPhysicalRoot, getRuntimePathPhysicalRoot, getSqlite3DatabaseNameDefault, instanceDesp, isLocaleMagic, loadJSONFile, onionNameFromBeanFullName, pathToHref, polyfillDispose, prepareEnv, registerMappedClassMetadataKey, requireAddon, requireDynamic, resolveAddon, resolverSharp, resolverSharpLibvips, resolverSqlite3, retry, saveJSONFile, setMappedClassMetadataKeys, text, useApp, usePrepareArg, usePrepareArgs, uuidv4 };
|
|
4488
|
+
export { $Class, $localeScope, $makeLocaleMagic, $protocolKey, AppHmr, AppHmrDeps, AppLocale, AppLogger, AppMeta, AppMetadata, AppResource, AppUtil, BeanAopBase, BeanAopMethodBase, BeanBase, BeanBaseSimple, BeanContainer, BeanInfo, BeanScopeBase, BeanScopeContainer, BeanScopeError, BeanScopeErrorImpl, BeanScopeLocale, BeanScopeScene, BeanScopeUtil, BeanSimple, EnumAppEvent, ErrorClass, Global, LocaleModuleNameSeparator, PickClassInner, ProxyDisable, SymbolBeanContainerInstances, SymbolBeanFullName, SymbolBeanInstanceKey, SymbolBeanInstancePropsLazy, SymbolCacheAopChains, SymbolCacheAopChainsKey, SymbolDecoratorBeanFullName, SymbolDecoratorBeanInfo, SymbolDecoratorGlobal, SymbolDecoratorProxyDisable, SymbolDecoratorUse, SymbolDecoratorVirtual, SymbolHmrStateLoad, SymbolHmrStateSave, SymbolMappedClassMetadataKeys, SymbolModuleBelong, SymbolModuleName, TEST_CLAIM_DIRECTORY, TEST_CLAIM_TOKEN_ENV, TEST_CLAIM_URL_ENV, TEST_RUN_ID_ENV, TEST_RUN_OWNER_FILE, Use, Virtual, VonaApplication, __prepareInjectSelectorInfo, appHmrDeps, appMetadata, appResource, assertDisposableDatabaseName, assertIsolatedTestConfig, assertIsolatedTestDatabaseClient, assertIsolatedTestPath, assertTestAppIdentity, assertTestClaimDatabaseClient, assertTestClaimRedisClient, assertTestClaimSafePath, assertTestRunOwner, beanFullNameFromOnionName, bootstrap, buildTestClaimResourceInventory, cast, closeApp, combineConfigDefault, combineFilePathSafe, compose, copyAddon, copyMetadataOfClasses, copyProperties, copyPropertiesOfClasses, createApp, createAppMaster, createBeanDecorator, createGeneralApp, createHash, createTestClaim, deepExtend, disposeInstance, errorsInternal, filterHeaders, formatLoggerAxiosError, formatLoggerConsole, formatLoggerCtx, formatLoggerDummy, formatLoggerErrors, formatLoggerFilter, functionNoop, getDatabaseTestPrefix, getHomeVonaAppDir, getLoggerPathPhysicalRoot, getMappedClassMetadataKeys, getPlatformArch, getPublicPathPhysicalRoot, getRuntimePathPhysicalRoot, getSqlite3DatabaseNameDefault, getTestAppName, getTestClaimCreatedAt, getTestClaimPaths, getTestClaimRedisPrefix, getTestRunIdFromEnv, hasTestClaimTransport, instanceDesp, isLocaleMagic, loadJSONFile, loadTestClaim, mergeTestClaimDatabaseOptions, mergeTestClaimRedisOptions, normalizeTestClaimDatabaseEndpoint, normalizeTestClaimRedisEndpoint, onionNameFromBeanFullName, pathToHref, polyfillDispose, prepareEnv, registerMappedClassMetadataKey, registerTestClaimDatabaseCreation, registerTestClaimResourceInventory, requireAddon, requireDynamic, resolveAddon, resolveTestRunId, resolveVonaLocalPaths, resolverSharp, resolverSharpLibvips, resolverSqlite3, retry, saveJSONFile, sendTestClaimEvent, setMappedClassMetadataKeys, startTestClaimBroker, text$1 as text, useApp, usePrepareArg, usePrepareArgs, uuidv4, validateTestClaim, withIsolatedTestDatabaseDiscovery, writeTestClaim };
|
|
3490
4489
|
//# sourceMappingURL=index.js.map
|