@elench/testkit 0.1.40 → 0.1.42
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/README.md +27 -13
- package/bin/testkit.mjs +6 -1
- package/lib/cli/args.mjs +0 -4
- package/lib/cli/args.test.mjs +0 -5
- package/lib/cli/index.mjs +4 -11
- package/lib/config/index.mjs +78 -24
- package/lib/database/index.mjs +19 -7
- package/lib/database/naming.mjs +2 -2
- package/lib/database/naming.test.mjs +2 -2
- package/lib/runner/default-runtime-runner.mjs +52 -55
- package/lib/runner/execution-config.mjs +31 -70
- package/lib/runner/execution-config.test.mjs +30 -74
- package/lib/runner/formatting.mjs +0 -15
- package/lib/runner/formatting.test.mjs +0 -18
- package/lib/runner/lifecycle.mjs +106 -8
- package/lib/runner/orchestrator.mjs +16 -10
- package/lib/runner/planning.mjs +66 -138
- package/lib/runner/planning.test.mjs +101 -167
- package/lib/runner/playwright-config.mjs +13 -2
- package/lib/runner/playwright-config.test.mjs +26 -6
- package/lib/runner/playwright-runner.mjs +50 -56
- package/lib/runner/readiness.mjs +2 -2
- package/lib/runner/reporting.mjs +4 -3
- package/lib/runner/reporting.test.mjs +2 -5
- package/lib/runner/results.mjs +1 -1
- package/lib/runner/results.test.mjs +1 -1
- package/lib/runner/runtime-contexts.mjs +20 -24
- package/lib/runner/runtime-manager.mjs +228 -0
- package/lib/runner/runtime-manager.test.mjs +206 -0
- package/lib/runner/services.mjs +8 -6
- package/lib/runner/state.mjs +1 -2
- package/lib/runner/state.test.mjs +2 -4
- package/lib/runner/template.mjs +90 -60
- package/lib/runner/template.test.mjs +59 -27
- package/lib/runner/worker-loop.mjs +35 -32
- package/lib/setup/index.d.ts +15 -10
- package/package.json +1 -1
- package/lib/runner/stack-manager.mjs +0 -146
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @elench/testkit
|
|
2
2
|
|
|
3
3
|
`@elench/testkit` discovers `*.testkit.ts` files, infers suite ownership from the
|
|
4
|
-
filesystem, starts local services, provisions
|
|
5
|
-
and runs HTTP, DAL, and Playwright suites.
|
|
4
|
+
filesystem, starts local services, provisions Docker-managed local Postgres
|
|
5
|
+
databases, and runs HTTP, DAL, and Playwright suites.
|
|
6
6
|
|
|
7
7
|
The package is now driven by `testkit.setup.ts`, not `testkit.config.json`.
|
|
8
8
|
|
|
@@ -21,15 +21,12 @@ npx @elench/testkit --type e2e
|
|
|
21
21
|
npx @elench/testkit --type int,e2e,dal
|
|
22
22
|
npx @elench/testkit --type pw
|
|
23
23
|
|
|
24
|
-
#
|
|
25
|
-
npx @elench/testkit --workers 8
|
|
24
|
+
# Parallel file execution
|
|
25
|
+
npx @elench/testkit --workers 8
|
|
26
26
|
|
|
27
27
|
# One file-level wall clock budget for every suite file
|
|
28
28
|
npx @elench/testkit --file-timeout-seconds 60
|
|
29
29
|
|
|
30
|
-
# Two reusable local stacks for browser-heavy suites
|
|
31
|
-
npx @elench/testkit --workers 6 --stack-mode pooled --stack-count 2
|
|
32
|
-
|
|
33
30
|
# Run a deterministic shard
|
|
34
31
|
npx @elench/testkit --shard 1/3
|
|
35
32
|
|
|
@@ -70,8 +67,6 @@ export default defineTestkitSetup({
|
|
|
70
67
|
execution: {
|
|
71
68
|
workers: 8,
|
|
72
69
|
fileTimeoutSeconds: 60,
|
|
73
|
-
stackMode: "shared",
|
|
74
|
-
stackCount: 1,
|
|
75
70
|
},
|
|
76
71
|
services: {
|
|
77
72
|
api: service({
|
|
@@ -86,6 +81,18 @@ export default defineTestkitSetup({
|
|
|
86
81
|
migrate: lifecycle("npm run db:migrate", {
|
|
87
82
|
testkitCmd: "npm run db:migrate:testkit",
|
|
88
83
|
}),
|
|
84
|
+
runtime: {
|
|
85
|
+
instances: 1,
|
|
86
|
+
maxConcurrentTasks: 4,
|
|
87
|
+
},
|
|
88
|
+
requirements: {
|
|
89
|
+
files: [
|
|
90
|
+
{
|
|
91
|
+
path: "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
|
|
92
|
+
locks: ["global-worker-loop"],
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
89
96
|
}),
|
|
90
97
|
frontend: service({
|
|
91
98
|
...nextService({
|
|
@@ -97,6 +104,9 @@ export default defineTestkitSetup({
|
|
|
97
104
|
}),
|
|
98
105
|
dependsOn: ["api"],
|
|
99
106
|
envFiles: ["frontend/.env.testkit"],
|
|
107
|
+
runtime: {
|
|
108
|
+
instances: 1,
|
|
109
|
+
},
|
|
100
110
|
}),
|
|
101
111
|
billing: service({
|
|
102
112
|
skip: {
|
|
@@ -121,10 +131,13 @@ export default defineTestkitSetup({
|
|
|
121
131
|
`testkit.setup.ts` is optional for simple repos, but it is the primary escape hatch
|
|
122
132
|
for:
|
|
123
133
|
|
|
124
|
-
- worker and
|
|
134
|
+
- worker count and per-file runtime budget
|
|
125
135
|
- per-file wall clock timeout budget
|
|
126
136
|
- multi-service graphs
|
|
127
|
-
- local
|
|
137
|
+
- local runtime instance counts
|
|
138
|
+
- per-runtime concurrent task caps
|
|
139
|
+
- local DB binding configuration
|
|
140
|
+
- explicit per-file or per-suite locks
|
|
128
141
|
- migrate / seed commands
|
|
129
142
|
- test-local migrate / seed overrides
|
|
130
143
|
- named HTTP suite profiles
|
|
@@ -198,7 +211,7 @@ Example layouts:
|
|
|
198
211
|
- `src/api/routes/__testkit__/auth/me.int.testkit.ts`
|
|
199
212
|
- `src/db/__testkit__/sessions/count-type.dal.testkit.ts`
|
|
200
213
|
- `frontend/__testkit__/navigation/navigation.pw.testkit.ts`
|
|
201
|
-
- `
|
|
214
|
+
- `src/internal/handler/__testkit__/repos/crud.int.testkit.ts`
|
|
202
215
|
|
|
203
216
|
`testkit` uses these suffixes automatically:
|
|
204
217
|
|
|
@@ -224,7 +237,8 @@ Suite names are inferred from the colocated path:
|
|
|
224
237
|
services that define `database: localDatabase(...)`.
|
|
225
238
|
|
|
226
239
|
- template databases are cached
|
|
227
|
-
-
|
|
240
|
+
- runtime databases are cloned from templates when binding is `per-runtime`
|
|
241
|
+
- shared databases are reused when binding is `shared`
|
|
228
242
|
- template fingerprints are derived automatically from env files, migrate/seed
|
|
229
243
|
config, and repo contents
|
|
230
244
|
|
package/bin/testkit.mjs
CHANGED
package/lib/cli/args.mjs
CHANGED
|
@@ -2,8 +2,6 @@ import path from "path";
|
|
|
2
2
|
import { normalizeTypeValues, parseSuiteSelectors } from "../runner/suite-selection.mjs";
|
|
3
3
|
import {
|
|
4
4
|
parseFileTimeoutOption,
|
|
5
|
-
parseStackCountOption,
|
|
6
|
-
parseStackModeOption,
|
|
7
5
|
parseWorkersOption,
|
|
8
6
|
} from "../runner/execution-config.mjs";
|
|
9
7
|
|
|
@@ -49,8 +47,6 @@ export function parseSuiteOption(values) {
|
|
|
49
47
|
export {
|
|
50
48
|
parseFileTimeoutOption,
|
|
51
49
|
parseWorkersOption,
|
|
52
|
-
parseStackModeOption,
|
|
53
|
-
parseStackCountOption,
|
|
54
50
|
};
|
|
55
51
|
|
|
56
52
|
export function parseShardOption(value) {
|
package/lib/cli/args.test.mjs
CHANGED
|
@@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
parseFileTimeoutOption,
|
|
4
4
|
parseShardOption,
|
|
5
|
-
parseStackCountOption,
|
|
6
|
-
parseStackModeOption,
|
|
7
5
|
parseSuiteOption,
|
|
8
6
|
parseTypeOption,
|
|
9
7
|
parseWorkersOption,
|
|
@@ -61,13 +59,10 @@ describe("cli-args", () => {
|
|
|
61
59
|
it("parses and validates execution options", () => {
|
|
62
60
|
expect(parseWorkersOption("3")).toBe(3);
|
|
63
61
|
expect(parseFileTimeoutOption("45")).toBe(45);
|
|
64
|
-
expect(parseStackCountOption("2")).toBe(2);
|
|
65
|
-
expect(parseStackModeOption("pooled")).toBe("pooled");
|
|
66
62
|
expect(() => parseWorkersOption("0")).toThrow("Invalid --workers value");
|
|
67
63
|
expect(() => parseFileTimeoutOption("0")).toThrow(
|
|
68
64
|
"Invalid --file-timeout-seconds value"
|
|
69
65
|
);
|
|
70
|
-
expect(() => parseStackModeOption("legacy")).toThrow("Invalid --stack-mode value");
|
|
71
66
|
});
|
|
72
67
|
|
|
73
68
|
it("parses and validates shards", () => {
|
package/lib/cli/index.mjs
CHANGED
|
@@ -3,8 +3,6 @@ import { loadConfigs } from "../config/index.mjs";
|
|
|
3
3
|
import {
|
|
4
4
|
parseFileTimeoutOption,
|
|
5
5
|
parseShardOption,
|
|
6
|
-
parseStackCountOption,
|
|
7
|
-
parseStackModeOption,
|
|
8
6
|
parseSuiteOption,
|
|
9
7
|
parseTypeOption,
|
|
10
8
|
parseWorkersOption,
|
|
@@ -13,7 +11,7 @@ import {
|
|
|
13
11
|
} from "./args.mjs";
|
|
14
12
|
import * as runner from "../runner/index.mjs";
|
|
15
13
|
|
|
16
|
-
export function run() {
|
|
14
|
+
export async function run(argv = process.argv) {
|
|
17
15
|
const cli = cac("testkit");
|
|
18
16
|
|
|
19
17
|
cli
|
|
@@ -27,8 +25,6 @@ export function run() {
|
|
|
27
25
|
.option("--dir <path>", "Explicit product directory")
|
|
28
26
|
.option("--workers <n>", "Number of test executors for the whole run")
|
|
29
27
|
.option("--file-timeout-seconds <n>", "Per-file wall-clock timeout in seconds")
|
|
30
|
-
.option("--stack-mode <mode>", "Stack topology: shared, pooled, or isolated")
|
|
31
|
-
.option("--stack-count <n>", "Number of prepared stacks when stack-mode=pooled")
|
|
32
28
|
.option("--shard <i/n>", "Run only shard i of n at suite granularity")
|
|
33
29
|
.option("--write-status", "Write a deterministic testkit.status.json snapshot")
|
|
34
30
|
.option("--allow-partial-status", "Allow --write-status for filtered runs")
|
|
@@ -71,9 +67,6 @@ export function run() {
|
|
|
71
67
|
options.fileTimeoutSeconds == null
|
|
72
68
|
? null
|
|
73
69
|
: parseFileTimeoutOption(options.fileTimeoutSeconds);
|
|
74
|
-
const stackMode = options.stackMode == null ? null : parseStackModeOption(options.stackMode);
|
|
75
|
-
const stackCount =
|
|
76
|
-
options.stackCount == null ? null : parseStackCountOption(options.stackCount);
|
|
77
70
|
const shard = parseShardOption(options.shard);
|
|
78
71
|
const typeValues = parseTypeOption(options.type, positionalType);
|
|
79
72
|
const suiteSelectors = parseSuiteOption(options.suite);
|
|
@@ -90,8 +83,6 @@ export function run() {
|
|
|
90
83
|
fileNames,
|
|
91
84
|
workers,
|
|
92
85
|
fileTimeoutSeconds,
|
|
93
|
-
stackMode,
|
|
94
|
-
stackCount,
|
|
95
86
|
shard,
|
|
96
87
|
serviceFilter: options.service || null,
|
|
97
88
|
},
|
|
@@ -100,5 +91,7 @@ export function run() {
|
|
|
100
91
|
});
|
|
101
92
|
|
|
102
93
|
cli.help();
|
|
103
|
-
cli.parse();
|
|
94
|
+
const parsed = cli.parse(argv, { run: false });
|
|
95
|
+
await cli.runMatchedCommand();
|
|
96
|
+
return parsed;
|
|
104
97
|
}
|
package/lib/config/index.mjs
CHANGED
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
} from "../runner/suite-selection.mjs";
|
|
11
11
|
import {
|
|
12
12
|
DEFAULT_FILE_TIMEOUT_SECONDS,
|
|
13
|
+
normalizeDatabaseBinding,
|
|
13
14
|
normalizeExecutionConfig,
|
|
14
|
-
|
|
15
|
+
normalizeRuntimeMaxConcurrentTasks,
|
|
16
|
+
normalizeRuntimeInstances,
|
|
15
17
|
} from "../runner/execution-config.mjs";
|
|
16
18
|
|
|
17
19
|
const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
|
|
@@ -116,12 +118,13 @@ function normalizeServiceConfig({
|
|
|
116
118
|
const database = normalizeDatabaseConfig(explicitService, name);
|
|
117
119
|
const migrate = normalizeLifecycle(explicitService.migrate);
|
|
118
120
|
const seed = normalizeLifecycle(explicitService.seed);
|
|
121
|
+
const runtime = normalizeRuntimeConfig(explicitService.runtime, name);
|
|
119
122
|
const skip = normalizeSkipConfig(explicitService.skip, {
|
|
120
123
|
name,
|
|
121
124
|
productDir,
|
|
122
125
|
suites,
|
|
123
126
|
});
|
|
124
|
-
const
|
|
127
|
+
const requirements = normalizeServiceRequirements(explicitService.requirements, {
|
|
125
128
|
name,
|
|
126
129
|
suites,
|
|
127
130
|
});
|
|
@@ -137,6 +140,7 @@ function normalizeServiceConfig({
|
|
|
137
140
|
local,
|
|
138
141
|
database,
|
|
139
142
|
databaseFrom: explicitService.databaseFrom,
|
|
143
|
+
runtime,
|
|
140
144
|
migrate,
|
|
141
145
|
seed,
|
|
142
146
|
dependsOn: explicitService.dependsOn || [],
|
|
@@ -157,11 +161,12 @@ function normalizeServiceConfig({
|
|
|
157
161
|
database,
|
|
158
162
|
databaseFrom: explicitService.databaseFrom,
|
|
159
163
|
envFiles,
|
|
160
|
-
|
|
164
|
+
requirements,
|
|
161
165
|
serviceEnv,
|
|
162
166
|
migrate,
|
|
163
167
|
seed,
|
|
164
168
|
skip,
|
|
169
|
+
runtime,
|
|
165
170
|
local,
|
|
166
171
|
},
|
|
167
172
|
};
|
|
@@ -240,6 +245,10 @@ function normalizeDatabaseConfig(explicitService, serviceName) {
|
|
|
240
245
|
|
|
241
246
|
return {
|
|
242
247
|
...database,
|
|
248
|
+
binding: normalizeDatabaseBinding(
|
|
249
|
+
database.binding || "per-runtime",
|
|
250
|
+
`Service "${serviceName}" database.binding`
|
|
251
|
+
),
|
|
243
252
|
provider: "local",
|
|
244
253
|
selectedBackend: "local",
|
|
245
254
|
reset: database.reset !== false,
|
|
@@ -253,6 +262,26 @@ function normalizeDatabaseConfig(explicitService, serviceName) {
|
|
|
253
262
|
};
|
|
254
263
|
}
|
|
255
264
|
|
|
265
|
+
function normalizeRuntimeConfig(value, serviceName) {
|
|
266
|
+
if (!value) {
|
|
267
|
+
return {
|
|
268
|
+
instances: 1,
|
|
269
|
+
maxConcurrentTasks: Number.POSITIVE_INFINITY,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
instances: normalizeRuntimeInstances(
|
|
275
|
+
value.instances ?? 1,
|
|
276
|
+
`Service "${serviceName}" runtime.instances`
|
|
277
|
+
),
|
|
278
|
+
maxConcurrentTasks: normalizeRuntimeMaxConcurrentTasks(
|
|
279
|
+
value.maxConcurrentTasks,
|
|
280
|
+
`Service "${serviceName}" runtime.maxConcurrentTasks`
|
|
281
|
+
),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
256
285
|
function normalizeLifecycle(value) {
|
|
257
286
|
if (!value) return undefined;
|
|
258
287
|
if (!value.cmd && !value.testkitCmd) {
|
|
@@ -355,8 +384,8 @@ function normalizeSkipConfig(value, { name, productDir, suites }) {
|
|
|
355
384
|
};
|
|
356
385
|
}
|
|
357
386
|
|
|
358
|
-
function
|
|
359
|
-
if (!value) return { suites: [], files: [],
|
|
387
|
+
function normalizeServiceRequirements(value, { name, suites }) {
|
|
388
|
+
if (!value) return { suites: [], files: [], fileLocksByPath: new Map() };
|
|
360
389
|
|
|
361
390
|
const discoveredSuites = [];
|
|
362
391
|
const discoveredFiles = new Set();
|
|
@@ -376,25 +405,25 @@ function normalizeServiceExecution(value, { name, suites }) {
|
|
|
376
405
|
const seenSelectors = new Set();
|
|
377
406
|
for (const rule of value.suites || []) {
|
|
378
407
|
if (!rule || typeof rule !== "object") {
|
|
379
|
-
throw new Error(`Service "${name}"
|
|
408
|
+
throw new Error(`Service "${name}" requirements.suites entries must be objects`);
|
|
380
409
|
}
|
|
381
410
|
const selector = String(rule.selector || "").trim();
|
|
382
411
|
if (!selector) {
|
|
383
|
-
throw new Error(`Service "${name}"
|
|
412
|
+
throw new Error(`Service "${name}" requirements.suites entries require a selector`);
|
|
384
413
|
}
|
|
385
414
|
const parsed = parseSuiteSelectors([selector]);
|
|
386
415
|
if (parsed.length !== 1) {
|
|
387
|
-
throw new Error(`Service "${name}"
|
|
416
|
+
throw new Error(`Service "${name}" requirements.suites["${selector}"] is invalid`);
|
|
388
417
|
}
|
|
389
418
|
const parsedSelector = parsed[0];
|
|
390
419
|
if (parsedSelector.kind !== "typed") {
|
|
391
420
|
throw new Error(
|
|
392
|
-
`Service "${name}"
|
|
421
|
+
`Service "${name}" requirements.suites["${selector}"] must use a typed selector like int:health`
|
|
393
422
|
);
|
|
394
423
|
}
|
|
395
424
|
if (seenSelectors.has(parsedSelector.raw)) {
|
|
396
425
|
throw new Error(
|
|
397
|
-
`Service "${name}" defines duplicate
|
|
426
|
+
`Service "${name}" defines duplicate requirements.suites selector "${parsedSelector.raw}"`
|
|
398
427
|
);
|
|
399
428
|
}
|
|
400
429
|
const matched = discoveredSuites.some((suite) =>
|
|
@@ -402,15 +431,15 @@ function normalizeServiceExecution(value, { name, suites }) {
|
|
|
402
431
|
);
|
|
403
432
|
if (!matched) {
|
|
404
433
|
throw new Error(
|
|
405
|
-
`Service "${name}"
|
|
434
|
+
`Service "${name}" requirements.suites selector "${parsedSelector.raw}" did not match any discovered suite`
|
|
406
435
|
);
|
|
407
436
|
}
|
|
408
437
|
seenSelectors.add(parsedSelector.raw);
|
|
409
438
|
suiteRules.push({
|
|
410
439
|
selector: parsedSelector,
|
|
411
|
-
|
|
412
|
-
rule.
|
|
413
|
-
`Service "${name}"
|
|
440
|
+
locks: normalizeRequirementLocks(
|
|
441
|
+
rule.locks,
|
|
442
|
+
`Service "${name}" requirements.suites["${parsedSelector.raw}"].locks`
|
|
414
443
|
),
|
|
415
444
|
});
|
|
416
445
|
}
|
|
@@ -419,28 +448,28 @@ function normalizeServiceExecution(value, { name, suites }) {
|
|
|
419
448
|
const seenFiles = new Set();
|
|
420
449
|
for (const [index, rule] of (value.files || []).entries()) {
|
|
421
450
|
if (!rule || typeof rule !== "object") {
|
|
422
|
-
throw new Error(`Service "${name}"
|
|
451
|
+
throw new Error(`Service "${name}" requirements.files entries must be objects`);
|
|
423
452
|
}
|
|
424
453
|
|
|
425
454
|
const filePath = String(rule.path || "").trim();
|
|
426
455
|
if (!filePath) {
|
|
427
|
-
throw new Error(`Service "${name}"
|
|
456
|
+
throw new Error(`Service "${name}" requirements.files[${index}] requires a path`);
|
|
428
457
|
}
|
|
429
458
|
if (!discoveredFiles.has(filePath)) {
|
|
430
459
|
throw new Error(
|
|
431
|
-
`Service "${name}"
|
|
460
|
+
`Service "${name}" requirements.files["${filePath}"] did not match any discovered test file`
|
|
432
461
|
);
|
|
433
462
|
}
|
|
434
463
|
if (seenFiles.has(filePath)) {
|
|
435
|
-
throw new Error(`Service "${name}" defines duplicate
|
|
464
|
+
throw new Error(`Service "${name}" defines duplicate requirements.files path "${filePath}"`);
|
|
436
465
|
}
|
|
437
466
|
|
|
438
467
|
seenFiles.add(filePath);
|
|
439
468
|
fileRules.push({
|
|
440
469
|
path: filePath,
|
|
441
|
-
|
|
442
|
-
rule.
|
|
443
|
-
`Service "${name}"
|
|
470
|
+
locks: normalizeRequirementLocks(
|
|
471
|
+
rule.locks,
|
|
472
|
+
`Service "${name}" requirements.files["${filePath}"].locks`
|
|
444
473
|
),
|
|
445
474
|
});
|
|
446
475
|
}
|
|
@@ -448,10 +477,28 @@ function normalizeServiceExecution(value, { name, suites }) {
|
|
|
448
477
|
return {
|
|
449
478
|
suites: suiteRules,
|
|
450
479
|
files: fileRules,
|
|
451
|
-
|
|
480
|
+
fileLocksByPath: new Map(fileRules.map((rule) => [rule.path, rule.locks])),
|
|
452
481
|
};
|
|
453
482
|
}
|
|
454
483
|
|
|
484
|
+
function normalizeRequirementLocks(value, label) {
|
|
485
|
+
const input = Array.isArray(value) ? value : value == null ? [] : [value];
|
|
486
|
+
const seen = new Set();
|
|
487
|
+
const locks = [];
|
|
488
|
+
|
|
489
|
+
for (const rawLock of input) {
|
|
490
|
+
const lockName = String(rawLock || "").trim();
|
|
491
|
+
if (!lockName) {
|
|
492
|
+
throw new Error(`${label} entries must be non-empty strings`);
|
|
493
|
+
}
|
|
494
|
+
if (seen.has(lockName)) continue;
|
|
495
|
+
seen.add(lockName);
|
|
496
|
+
locks.push(lockName);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return locks.sort();
|
|
500
|
+
}
|
|
501
|
+
|
|
455
502
|
function inferEnvFiles(productDir, explicitService, local) {
|
|
456
503
|
if (explicitService.envFile || explicitService.envFiles) {
|
|
457
504
|
const files = [];
|
|
@@ -512,6 +559,7 @@ function validateServiceConfig({
|
|
|
512
559
|
local,
|
|
513
560
|
database,
|
|
514
561
|
databaseFrom,
|
|
562
|
+
runtime,
|
|
515
563
|
migrate,
|
|
516
564
|
seed,
|
|
517
565
|
dependsOn,
|
|
@@ -533,6 +581,14 @@ function validateServiceConfig({
|
|
|
533
581
|
if (database && databaseFrom) {
|
|
534
582
|
throw new Error(`Service "${name}" cannot define both database and databaseFrom`);
|
|
535
583
|
}
|
|
584
|
+
if (runtime.instances < 1) {
|
|
585
|
+
throw new Error(`Service "${name}" runtime.instances must be a positive integer`);
|
|
586
|
+
}
|
|
587
|
+
if (runtime.maxConcurrentTasks <= 0) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
`Service "${name}" runtime.maxConcurrentTasks must be a positive integer when provided`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
536
592
|
|
|
537
593
|
for (const depName of dependsOn || []) {
|
|
538
594
|
if (depName === name) {
|
|
@@ -592,8 +648,6 @@ function normalizeRepoExecution(execution) {
|
|
|
592
648
|
return normalizeExecutionConfig({
|
|
593
649
|
workers: 1,
|
|
594
650
|
fileTimeoutSeconds: DEFAULT_FILE_TIMEOUT_SECONDS,
|
|
595
|
-
stackMode: "isolated",
|
|
596
|
-
stackCount: 1,
|
|
597
651
|
});
|
|
598
652
|
}
|
|
599
653
|
return normalizeExecutionConfig(execution);
|
package/lib/database/index.mjs
CHANGED
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
buildContainerName as buildContainerNameModel,
|
|
11
11
|
buildDatabaseUrl as buildDatabaseUrlModel,
|
|
12
|
+
buildRuntimeDatabaseName as buildRuntimeDatabaseNameModel,
|
|
12
13
|
buildTemplateDatabaseName as buildTemplateDatabaseNameModel,
|
|
13
|
-
buildWorkerDatabaseName as buildWorkerDatabaseNameModel,
|
|
14
14
|
escapeIdentifier as escapeIdentifierModel,
|
|
15
15
|
escapeSqlLiteral as escapeSqlLiteralModel,
|
|
16
16
|
hashString as hashStringModel,
|
|
@@ -119,6 +119,7 @@ async function prepareLocalDatabase(config, hooks) {
|
|
|
119
119
|
const db = config.testkit.database;
|
|
120
120
|
const productDir = config.productDir;
|
|
121
121
|
const serviceName = config.name;
|
|
122
|
+
const bindingKey = resolveDatabaseBindingKey(config);
|
|
122
123
|
const lockDir = getLocalLocksDir(productDir);
|
|
123
124
|
const cacheDir = getLocalServiceCacheDir(productDir, serviceName);
|
|
124
125
|
fs.mkdirSync(lockDir, { recursive: true });
|
|
@@ -133,8 +134,8 @@ async function prepareLocalDatabase(config, hooks) {
|
|
|
133
134
|
await ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint, hooks);
|
|
134
135
|
});
|
|
135
136
|
|
|
136
|
-
await withLock(path.join(lockDir, `runtime-${serviceName}-${hashString(
|
|
137
|
-
await
|
|
137
|
+
await withLock(path.join(lockDir, `runtime-${serviceName}-${hashString(bindingKey, 10)}.lock`), async () => {
|
|
138
|
+
await ensureRuntimeClone(config, infra, cacheDir, templateFingerprint, bindingKey);
|
|
138
139
|
});
|
|
139
140
|
}
|
|
140
141
|
|
|
@@ -172,14 +173,14 @@ async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerpri
|
|
|
172
173
|
writeLocalCacheState(cacheDir, infra, desiredDbName, templateFingerprint);
|
|
173
174
|
}
|
|
174
175
|
|
|
175
|
-
async function
|
|
176
|
+
async function ensureRuntimeClone(config, infra, cacheDir, templateFingerprint, bindingKey) {
|
|
176
177
|
const serviceName = config.name;
|
|
177
178
|
const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
|
|
178
179
|
if (!templateDbName) {
|
|
179
180
|
throw new Error(`Missing template database for service "${serviceName}"`);
|
|
180
181
|
}
|
|
181
182
|
|
|
182
|
-
const desiredDbName =
|
|
183
|
+
const desiredDbName = buildRuntimeDatabaseName(serviceName, bindingKey, templateFingerprint);
|
|
183
184
|
const existingDbName = readStateValue(path.join(config.stateDir, "local_database_name"));
|
|
184
185
|
const existingFingerprint = readStateValue(path.join(config.stateDir, "local_template_fingerprint"));
|
|
185
186
|
const needsReset =
|
|
@@ -206,6 +207,17 @@ async function ensureWorkerClone(config, infra, cacheDir, templateFingerprint) {
|
|
|
206
207
|
fs.writeFileSync(path.join(config.stateDir, "local_container_name"), infra.containerName);
|
|
207
208
|
}
|
|
208
209
|
|
|
210
|
+
function resolveDatabaseBindingKey(config) {
|
|
211
|
+
const binding = config.testkit.database?.binding || "per-runtime";
|
|
212
|
+
if (binding === "shared") {
|
|
213
|
+
return `${config.name}:shared`;
|
|
214
|
+
}
|
|
215
|
+
if (config.runtimeId) {
|
|
216
|
+
return `${config.name}:${config.runtimeId}`;
|
|
217
|
+
}
|
|
218
|
+
return `${config.name}:${config.stateDir}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
209
221
|
async function destroyLocalRuntimeDatabase(productDir, stateDir) {
|
|
210
222
|
const dbName = readStateValue(path.join(stateDir, "local_database_name"));
|
|
211
223
|
if (!dbName) return;
|
|
@@ -420,8 +432,8 @@ function buildTemplateDatabaseName(serviceName, fingerprint) {
|
|
|
420
432
|
return buildTemplateDatabaseNameModel(serviceName, fingerprint);
|
|
421
433
|
}
|
|
422
434
|
|
|
423
|
-
function
|
|
424
|
-
return
|
|
435
|
+
function buildRuntimeDatabaseName(serviceName, bindingKey, fingerprint) {
|
|
436
|
+
return buildRuntimeDatabaseNameModel(serviceName, bindingKey, fingerprint);
|
|
425
437
|
}
|
|
426
438
|
|
|
427
439
|
function writeLocalInfraState(infraDir, infra) {
|
package/lib/database/naming.mjs
CHANGED
|
@@ -19,9 +19,9 @@ export function buildTemplateDatabaseName(serviceName, fingerprint) {
|
|
|
19
19
|
);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export function
|
|
22
|
+
export function buildRuntimeDatabaseName(serviceName, bindingKey, fingerprint) {
|
|
23
23
|
return limitIdentifier(
|
|
24
|
-
`tk_${slugSegment(serviceName)}_${hashString(
|
|
24
|
+
`tk_${slugSegment(serviceName)}_${hashString(bindingKey, 10)}_${fingerprint.slice(0, 12)}`,
|
|
25
25
|
63
|
|
26
26
|
);
|
|
27
27
|
}
|
|
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
buildContainerName,
|
|
4
4
|
buildDatabaseUrl,
|
|
5
|
+
buildRuntimeDatabaseName,
|
|
5
6
|
buildTemplateDatabaseName,
|
|
6
|
-
buildWorkerDatabaseName,
|
|
7
7
|
escapeIdentifier,
|
|
8
8
|
escapeSqlLiteral,
|
|
9
9
|
slugSegment,
|
|
@@ -15,7 +15,7 @@ describe("database-naming", () => {
|
|
|
15
15
|
expect(buildTemplateDatabaseName("api", "1234567890abcdef1234")).toBe(
|
|
16
16
|
"tk_tpl_api_1234567890abcdef"
|
|
17
17
|
);
|
|
18
|
-
expect(
|
|
18
|
+
expect(buildRuntimeDatabaseName("api", "/tmp/state", "abcdef1234567890")).toMatch(
|
|
19
19
|
/^tk_api_[a-f0-9]{10}_abcdef123456$/
|
|
20
20
|
);
|
|
21
21
|
expect(
|