@elench/testkit 0.1.40 → 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 +25 -13
- package/lib/cli/args.mjs +0 -4
- package/lib/cli/args.test.mjs +0 -5
- package/lib/cli/index.mjs +0 -9
- package/lib/config/index.mjs +67 -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 +31 -53
- package/lib/runner/execution-config.mjs +14 -70
- package/lib/runner/execution-config.test.mjs +22 -74
- 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 +2 -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 +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/setup/index.d.ts +14 -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,17 @@ 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
|
+
},
|
|
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
|
+
},
|
|
89
95
|
}),
|
|
90
96
|
frontend: service({
|
|
91
97
|
...nextService({
|
|
@@ -97,6 +103,9 @@ export default defineTestkitSetup({
|
|
|
97
103
|
}),
|
|
98
104
|
dependsOn: ["api"],
|
|
99
105
|
envFiles: ["frontend/.env.testkit"],
|
|
106
|
+
runtime: {
|
|
107
|
+
instances: 1,
|
|
108
|
+
},
|
|
100
109
|
}),
|
|
101
110
|
billing: service({
|
|
102
111
|
skip: {
|
|
@@ -121,10 +130,12 @@ export default defineTestkitSetup({
|
|
|
121
130
|
`testkit.setup.ts` is optional for simple repos, but it is the primary escape hatch
|
|
122
131
|
for:
|
|
123
132
|
|
|
124
|
-
- worker and
|
|
133
|
+
- worker count and per-file runtime budget
|
|
125
134
|
- per-file wall clock timeout budget
|
|
126
135
|
- multi-service graphs
|
|
127
|
-
- local
|
|
136
|
+
- local runtime instance counts
|
|
137
|
+
- local DB binding configuration
|
|
138
|
+
- explicit per-file or per-suite locks
|
|
128
139
|
- migrate / seed commands
|
|
129
140
|
- test-local migrate / seed overrides
|
|
130
141
|
- named HTTP suite profiles
|
|
@@ -198,7 +209,7 @@ Example layouts:
|
|
|
198
209
|
- `src/api/routes/__testkit__/auth/me.int.testkit.ts`
|
|
199
210
|
- `src/db/__testkit__/sessions/count-type.dal.testkit.ts`
|
|
200
211
|
- `frontend/__testkit__/navigation/navigation.pw.testkit.ts`
|
|
201
|
-
- `
|
|
212
|
+
- `src/internal/handler/__testkit__/repos/crud.int.testkit.ts`
|
|
202
213
|
|
|
203
214
|
`testkit` uses these suffixes automatically:
|
|
204
215
|
|
|
@@ -224,7 +235,8 @@ Suite names are inferred from the colocated path:
|
|
|
224
235
|
services that define `database: localDatabase(...)`.
|
|
225
236
|
|
|
226
237
|
- template databases are cached
|
|
227
|
-
-
|
|
238
|
+
- runtime databases are cloned from templates when binding is `per-runtime`
|
|
239
|
+
- shared databases are reused when binding is `shared`
|
|
228
240
|
- template fingerprints are derived automatically from env files, migrate/seed
|
|
229
241
|
config, and repo contents
|
|
230
242
|
|
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,
|
|
@@ -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
|
},
|
package/lib/config/index.mjs
CHANGED
|
@@ -10,8 +10,9 @@ import {
|
|
|
10
10
|
} from "../runner/suite-selection.mjs";
|
|
11
11
|
import {
|
|
12
12
|
DEFAULT_FILE_TIMEOUT_SECONDS,
|
|
13
|
+
normalizeDatabaseBinding,
|
|
13
14
|
normalizeExecutionConfig,
|
|
14
|
-
|
|
15
|
+
normalizeRuntimeInstances,
|
|
15
16
|
} from "../runner/execution-config.mjs";
|
|
16
17
|
|
|
17
18
|
const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
|
|
@@ -116,12 +117,13 @@ function normalizeServiceConfig({
|
|
|
116
117
|
const database = normalizeDatabaseConfig(explicitService, name);
|
|
117
118
|
const migrate = normalizeLifecycle(explicitService.migrate);
|
|
118
119
|
const seed = normalizeLifecycle(explicitService.seed);
|
|
120
|
+
const runtime = normalizeRuntimeConfig(explicitService.runtime, name);
|
|
119
121
|
const skip = normalizeSkipConfig(explicitService.skip, {
|
|
120
122
|
name,
|
|
121
123
|
productDir,
|
|
122
124
|
suites,
|
|
123
125
|
});
|
|
124
|
-
const
|
|
126
|
+
const requirements = normalizeServiceRequirements(explicitService.requirements, {
|
|
125
127
|
name,
|
|
126
128
|
suites,
|
|
127
129
|
});
|
|
@@ -137,6 +139,7 @@ function normalizeServiceConfig({
|
|
|
137
139
|
local,
|
|
138
140
|
database,
|
|
139
141
|
databaseFrom: explicitService.databaseFrom,
|
|
142
|
+
runtime,
|
|
140
143
|
migrate,
|
|
141
144
|
seed,
|
|
142
145
|
dependsOn: explicitService.dependsOn || [],
|
|
@@ -157,11 +160,12 @@ function normalizeServiceConfig({
|
|
|
157
160
|
database,
|
|
158
161
|
databaseFrom: explicitService.databaseFrom,
|
|
159
162
|
envFiles,
|
|
160
|
-
|
|
163
|
+
requirements,
|
|
161
164
|
serviceEnv,
|
|
162
165
|
migrate,
|
|
163
166
|
seed,
|
|
164
167
|
skip,
|
|
168
|
+
runtime,
|
|
165
169
|
local,
|
|
166
170
|
},
|
|
167
171
|
};
|
|
@@ -240,6 +244,10 @@ function normalizeDatabaseConfig(explicitService, serviceName) {
|
|
|
240
244
|
|
|
241
245
|
return {
|
|
242
246
|
...database,
|
|
247
|
+
binding: normalizeDatabaseBinding(
|
|
248
|
+
database.binding || "per-runtime",
|
|
249
|
+
`Service "${serviceName}" database.binding`
|
|
250
|
+
),
|
|
243
251
|
provider: "local",
|
|
244
252
|
selectedBackend: "local",
|
|
245
253
|
reset: database.reset !== false,
|
|
@@ -253,6 +261,21 @@ function normalizeDatabaseConfig(explicitService, serviceName) {
|
|
|
253
261
|
};
|
|
254
262
|
}
|
|
255
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
|
+
|
|
256
279
|
function normalizeLifecycle(value) {
|
|
257
280
|
if (!value) return undefined;
|
|
258
281
|
if (!value.cmd && !value.testkitCmd) {
|
|
@@ -355,8 +378,8 @@ function normalizeSkipConfig(value, { name, productDir, suites }) {
|
|
|
355
378
|
};
|
|
356
379
|
}
|
|
357
380
|
|
|
358
|
-
function
|
|
359
|
-
if (!value) return { suites: [], files: [],
|
|
381
|
+
function normalizeServiceRequirements(value, { name, suites }) {
|
|
382
|
+
if (!value) return { suites: [], files: [], fileLocksByPath: new Map() };
|
|
360
383
|
|
|
361
384
|
const discoveredSuites = [];
|
|
362
385
|
const discoveredFiles = new Set();
|
|
@@ -376,25 +399,25 @@ function normalizeServiceExecution(value, { name, suites }) {
|
|
|
376
399
|
const seenSelectors = new Set();
|
|
377
400
|
for (const rule of value.suites || []) {
|
|
378
401
|
if (!rule || typeof rule !== "object") {
|
|
379
|
-
throw new Error(`Service "${name}"
|
|
402
|
+
throw new Error(`Service "${name}" requirements.suites entries must be objects`);
|
|
380
403
|
}
|
|
381
404
|
const selector = String(rule.selector || "").trim();
|
|
382
405
|
if (!selector) {
|
|
383
|
-
throw new Error(`Service "${name}"
|
|
406
|
+
throw new Error(`Service "${name}" requirements.suites entries require a selector`);
|
|
384
407
|
}
|
|
385
408
|
const parsed = parseSuiteSelectors([selector]);
|
|
386
409
|
if (parsed.length !== 1) {
|
|
387
|
-
throw new Error(`Service "${name}"
|
|
410
|
+
throw new Error(`Service "${name}" requirements.suites["${selector}"] is invalid`);
|
|
388
411
|
}
|
|
389
412
|
const parsedSelector = parsed[0];
|
|
390
413
|
if (parsedSelector.kind !== "typed") {
|
|
391
414
|
throw new Error(
|
|
392
|
-
`Service "${name}"
|
|
415
|
+
`Service "${name}" requirements.suites["${selector}"] must use a typed selector like int:health`
|
|
393
416
|
);
|
|
394
417
|
}
|
|
395
418
|
if (seenSelectors.has(parsedSelector.raw)) {
|
|
396
419
|
throw new Error(
|
|
397
|
-
`Service "${name}" defines duplicate
|
|
420
|
+
`Service "${name}" defines duplicate requirements.suites selector "${parsedSelector.raw}"`
|
|
398
421
|
);
|
|
399
422
|
}
|
|
400
423
|
const matched = discoveredSuites.some((suite) =>
|
|
@@ -402,15 +425,15 @@ function normalizeServiceExecution(value, { name, suites }) {
|
|
|
402
425
|
);
|
|
403
426
|
if (!matched) {
|
|
404
427
|
throw new Error(
|
|
405
|
-
`Service "${name}"
|
|
428
|
+
`Service "${name}" requirements.suites selector "${parsedSelector.raw}" did not match any discovered suite`
|
|
406
429
|
);
|
|
407
430
|
}
|
|
408
431
|
seenSelectors.add(parsedSelector.raw);
|
|
409
432
|
suiteRules.push({
|
|
410
433
|
selector: parsedSelector,
|
|
411
|
-
|
|
412
|
-
rule.
|
|
413
|
-
`Service "${name}"
|
|
434
|
+
locks: normalizeRequirementLocks(
|
|
435
|
+
rule.locks,
|
|
436
|
+
`Service "${name}" requirements.suites["${parsedSelector.raw}"].locks`
|
|
414
437
|
),
|
|
415
438
|
});
|
|
416
439
|
}
|
|
@@ -419,28 +442,28 @@ function normalizeServiceExecution(value, { name, suites }) {
|
|
|
419
442
|
const seenFiles = new Set();
|
|
420
443
|
for (const [index, rule] of (value.files || []).entries()) {
|
|
421
444
|
if (!rule || typeof rule !== "object") {
|
|
422
|
-
throw new Error(`Service "${name}"
|
|
445
|
+
throw new Error(`Service "${name}" requirements.files entries must be objects`);
|
|
423
446
|
}
|
|
424
447
|
|
|
425
448
|
const filePath = String(rule.path || "").trim();
|
|
426
449
|
if (!filePath) {
|
|
427
|
-
throw new Error(`Service "${name}"
|
|
450
|
+
throw new Error(`Service "${name}" requirements.files[${index}] requires a path`);
|
|
428
451
|
}
|
|
429
452
|
if (!discoveredFiles.has(filePath)) {
|
|
430
453
|
throw new Error(
|
|
431
|
-
`Service "${name}"
|
|
454
|
+
`Service "${name}" requirements.files["${filePath}"] did not match any discovered test file`
|
|
432
455
|
);
|
|
433
456
|
}
|
|
434
457
|
if (seenFiles.has(filePath)) {
|
|
435
|
-
throw new Error(`Service "${name}" defines duplicate
|
|
458
|
+
throw new Error(`Service "${name}" defines duplicate requirements.files path "${filePath}"`);
|
|
436
459
|
}
|
|
437
460
|
|
|
438
461
|
seenFiles.add(filePath);
|
|
439
462
|
fileRules.push({
|
|
440
463
|
path: filePath,
|
|
441
|
-
|
|
442
|
-
rule.
|
|
443
|
-
`Service "${name}"
|
|
464
|
+
locks: normalizeRequirementLocks(
|
|
465
|
+
rule.locks,
|
|
466
|
+
`Service "${name}" requirements.files["${filePath}"].locks`
|
|
444
467
|
),
|
|
445
468
|
});
|
|
446
469
|
}
|
|
@@ -448,10 +471,28 @@ function normalizeServiceExecution(value, { name, suites }) {
|
|
|
448
471
|
return {
|
|
449
472
|
suites: suiteRules,
|
|
450
473
|
files: fileRules,
|
|
451
|
-
|
|
474
|
+
fileLocksByPath: new Map(fileRules.map((rule) => [rule.path, rule.locks])),
|
|
452
475
|
};
|
|
453
476
|
}
|
|
454
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
|
+
|
|
455
496
|
function inferEnvFiles(productDir, explicitService, local) {
|
|
456
497
|
if (explicitService.envFile || explicitService.envFiles) {
|
|
457
498
|
const files = [];
|
|
@@ -512,6 +553,7 @@ function validateServiceConfig({
|
|
|
512
553
|
local,
|
|
513
554
|
database,
|
|
514
555
|
databaseFrom,
|
|
556
|
+
runtime,
|
|
515
557
|
migrate,
|
|
516
558
|
seed,
|
|
517
559
|
dependsOn,
|
|
@@ -533,6 +575,9 @@ function validateServiceConfig({
|
|
|
533
575
|
if (database && databaseFrom) {
|
|
534
576
|
throw new Error(`Service "${name}" cannot define both database and databaseFrom`);
|
|
535
577
|
}
|
|
578
|
+
if (runtime.instances < 1) {
|
|
579
|
+
throw new Error(`Service "${name}" runtime.instances must be a positive integer`);
|
|
580
|
+
}
|
|
536
581
|
|
|
537
582
|
for (const depName of dependsOn || []) {
|
|
538
583
|
if (depName === name) {
|
|
@@ -592,8 +637,6 @@ function normalizeRepoExecution(execution) {
|
|
|
592
637
|
return normalizeExecutionConfig({
|
|
593
638
|
workers: 1,
|
|
594
639
|
fileTimeoutSeconds: DEFAULT_FILE_TIMEOUT_SECONDS,
|
|
595
|
-
stackMode: "isolated",
|
|
596
|
-
stackCount: 1,
|
|
597
640
|
});
|
|
598
641
|
}
|
|
599
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(
|
|
@@ -3,81 +3,62 @@ import path from "path";
|
|
|
3
3
|
import { execa } from "execa";
|
|
4
4
|
import { bundleK6File } from "../bundler/index.mjs";
|
|
5
5
|
import { resolveK6Binary } from "../config/index.mjs";
|
|
6
|
-
import { persistTaskArtifacts } from "./artifacts.mjs";
|
|
7
|
-
import { RUNTIME_ARTIFACT_MARKER } from "../runtime-src/k6/artifacts.js";
|
|
8
|
-
import { determineDefaultRuntimeFailure } from "./default-runtime-errors.mjs";
|
|
9
|
-
import { formatBatchDescriptor } from "./formatting.mjs";
|
|
10
|
-
import { buildExecutionEnv } from "./template.mjs";
|
|
11
|
-
import { readDatabaseUrl } from "./state-io.mjs";
|
|
12
6
|
import {
|
|
13
7
|
buildFileTimeoutEnv,
|
|
14
8
|
formatFileTimeoutBudgetError,
|
|
15
9
|
} from "../shared/file-timeout.mjs";
|
|
10
|
+
import { persistTaskArtifacts } from "./artifacts.mjs";
|
|
11
|
+
import { determineDefaultRuntimeFailure } from "./default-runtime-errors.mjs";
|
|
12
|
+
import { RUNTIME_ARTIFACT_MARKER } from "../runtime-src/k6/artifacts.js";
|
|
13
|
+
import { readDatabaseUrl } from "./state-io.mjs";
|
|
14
|
+
import { buildTaskExecutionEnv } from "./template.mjs";
|
|
16
15
|
|
|
17
|
-
export async function
|
|
16
|
+
export async function runHttpK6Task(targetConfig, task, lifecycle, lease) {
|
|
18
17
|
const baseUrl = targetConfig.testkit.local?.baseUrl;
|
|
19
18
|
if (!baseUrl) {
|
|
20
19
|
throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP suites`);
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
console.log(
|
|
24
|
-
`\n── ${targetConfig.stackLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
return Promise.all(
|
|
28
|
-
batch.tasks.map((task) => runHttpK6Task(targetConfig, task, baseUrl, lifecycle))
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function runDalBatch(targetConfig, batch, lifecycle) {
|
|
33
|
-
const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
|
|
34
|
-
if (!databaseUrl) {
|
|
35
|
-
throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
console.log(
|
|
39
|
-
`\n── ${targetConfig.stackLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
return Promise.all(
|
|
43
|
-
batch.tasks.map((task) => runDalTask(targetConfig, task, databaseUrl, lifecycle))
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function runHttpK6Task(targetConfig, task, baseUrl, lifecycle) {
|
|
48
22
|
const bundledFile = await bundleK6File({
|
|
49
23
|
productDir: targetConfig.productDir,
|
|
50
24
|
serviceName: targetConfig.name,
|
|
51
25
|
sourceFile: path.join(targetConfig.productDir, task.file),
|
|
52
26
|
});
|
|
53
|
-
console.log(`·· ${targetConfig.
|
|
27
|
+
console.log(`·· ${targetConfig.runtimeLabel}:${task.suiteName} → ${task.file}`);
|
|
54
28
|
return runDefaultRuntimeTask(
|
|
55
29
|
targetConfig,
|
|
56
30
|
task,
|
|
31
|
+
lease,
|
|
57
32
|
["run", "--address", "127.0.0.1:0", "-e", `BASE_URL=${baseUrl}`, bundledFile],
|
|
58
33
|
lifecycle
|
|
59
34
|
);
|
|
60
35
|
}
|
|
61
36
|
|
|
62
|
-
async function runDalTask(targetConfig, task,
|
|
37
|
+
export async function runDalTask(targetConfig, task, lifecycle, lease) {
|
|
38
|
+
const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
|
|
39
|
+
if (!databaseUrl) {
|
|
40
|
+
throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
|
|
41
|
+
}
|
|
42
|
+
|
|
63
43
|
const bundledFile = await bundleK6File({
|
|
64
44
|
productDir: targetConfig.productDir,
|
|
65
45
|
serviceName: targetConfig.name,
|
|
66
46
|
sourceFile: path.join(targetConfig.productDir, task.file),
|
|
67
47
|
});
|
|
68
|
-
console.log(`·· ${targetConfig.
|
|
48
|
+
console.log(`·· ${targetConfig.runtimeLabel}:${task.suiteName} → ${task.file}`);
|
|
69
49
|
return runDefaultRuntimeTask(
|
|
70
50
|
targetConfig,
|
|
71
51
|
task,
|
|
52
|
+
lease,
|
|
72
53
|
["run", "--address", "127.0.0.1:0", "-e", `DATABASE_URL=${databaseUrl}`, bundledFile],
|
|
73
54
|
lifecycle
|
|
74
55
|
);
|
|
75
56
|
}
|
|
76
57
|
|
|
77
|
-
export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle, firstLine) {
|
|
58
|
+
export async function runDefaultRuntimeTask(targetConfig, task, lease, args, lifecycle, firstLine) {
|
|
78
59
|
const k6Binary = resolveK6Binary();
|
|
79
60
|
const getFirstLine = firstLine || defaultFirstLine;
|
|
80
|
-
const summaryFile = buildDefaultRuntimeSummaryPath(
|
|
61
|
+
const summaryFile = buildDefaultRuntimeSummaryPath(lease, task);
|
|
81
62
|
fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
|
|
82
63
|
const startedAt = Date.now();
|
|
83
64
|
const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
|
|
@@ -86,8 +67,9 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
|
|
|
86
67
|
[...args.slice(0, 1), "--summary-export", summaryFile, ...args.slice(1)],
|
|
87
68
|
{
|
|
88
69
|
cwd: targetConfig.productDir,
|
|
89
|
-
env:
|
|
70
|
+
env: buildTaskExecutionEnv(
|
|
90
71
|
targetConfig,
|
|
72
|
+
lease,
|
|
91
73
|
buildFileTimeoutEnv(fileTimeoutSeconds, startedAt),
|
|
92
74
|
process.env
|
|
93
75
|
),
|
|
@@ -96,7 +78,7 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
|
|
|
96
78
|
forceKillAfterDelay: 5_000,
|
|
97
79
|
}
|
|
98
80
|
);
|
|
99
|
-
const { result, timedOut } = await
|
|
81
|
+
const { result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds);
|
|
100
82
|
|
|
101
83
|
const stdout = parseDefaultRuntimeOutput(result.stdout || "");
|
|
102
84
|
const stderr = parseDefaultRuntimeOutput(result.stderr || "");
|
|
@@ -125,14 +107,7 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
|
|
|
125
107
|
};
|
|
126
108
|
}
|
|
127
109
|
|
|
128
|
-
function
|
|
129
|
-
return String(output || "")
|
|
130
|
-
.split(/\r?\n/)
|
|
131
|
-
.map((line) => line.trim())
|
|
132
|
-
.find(Boolean) || null;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async function settleDefaultRuntimeProcess(subprocess, fileTimeoutSeconds) {
|
|
110
|
+
export async function settleSubprocess(subprocess, fileTimeoutSeconds) {
|
|
136
111
|
const timeoutMs = fileTimeoutSeconds * 1000 + 1_000;
|
|
137
112
|
let timeoutHandle = null;
|
|
138
113
|
let timedOut = false;
|
|
@@ -154,12 +129,8 @@ async function settleDefaultRuntimeProcess(subprocess, fileTimeoutSeconds) {
|
|
|
154
129
|
}
|
|
155
130
|
}
|
|
156
131
|
|
|
157
|
-
export function buildDefaultRuntimeSummaryPath(
|
|
158
|
-
return path.join(
|
|
159
|
-
targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit"),
|
|
160
|
-
"_runtime",
|
|
161
|
-
`task-${task.id}.summary.json`
|
|
162
|
-
);
|
|
132
|
+
export function buildDefaultRuntimeSummaryPath(lease, task) {
|
|
133
|
+
return path.join(lease.leaseDir, "default-runtime", `task-${task.id}.summary.json`);
|
|
163
134
|
}
|
|
164
135
|
|
|
165
136
|
export function readDefaultRuntimeSummary(filePath) {
|
|
@@ -170,6 +141,13 @@ export function readDefaultRuntimeSummary(filePath) {
|
|
|
170
141
|
}
|
|
171
142
|
}
|
|
172
143
|
|
|
144
|
+
function defaultFirstLine(output) {
|
|
145
|
+
return String(output || "")
|
|
146
|
+
.split(/\r?\n/)
|
|
147
|
+
.map((line) => line.trim())
|
|
148
|
+
.find(Boolean) || null;
|
|
149
|
+
}
|
|
150
|
+
|
|
173
151
|
function parseDefaultRuntimeOutput(output) {
|
|
174
152
|
if (!output) {
|
|
175
153
|
return {
|