@elench/testkit 0.1.39 → 0.1.41
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 +43 -13
- package/lib/cli/args.mjs +5 -3
- package/lib/cli/args.test.mjs +5 -5
- package/lib/cli/index.mjs +9 -15
- package/lib/config/index.mjs +72 -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 +63 -43
- package/lib/runner/execution-config.mjs +24 -64
- package/lib/runner/execution-config.test.mjs +30 -72
- package/lib/runner/formatting.mjs +0 -15
- package/lib/runner/formatting.test.mjs +0 -18
- package/lib/runner/lifecycle.mjs +7 -7
- package/lib/runner/orchestrator.mjs +9 -10
- package/lib/runner/planning.mjs +42 -136
- package/lib/runner/planning.test.mjs +70 -174
- package/lib/runner/playwright-config.mjs +8 -2
- package/lib/runner/playwright-config.test.mjs +20 -5
- package/lib/runner/playwright-runner.mjs +32 -54
- package/lib/runner/readiness.mjs +2 -2
- package/lib/runner/reporting.mjs +3 -3
- package/lib/runner/reporting.test.mjs +4 -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 +181 -0
- package/lib/runner/runtime-manager.test.mjs +181 -0
- package/lib/runner/services.mjs +4 -4
- 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 +29 -32
- package/lib/runtime/index.d.ts +11 -0
- package/lib/runtime/index.mjs +34 -0
- package/lib/setup/index.d.ts +15 -10
- package/lib/shared/file-timeout.mjs +107 -0
- package/lib/shared/file-timeout.test.mjs +64 -0
- 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,11 +21,11 @@ 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
|
-
#
|
|
28
|
-
npx @elench/testkit --
|
|
27
|
+
# One file-level wall clock budget for every suite file
|
|
28
|
+
npx @elench/testkit --file-timeout-seconds 60
|
|
29
29
|
|
|
30
30
|
# Run a deterministic shard
|
|
31
31
|
npx @elench/testkit --shard 1/3
|
|
@@ -66,8 +66,7 @@ import {
|
|
|
66
66
|
export default defineTestkitSetup({
|
|
67
67
|
execution: {
|
|
68
68
|
workers: 8,
|
|
69
|
-
|
|
70
|
-
stackCount: 1,
|
|
69
|
+
fileTimeoutSeconds: 60,
|
|
71
70
|
},
|
|
72
71
|
services: {
|
|
73
72
|
api: service({
|
|
@@ -82,6 +81,17 @@ export default defineTestkitSetup({
|
|
|
82
81
|
migrate: lifecycle("npm run db:migrate", {
|
|
83
82
|
testkitCmd: "npm run db:migrate:testkit",
|
|
84
83
|
}),
|
|
84
|
+
runtime: {
|
|
85
|
+
instances: 1,
|
|
86
|
+
},
|
|
87
|
+
requirements: {
|
|
88
|
+
files: [
|
|
89
|
+
{
|
|
90
|
+
path: "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
|
|
91
|
+
locks: ["global-worker-loop"],
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
85
95
|
}),
|
|
86
96
|
frontend: service({
|
|
87
97
|
...nextService({
|
|
@@ -93,6 +103,9 @@ export default defineTestkitSetup({
|
|
|
93
103
|
}),
|
|
94
104
|
dependsOn: ["api"],
|
|
95
105
|
envFiles: ["frontend/.env.testkit"],
|
|
106
|
+
runtime: {
|
|
107
|
+
instances: 1,
|
|
108
|
+
},
|
|
96
109
|
}),
|
|
97
110
|
billing: service({
|
|
98
111
|
skip: {
|
|
@@ -117,9 +130,12 @@ export default defineTestkitSetup({
|
|
|
117
130
|
`testkit.setup.ts` is optional for simple repos, but it is the primary escape hatch
|
|
118
131
|
for:
|
|
119
132
|
|
|
120
|
-
- worker and
|
|
133
|
+
- worker count and per-file runtime budget
|
|
134
|
+
- per-file wall clock timeout budget
|
|
121
135
|
- multi-service graphs
|
|
122
|
-
- local
|
|
136
|
+
- local runtime instance counts
|
|
137
|
+
- local DB binding configuration
|
|
138
|
+
- explicit per-file or per-suite locks
|
|
123
139
|
- migrate / seed commands
|
|
124
140
|
- test-local migrate / seed overrides
|
|
125
141
|
- named HTTP suite profiles
|
|
@@ -168,7 +184,20 @@ export default suite;
|
|
|
168
184
|
Low-level runtime primitives remain available:
|
|
169
185
|
|
|
170
186
|
```ts
|
|
171
|
-
import { check, group, http } from "@elench/testkit/runtime";
|
|
187
|
+
import { check, group, http, waitFor } from "@elench/testkit/runtime";
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`waitFor()` consumes the file budget configured by `execution.fileTimeoutSeconds`.
|
|
191
|
+
Consumers should not set local timeout values in test files.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { waitFor } from "@elench/testkit/runtime";
|
|
195
|
+
|
|
196
|
+
const response = waitFor(
|
|
197
|
+
() => req("GET", "/api/v1/jobs/123", setupData),
|
|
198
|
+
(res) => JSON.parse(res.body).data?.status === "completed",
|
|
199
|
+
{ description: "job 123 to complete" }
|
|
200
|
+
);
|
|
172
201
|
```
|
|
173
202
|
|
|
174
203
|
## Discovery
|
|
@@ -180,7 +209,7 @@ Example layouts:
|
|
|
180
209
|
- `src/api/routes/__testkit__/auth/me.int.testkit.ts`
|
|
181
210
|
- `src/db/__testkit__/sessions/count-type.dal.testkit.ts`
|
|
182
211
|
- `frontend/__testkit__/navigation/navigation.pw.testkit.ts`
|
|
183
|
-
- `
|
|
212
|
+
- `src/internal/handler/__testkit__/repos/crud.int.testkit.ts`
|
|
184
213
|
|
|
185
214
|
`testkit` uses these suffixes automatically:
|
|
186
215
|
|
|
@@ -206,7 +235,8 @@ Suite names are inferred from the colocated path:
|
|
|
206
235
|
services that define `database: localDatabase(...)`.
|
|
207
236
|
|
|
208
237
|
- template databases are cached
|
|
209
|
-
-
|
|
238
|
+
- runtime databases are cloned from templates when binding is `per-runtime`
|
|
239
|
+
- shared databases are reused when binding is `shared`
|
|
210
240
|
- template fingerprints are derived automatically from env files, migrate/seed
|
|
211
241
|
config, and repo contents
|
|
212
242
|
|
package/lib/cli/args.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { normalizeTypeValues, parseSuiteSelectors } from "../runner/suite-selection.mjs";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
parseStackModeOption,
|
|
4
|
+
parseFileTimeoutOption,
|
|
6
5
|
parseWorkersOption,
|
|
7
6
|
} from "../runner/execution-config.mjs";
|
|
8
7
|
|
|
@@ -45,7 +44,10 @@ export function parseSuiteOption(values) {
|
|
|
45
44
|
return parseSuiteSelectors(input);
|
|
46
45
|
}
|
|
47
46
|
|
|
48
|
-
export {
|
|
47
|
+
export {
|
|
48
|
+
parseFileTimeoutOption,
|
|
49
|
+
parseWorkersOption,
|
|
50
|
+
};
|
|
49
51
|
|
|
50
52
|
export function parseShardOption(value) {
|
|
51
53
|
if (!value) return null;
|
package/lib/cli/args.test.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
parseFileTimeoutOption,
|
|
3
4
|
parseShardOption,
|
|
4
|
-
parseStackCountOption,
|
|
5
|
-
parseStackModeOption,
|
|
6
5
|
parseSuiteOption,
|
|
7
6
|
parseTypeOption,
|
|
8
7
|
parseWorkersOption,
|
|
@@ -59,10 +58,11 @@ describe("cli-args", () => {
|
|
|
59
58
|
|
|
60
59
|
it("parses and validates execution options", () => {
|
|
61
60
|
expect(parseWorkersOption("3")).toBe(3);
|
|
62
|
-
expect(
|
|
63
|
-
expect(parseStackModeOption("pooled")).toBe("pooled");
|
|
61
|
+
expect(parseFileTimeoutOption("45")).toBe(45);
|
|
64
62
|
expect(() => parseWorkersOption("0")).toThrow("Invalid --workers value");
|
|
65
|
-
expect(() =>
|
|
63
|
+
expect(() => parseFileTimeoutOption("0")).toThrow(
|
|
64
|
+
"Invalid --file-timeout-seconds value"
|
|
65
|
+
);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
it("parses and validates shards", () => {
|
package/lib/cli/index.mjs
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { cac } from "cac";
|
|
2
2
|
import { loadConfigs } from "../config/index.mjs";
|
|
3
3
|
import {
|
|
4
|
+
parseFileTimeoutOption,
|
|
4
5
|
parseShardOption,
|
|
5
|
-
parseStackCountOption,
|
|
6
|
-
parseStackModeOption,
|
|
7
6
|
parseSuiteOption,
|
|
8
7
|
parseTypeOption,
|
|
9
8
|
parseWorkersOption,
|
|
@@ -24,13 +23,8 @@ export function run() {
|
|
|
24
23
|
.option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
|
|
25
24
|
.option("-f, --file <path>", "Run specific file(s)", { default: [] })
|
|
26
25
|
.option("--dir <path>", "Explicit product directory")
|
|
27
|
-
.option("--workers <n>", "Number of test executors for the whole run"
|
|
28
|
-
|
|
29
|
-
})
|
|
30
|
-
.option("--stack-mode <mode>", "Stack topology: shared, pooled, or isolated", {
|
|
31
|
-
default: "isolated",
|
|
32
|
-
})
|
|
33
|
-
.option("--stack-count <n>", "Number of prepared stacks when stack-mode=pooled")
|
|
26
|
+
.option("--workers <n>", "Number of test executors for the whole run")
|
|
27
|
+
.option("--file-timeout-seconds <n>", "Per-file wall-clock timeout in seconds")
|
|
34
28
|
.option("--shard <i/n>", "Run only shard i of n at suite granularity")
|
|
35
29
|
.option("--write-status", "Write a deterministic testkit.status.json snapshot")
|
|
36
30
|
.option("--allow-partial-status", "Allow --write-status for filtered runs")
|
|
@@ -68,10 +62,11 @@ export function run() {
|
|
|
68
62
|
return;
|
|
69
63
|
}
|
|
70
64
|
|
|
71
|
-
const workers = parseWorkersOption(options.workers);
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
65
|
+
const workers = options.workers == null ? null : parseWorkersOption(options.workers);
|
|
66
|
+
const fileTimeoutSeconds =
|
|
67
|
+
options.fileTimeoutSeconds == null
|
|
68
|
+
? null
|
|
69
|
+
: parseFileTimeoutOption(options.fileTimeoutSeconds);
|
|
75
70
|
const shard = parseShardOption(options.shard);
|
|
76
71
|
const typeValues = parseTypeOption(options.type, positionalType);
|
|
77
72
|
const suiteSelectors = parseSuiteOption(options.suite);
|
|
@@ -87,8 +82,7 @@ export function run() {
|
|
|
87
82
|
typeValues,
|
|
88
83
|
fileNames,
|
|
89
84
|
workers,
|
|
90
|
-
|
|
91
|
-
stackCount,
|
|
85
|
+
fileTimeoutSeconds,
|
|
92
86
|
shard,
|
|
93
87
|
serviceFilter: options.service || null,
|
|
94
88
|
},
|
package/lib/config/index.mjs
CHANGED
|
@@ -8,7 +8,12 @@ import {
|
|
|
8
8
|
parseSuiteSelectors,
|
|
9
9
|
suiteSelectionType,
|
|
10
10
|
} from "../runner/suite-selection.mjs";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_FILE_TIMEOUT_SECONDS,
|
|
13
|
+
normalizeDatabaseBinding,
|
|
14
|
+
normalizeExecutionConfig,
|
|
15
|
+
normalizeRuntimeInstances,
|
|
16
|
+
} from "../runner/execution-config.mjs";
|
|
12
17
|
|
|
13
18
|
const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
|
|
14
19
|
const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
|
|
@@ -112,12 +117,13 @@ function normalizeServiceConfig({
|
|
|
112
117
|
const database = normalizeDatabaseConfig(explicitService, name);
|
|
113
118
|
const migrate = normalizeLifecycle(explicitService.migrate);
|
|
114
119
|
const seed = normalizeLifecycle(explicitService.seed);
|
|
120
|
+
const runtime = normalizeRuntimeConfig(explicitService.runtime, name);
|
|
115
121
|
const skip = normalizeSkipConfig(explicitService.skip, {
|
|
116
122
|
name,
|
|
117
123
|
productDir,
|
|
118
124
|
suites,
|
|
119
125
|
});
|
|
120
|
-
const
|
|
126
|
+
const requirements = normalizeServiceRequirements(explicitService.requirements, {
|
|
121
127
|
name,
|
|
122
128
|
suites,
|
|
123
129
|
});
|
|
@@ -133,6 +139,7 @@ function normalizeServiceConfig({
|
|
|
133
139
|
local,
|
|
134
140
|
database,
|
|
135
141
|
databaseFrom: explicitService.databaseFrom,
|
|
142
|
+
runtime,
|
|
136
143
|
migrate,
|
|
137
144
|
seed,
|
|
138
145
|
dependsOn: explicitService.dependsOn || [],
|
|
@@ -153,11 +160,12 @@ function normalizeServiceConfig({
|
|
|
153
160
|
database,
|
|
154
161
|
databaseFrom: explicitService.databaseFrom,
|
|
155
162
|
envFiles,
|
|
156
|
-
|
|
163
|
+
requirements,
|
|
157
164
|
serviceEnv,
|
|
158
165
|
migrate,
|
|
159
166
|
seed,
|
|
160
167
|
skip,
|
|
168
|
+
runtime,
|
|
161
169
|
local,
|
|
162
170
|
},
|
|
163
171
|
};
|
|
@@ -236,6 +244,10 @@ function normalizeDatabaseConfig(explicitService, serviceName) {
|
|
|
236
244
|
|
|
237
245
|
return {
|
|
238
246
|
...database,
|
|
247
|
+
binding: normalizeDatabaseBinding(
|
|
248
|
+
database.binding || "per-runtime",
|
|
249
|
+
`Service "${serviceName}" database.binding`
|
|
250
|
+
),
|
|
239
251
|
provider: "local",
|
|
240
252
|
selectedBackend: "local",
|
|
241
253
|
reset: database.reset !== false,
|
|
@@ -249,6 +261,21 @@ function normalizeDatabaseConfig(explicitService, serviceName) {
|
|
|
249
261
|
};
|
|
250
262
|
}
|
|
251
263
|
|
|
264
|
+
function normalizeRuntimeConfig(value, serviceName) {
|
|
265
|
+
if (!value) {
|
|
266
|
+
return {
|
|
267
|
+
instances: 1,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
instances: normalizeRuntimeInstances(
|
|
273
|
+
value.instances ?? 1,
|
|
274
|
+
`Service "${serviceName}" runtime.instances`
|
|
275
|
+
),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
252
279
|
function normalizeLifecycle(value) {
|
|
253
280
|
if (!value) return undefined;
|
|
254
281
|
if (!value.cmd && !value.testkitCmd) {
|
|
@@ -351,8 +378,8 @@ function normalizeSkipConfig(value, { name, productDir, suites }) {
|
|
|
351
378
|
};
|
|
352
379
|
}
|
|
353
380
|
|
|
354
|
-
function
|
|
355
|
-
if (!value) return { suites: [], files: [],
|
|
381
|
+
function normalizeServiceRequirements(value, { name, suites }) {
|
|
382
|
+
if (!value) return { suites: [], files: [], fileLocksByPath: new Map() };
|
|
356
383
|
|
|
357
384
|
const discoveredSuites = [];
|
|
358
385
|
const discoveredFiles = new Set();
|
|
@@ -372,25 +399,25 @@ function normalizeServiceExecution(value, { name, suites }) {
|
|
|
372
399
|
const seenSelectors = new Set();
|
|
373
400
|
for (const rule of value.suites || []) {
|
|
374
401
|
if (!rule || typeof rule !== "object") {
|
|
375
|
-
throw new Error(`Service "${name}"
|
|
402
|
+
throw new Error(`Service "${name}" requirements.suites entries must be objects`);
|
|
376
403
|
}
|
|
377
404
|
const selector = String(rule.selector || "").trim();
|
|
378
405
|
if (!selector) {
|
|
379
|
-
throw new Error(`Service "${name}"
|
|
406
|
+
throw new Error(`Service "${name}" requirements.suites entries require a selector`);
|
|
380
407
|
}
|
|
381
408
|
const parsed = parseSuiteSelectors([selector]);
|
|
382
409
|
if (parsed.length !== 1) {
|
|
383
|
-
throw new Error(`Service "${name}"
|
|
410
|
+
throw new Error(`Service "${name}" requirements.suites["${selector}"] is invalid`);
|
|
384
411
|
}
|
|
385
412
|
const parsedSelector = parsed[0];
|
|
386
413
|
if (parsedSelector.kind !== "typed") {
|
|
387
414
|
throw new Error(
|
|
388
|
-
`Service "${name}"
|
|
415
|
+
`Service "${name}" requirements.suites["${selector}"] must use a typed selector like int:health`
|
|
389
416
|
);
|
|
390
417
|
}
|
|
391
418
|
if (seenSelectors.has(parsedSelector.raw)) {
|
|
392
419
|
throw new Error(
|
|
393
|
-
`Service "${name}" defines duplicate
|
|
420
|
+
`Service "${name}" defines duplicate requirements.suites selector "${parsedSelector.raw}"`
|
|
394
421
|
);
|
|
395
422
|
}
|
|
396
423
|
const matched = discoveredSuites.some((suite) =>
|
|
@@ -398,15 +425,15 @@ function normalizeServiceExecution(value, { name, suites }) {
|
|
|
398
425
|
);
|
|
399
426
|
if (!matched) {
|
|
400
427
|
throw new Error(
|
|
401
|
-
`Service "${name}"
|
|
428
|
+
`Service "${name}" requirements.suites selector "${parsedSelector.raw}" did not match any discovered suite`
|
|
402
429
|
);
|
|
403
430
|
}
|
|
404
431
|
seenSelectors.add(parsedSelector.raw);
|
|
405
432
|
suiteRules.push({
|
|
406
433
|
selector: parsedSelector,
|
|
407
|
-
|
|
408
|
-
rule.
|
|
409
|
-
`Service "${name}"
|
|
434
|
+
locks: normalizeRequirementLocks(
|
|
435
|
+
rule.locks,
|
|
436
|
+
`Service "${name}" requirements.suites["${parsedSelector.raw}"].locks`
|
|
410
437
|
),
|
|
411
438
|
});
|
|
412
439
|
}
|
|
@@ -415,28 +442,28 @@ function normalizeServiceExecution(value, { name, suites }) {
|
|
|
415
442
|
const seenFiles = new Set();
|
|
416
443
|
for (const [index, rule] of (value.files || []).entries()) {
|
|
417
444
|
if (!rule || typeof rule !== "object") {
|
|
418
|
-
throw new Error(`Service "${name}"
|
|
445
|
+
throw new Error(`Service "${name}" requirements.files entries must be objects`);
|
|
419
446
|
}
|
|
420
447
|
|
|
421
448
|
const filePath = String(rule.path || "").trim();
|
|
422
449
|
if (!filePath) {
|
|
423
|
-
throw new Error(`Service "${name}"
|
|
450
|
+
throw new Error(`Service "${name}" requirements.files[${index}] requires a path`);
|
|
424
451
|
}
|
|
425
452
|
if (!discoveredFiles.has(filePath)) {
|
|
426
453
|
throw new Error(
|
|
427
|
-
`Service "${name}"
|
|
454
|
+
`Service "${name}" requirements.files["${filePath}"] did not match any discovered test file`
|
|
428
455
|
);
|
|
429
456
|
}
|
|
430
457
|
if (seenFiles.has(filePath)) {
|
|
431
|
-
throw new Error(`Service "${name}" defines duplicate
|
|
458
|
+
throw new Error(`Service "${name}" defines duplicate requirements.files path "${filePath}"`);
|
|
432
459
|
}
|
|
433
460
|
|
|
434
461
|
seenFiles.add(filePath);
|
|
435
462
|
fileRules.push({
|
|
436
463
|
path: filePath,
|
|
437
|
-
|
|
438
|
-
rule.
|
|
439
|
-
`Service "${name}"
|
|
464
|
+
locks: normalizeRequirementLocks(
|
|
465
|
+
rule.locks,
|
|
466
|
+
`Service "${name}" requirements.files["${filePath}"].locks`
|
|
440
467
|
),
|
|
441
468
|
});
|
|
442
469
|
}
|
|
@@ -444,10 +471,28 @@ function normalizeServiceExecution(value, { name, suites }) {
|
|
|
444
471
|
return {
|
|
445
472
|
suites: suiteRules,
|
|
446
473
|
files: fileRules,
|
|
447
|
-
|
|
474
|
+
fileLocksByPath: new Map(fileRules.map((rule) => [rule.path, rule.locks])),
|
|
448
475
|
};
|
|
449
476
|
}
|
|
450
477
|
|
|
478
|
+
function normalizeRequirementLocks(value, label) {
|
|
479
|
+
const input = Array.isArray(value) ? value : value == null ? [] : [value];
|
|
480
|
+
const seen = new Set();
|
|
481
|
+
const locks = [];
|
|
482
|
+
|
|
483
|
+
for (const rawLock of input) {
|
|
484
|
+
const lockName = String(rawLock || "").trim();
|
|
485
|
+
if (!lockName) {
|
|
486
|
+
throw new Error(`${label} entries must be non-empty strings`);
|
|
487
|
+
}
|
|
488
|
+
if (seen.has(lockName)) continue;
|
|
489
|
+
seen.add(lockName);
|
|
490
|
+
locks.push(lockName);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return locks.sort();
|
|
494
|
+
}
|
|
495
|
+
|
|
451
496
|
function inferEnvFiles(productDir, explicitService, local) {
|
|
452
497
|
if (explicitService.envFile || explicitService.envFiles) {
|
|
453
498
|
const files = [];
|
|
@@ -508,6 +553,7 @@ function validateServiceConfig({
|
|
|
508
553
|
local,
|
|
509
554
|
database,
|
|
510
555
|
databaseFrom,
|
|
556
|
+
runtime,
|
|
511
557
|
migrate,
|
|
512
558
|
seed,
|
|
513
559
|
dependsOn,
|
|
@@ -529,6 +575,9 @@ function validateServiceConfig({
|
|
|
529
575
|
if (database && databaseFrom) {
|
|
530
576
|
throw new Error(`Service "${name}" cannot define both database and databaseFrom`);
|
|
531
577
|
}
|
|
578
|
+
if (runtime.instances < 1) {
|
|
579
|
+
throw new Error(`Service "${name}" runtime.instances must be a positive integer`);
|
|
580
|
+
}
|
|
532
581
|
|
|
533
582
|
for (const depName of dependsOn || []) {
|
|
534
583
|
if (depName === name) {
|
|
@@ -587,8 +636,7 @@ function normalizeRepoExecution(execution) {
|
|
|
587
636
|
if (!execution) {
|
|
588
637
|
return normalizeExecutionConfig({
|
|
589
638
|
workers: 1,
|
|
590
|
-
|
|
591
|
-
stackCount: 1,
|
|
639
|
+
fileTimeoutSeconds: DEFAULT_FILE_TIMEOUT_SECONDS,
|
|
592
640
|
});
|
|
593
641
|
}
|
|
594
642
|
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(
|