@elench/testkit 0.1.38 → 0.1.40
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 +31 -4
- package/lib/cli/args.mjs +12 -7
- package/lib/cli/args.test.mjs +14 -4
- package/lib/cli/index.mjs +20 -6
- package/lib/config/index.mjs +123 -0
- package/lib/runner/default-runtime-runner.mjs +49 -7
- package/lib/runner/execution-config.mjs +124 -0
- package/lib/runner/execution-config.test.mjs +111 -0
- package/lib/runner/lifecycle.mjs +7 -7
- package/lib/runner/orchestrator.mjs +51 -24
- package/lib/runner/planning.mjs +42 -2
- package/lib/runner/planning.test.mjs +175 -3
- package/lib/runner/playwright-config.test.mjs +1 -1
- package/lib/runner/playwright-runner.mjs +2 -2
- package/lib/runner/readiness.mjs +2 -2
- package/lib/runner/reporting.mjs +6 -2
- package/lib/runner/reporting.test.mjs +14 -1
- package/lib/runner/runtime-contexts.mjs +38 -47
- package/lib/runner/services.mjs +4 -4
- package/lib/runner/stack-manager.mjs +146 -0
- package/lib/runner/template.mjs +40 -32
- package/lib/runner/template.test.mjs +42 -35
- package/lib/runner/worker-loop.mjs +17 -14
- package/lib/runtime/index.d.ts +11 -0
- package/lib/runtime/index.mjs +34 -0
- package/lib/setup/index.d.ts +24 -0
- package/lib/shared/file-timeout.mjs +107 -0
- package/lib/shared/file-timeout.test.mjs +64 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,8 +21,14 @@ 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 --
|
|
24
|
+
# Shared local stack with parallel workers
|
|
25
|
+
npx @elench/testkit --workers 8 --stack-mode shared
|
|
26
|
+
|
|
27
|
+
# One file-level wall clock budget for every suite file
|
|
28
|
+
npx @elench/testkit --file-timeout-seconds 60
|
|
29
|
+
|
|
30
|
+
# Two reusable local stacks for browser-heavy suites
|
|
31
|
+
npx @elench/testkit --workers 6 --stack-mode pooled --stack-count 2
|
|
26
32
|
|
|
27
33
|
# Run a deterministic shard
|
|
28
34
|
npx @elench/testkit --shard 1/3
|
|
@@ -61,6 +67,12 @@ import {
|
|
|
61
67
|
} from "@elench/testkit/setup";
|
|
62
68
|
|
|
63
69
|
export default defineTestkitSetup({
|
|
70
|
+
execution: {
|
|
71
|
+
workers: 8,
|
|
72
|
+
fileTimeoutSeconds: 60,
|
|
73
|
+
stackMode: "shared",
|
|
74
|
+
stackCount: 1,
|
|
75
|
+
},
|
|
64
76
|
services: {
|
|
65
77
|
api: service({
|
|
66
78
|
...tsxService({
|
|
@@ -109,6 +121,8 @@ export default defineTestkitSetup({
|
|
|
109
121
|
`testkit.setup.ts` is optional for simple repos, but it is the primary escape hatch
|
|
110
122
|
for:
|
|
111
123
|
|
|
124
|
+
- worker and stack topology
|
|
125
|
+
- per-file wall clock timeout budget
|
|
112
126
|
- multi-service graphs
|
|
113
127
|
- local DB configuration
|
|
114
128
|
- migrate / seed commands
|
|
@@ -159,7 +173,20 @@ export default suite;
|
|
|
159
173
|
Low-level runtime primitives remain available:
|
|
160
174
|
|
|
161
175
|
```ts
|
|
162
|
-
import { check, group, http } from "@elench/testkit/runtime";
|
|
176
|
+
import { check, group, http, waitFor } from "@elench/testkit/runtime";
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`waitFor()` consumes the file budget configured by `execution.fileTimeoutSeconds`.
|
|
180
|
+
Consumers should not set local timeout values in test files.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { waitFor } from "@elench/testkit/runtime";
|
|
184
|
+
|
|
185
|
+
const response = waitFor(
|
|
186
|
+
() => req("GET", "/api/v1/jobs/123", setupData),
|
|
187
|
+
(res) => JSON.parse(res.body).data?.status === "completed",
|
|
188
|
+
{ description: "job 123 to complete" }
|
|
189
|
+
);
|
|
163
190
|
```
|
|
164
191
|
|
|
165
192
|
## Discovery
|
|
@@ -197,7 +224,7 @@ Suite names are inferred from the colocated path:
|
|
|
197
224
|
services that define `database: localDatabase(...)`.
|
|
198
225
|
|
|
199
226
|
- template databases are cached
|
|
200
|
-
-
|
|
227
|
+
- stack databases are cloned from templates
|
|
201
228
|
- template fingerprints are derived automatically from env files, migrate/seed
|
|
202
229
|
config, and repo contents
|
|
203
230
|
|
package/lib/cli/args.mjs
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { normalizeTypeValues, parseSuiteSelectors } from "../runner/suite-selection.mjs";
|
|
3
|
+
import {
|
|
4
|
+
parseFileTimeoutOption,
|
|
5
|
+
parseStackCountOption,
|
|
6
|
+
parseStackModeOption,
|
|
7
|
+
parseWorkersOption,
|
|
8
|
+
} from "../runner/execution-config.mjs";
|
|
3
9
|
|
|
4
10
|
export const POSITIONAL_TYPES = new Set(["int", "e2e", "dal", "load", "pw", "all"]);
|
|
5
11
|
export const LIFECYCLE = new Set(["status", "destroy", "cleanup"]);
|
|
@@ -40,13 +46,12 @@ export function parseSuiteOption(values) {
|
|
|
40
46
|
return parseSuiteSelectors(input);
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
49
|
+
export {
|
|
50
|
+
parseFileTimeoutOption,
|
|
51
|
+
parseWorkersOption,
|
|
52
|
+
parseStackModeOption,
|
|
53
|
+
parseStackCountOption,
|
|
54
|
+
};
|
|
50
55
|
|
|
51
56
|
export function parseShardOption(value) {
|
|
52
57
|
if (!value) return null;
|
package/lib/cli/args.test.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
parseFileTimeoutOption,
|
|
4
4
|
parseShardOption,
|
|
5
|
+
parseStackCountOption,
|
|
6
|
+
parseStackModeOption,
|
|
5
7
|
parseSuiteOption,
|
|
6
8
|
parseTypeOption,
|
|
9
|
+
parseWorkersOption,
|
|
7
10
|
resolveRequestedFiles,
|
|
8
11
|
resolveCliSelection,
|
|
9
12
|
} from "./args.mjs";
|
|
@@ -55,9 +58,16 @@ describe("cli-args", () => {
|
|
|
55
58
|
]);
|
|
56
59
|
});
|
|
57
60
|
|
|
58
|
-
it("parses and validates
|
|
59
|
-
expect(
|
|
60
|
-
expect((
|
|
61
|
+
it("parses and validates execution options", () => {
|
|
62
|
+
expect(parseWorkersOption("3")).toBe(3);
|
|
63
|
+
expect(parseFileTimeoutOption("45")).toBe(45);
|
|
64
|
+
expect(parseStackCountOption("2")).toBe(2);
|
|
65
|
+
expect(parseStackModeOption("pooled")).toBe("pooled");
|
|
66
|
+
expect(() => parseWorkersOption("0")).toThrow("Invalid --workers value");
|
|
67
|
+
expect(() => parseFileTimeoutOption("0")).toThrow(
|
|
68
|
+
"Invalid --file-timeout-seconds value"
|
|
69
|
+
);
|
|
70
|
+
expect(() => parseStackModeOption("legacy")).toThrow("Invalid --stack-mode value");
|
|
61
71
|
});
|
|
62
72
|
|
|
63
73
|
it("parses and validates shards", () => {
|
package/lib/cli/index.mjs
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { cac } from "cac";
|
|
2
2
|
import { loadConfigs } from "../config/index.mjs";
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
parseFileTimeoutOption,
|
|
5
5
|
parseShardOption,
|
|
6
|
+
parseStackCountOption,
|
|
7
|
+
parseStackModeOption,
|
|
6
8
|
parseSuiteOption,
|
|
7
9
|
parseTypeOption,
|
|
10
|
+
parseWorkersOption,
|
|
8
11
|
resolveRequestedFiles,
|
|
9
12
|
resolveCliSelection,
|
|
10
13
|
} from "./args.mjs";
|
|
@@ -22,9 +25,10 @@ export function run() {
|
|
|
22
25
|
.option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
|
|
23
26
|
.option("-f, --file <path>", "Run specific file(s)", { default: [] })
|
|
24
27
|
.option("--dir <path>", "Explicit product directory")
|
|
25
|
-
.option("--
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
.option("--workers <n>", "Number of test executors for the whole run")
|
|
29
|
+
.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")
|
|
28
32
|
.option("--shard <i/n>", "Run only shard i of n at suite granularity")
|
|
29
33
|
.option("--write-status", "Write a deterministic testkit.status.json snapshot")
|
|
30
34
|
.option("--allow-partial-status", "Allow --write-status for filtered runs")
|
|
@@ -62,7 +66,14 @@ export function run() {
|
|
|
62
66
|
return;
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
const
|
|
69
|
+
const workers = options.workers == null ? null : parseWorkersOption(options.workers);
|
|
70
|
+
const fileTimeoutSeconds =
|
|
71
|
+
options.fileTimeoutSeconds == null
|
|
72
|
+
? null
|
|
73
|
+
: parseFileTimeoutOption(options.fileTimeoutSeconds);
|
|
74
|
+
const stackMode = options.stackMode == null ? null : parseStackModeOption(options.stackMode);
|
|
75
|
+
const stackCount =
|
|
76
|
+
options.stackCount == null ? null : parseStackCountOption(options.stackCount);
|
|
66
77
|
const shard = parseShardOption(options.shard);
|
|
67
78
|
const typeValues = parseTypeOption(options.type, positionalType);
|
|
68
79
|
const suiteSelectors = parseSuiteOption(options.suite);
|
|
@@ -77,7 +88,10 @@ export function run() {
|
|
|
77
88
|
...options,
|
|
78
89
|
typeValues,
|
|
79
90
|
fileNames,
|
|
80
|
-
|
|
91
|
+
workers,
|
|
92
|
+
fileTimeoutSeconds,
|
|
93
|
+
stackMode,
|
|
94
|
+
stackCount,
|
|
81
95
|
shard,
|
|
82
96
|
serviceFilter: options.service || null,
|
|
83
97
|
},
|
package/lib/config/index.mjs
CHANGED
|
@@ -8,6 +8,11 @@ import {
|
|
|
8
8
|
parseSuiteSelectors,
|
|
9
9
|
suiteSelectionType,
|
|
10
10
|
} from "../runner/suite-selection.mjs";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_FILE_TIMEOUT_SECONDS,
|
|
13
|
+
normalizeExecutionConfig,
|
|
14
|
+
normalizeStackModeValue,
|
|
15
|
+
} from "../runner/execution-config.mjs";
|
|
11
16
|
|
|
12
17
|
const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
|
|
13
18
|
const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
|
|
@@ -22,6 +27,7 @@ export function parseDotenv(filePath) {
|
|
|
22
27
|
export async function loadConfigs(opts = {}) {
|
|
23
28
|
const productDir = resolveProductDir(process.cwd(), opts.dir);
|
|
24
29
|
const { setup, setupFile } = await loadTestkitSetup(productDir);
|
|
30
|
+
const execution = normalizeRepoExecution(setup.execution);
|
|
25
31
|
const explicitServices = setup.services || {};
|
|
26
32
|
const discovery = discoverProject(productDir, explicitServices);
|
|
27
33
|
const serviceNames = new Set([
|
|
@@ -37,6 +43,7 @@ export async function loadConfigs(opts = {}) {
|
|
|
37
43
|
productDir,
|
|
38
44
|
setup,
|
|
39
45
|
setupFile,
|
|
46
|
+
execution,
|
|
40
47
|
explicitService: explicitServices[name] || {},
|
|
41
48
|
discoveredService: discovery.services[name] || null,
|
|
42
49
|
suites: discovery.suitesByService[name] || {},
|
|
@@ -95,6 +102,7 @@ function normalizeServiceConfig({
|
|
|
95
102
|
productDir,
|
|
96
103
|
setup,
|
|
97
104
|
setupFile,
|
|
105
|
+
execution,
|
|
98
106
|
explicitService,
|
|
99
107
|
discoveredService,
|
|
100
108
|
suites,
|
|
@@ -113,6 +121,10 @@ function normalizeServiceConfig({
|
|
|
113
121
|
productDir,
|
|
114
122
|
suites,
|
|
115
123
|
});
|
|
124
|
+
const serviceExecution = normalizeServiceExecution(explicitService.execution, {
|
|
125
|
+
name,
|
|
126
|
+
suites,
|
|
127
|
+
});
|
|
116
128
|
|
|
117
129
|
if (!explicitService.databaseFrom && !database && (migrate || seed)) {
|
|
118
130
|
throw new Error(
|
|
@@ -140,10 +152,12 @@ function normalizeServiceConfig({
|
|
|
140
152
|
telemetry: normalizeTelemetryConfig(setup.telemetry),
|
|
141
153
|
suites,
|
|
142
154
|
testkit: {
|
|
155
|
+
execution,
|
|
143
156
|
dependsOn: explicitService.dependsOn || [],
|
|
144
157
|
database,
|
|
145
158
|
databaseFrom: explicitService.databaseFrom,
|
|
146
159
|
envFiles,
|
|
160
|
+
serviceExecution,
|
|
147
161
|
serviceEnv,
|
|
148
162
|
migrate,
|
|
149
163
|
seed,
|
|
@@ -341,6 +355,103 @@ function normalizeSkipConfig(value, { name, productDir, suites }) {
|
|
|
341
355
|
};
|
|
342
356
|
}
|
|
343
357
|
|
|
358
|
+
function normalizeServiceExecution(value, { name, suites }) {
|
|
359
|
+
if (!value) return { suites: [], files: [], fileStackModeByPath: new Map() };
|
|
360
|
+
|
|
361
|
+
const discoveredSuites = [];
|
|
362
|
+
const discoveredFiles = new Set();
|
|
363
|
+
for (const [type, typedSuites] of Object.entries(suites || {})) {
|
|
364
|
+
for (const suite of typedSuites || []) {
|
|
365
|
+
discoveredSuites.push({
|
|
366
|
+
displayType: suiteSelectionType(type, suite.framework || "k6"),
|
|
367
|
+
name: suite.name,
|
|
368
|
+
});
|
|
369
|
+
for (const file of suite.files || []) {
|
|
370
|
+
discoveredFiles.add(file);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const suiteRules = [];
|
|
376
|
+
const seenSelectors = new Set();
|
|
377
|
+
for (const rule of value.suites || []) {
|
|
378
|
+
if (!rule || typeof rule !== "object") {
|
|
379
|
+
throw new Error(`Service "${name}" execution.suites entries must be objects`);
|
|
380
|
+
}
|
|
381
|
+
const selector = String(rule.selector || "").trim();
|
|
382
|
+
if (!selector) {
|
|
383
|
+
throw new Error(`Service "${name}" execution.suites entries require a selector`);
|
|
384
|
+
}
|
|
385
|
+
const parsed = parseSuiteSelectors([selector]);
|
|
386
|
+
if (parsed.length !== 1) {
|
|
387
|
+
throw new Error(`Service "${name}" execution.suites["${selector}"] is invalid`);
|
|
388
|
+
}
|
|
389
|
+
const parsedSelector = parsed[0];
|
|
390
|
+
if (parsedSelector.kind !== "typed") {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`Service "${name}" execution.suites["${selector}"] must use a typed selector like int:health`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
if (seenSelectors.has(parsedSelector.raw)) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`Service "${name}" defines duplicate execution.suites selector "${parsedSelector.raw}"`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
const matched = discoveredSuites.some((suite) =>
|
|
401
|
+
matchesSuiteSelectors(suite.displayType, suite.name, [parsedSelector])
|
|
402
|
+
);
|
|
403
|
+
if (!matched) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`Service "${name}" execution.suites selector "${parsedSelector.raw}" did not match any discovered suite`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
seenSelectors.add(parsedSelector.raw);
|
|
409
|
+
suiteRules.push({
|
|
410
|
+
selector: parsedSelector,
|
|
411
|
+
stackMode: normalizeStackModeValue(
|
|
412
|
+
rule.stackMode,
|
|
413
|
+
`Service "${name}" execution.suites["${parsedSelector.raw}"].stackMode`
|
|
414
|
+
),
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const fileRules = [];
|
|
419
|
+
const seenFiles = new Set();
|
|
420
|
+
for (const [index, rule] of (value.files || []).entries()) {
|
|
421
|
+
if (!rule || typeof rule !== "object") {
|
|
422
|
+
throw new Error(`Service "${name}" execution.files entries must be objects`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const filePath = String(rule.path || "").trim();
|
|
426
|
+
if (!filePath) {
|
|
427
|
+
throw new Error(`Service "${name}" execution.files[${index}] requires a path`);
|
|
428
|
+
}
|
|
429
|
+
if (!discoveredFiles.has(filePath)) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
`Service "${name}" execution.files["${filePath}"] did not match any discovered test file`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
if (seenFiles.has(filePath)) {
|
|
435
|
+
throw new Error(`Service "${name}" defines duplicate execution.files path "${filePath}"`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
seenFiles.add(filePath);
|
|
439
|
+
fileRules.push({
|
|
440
|
+
path: filePath,
|
|
441
|
+
stackMode: normalizeStackModeValue(
|
|
442
|
+
rule.stackMode,
|
|
443
|
+
`Service "${name}" execution.files["${filePath}"].stackMode`
|
|
444
|
+
),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
suites: suiteRules,
|
|
450
|
+
files: fileRules,
|
|
451
|
+
fileStackModeByPath: new Map(fileRules.map((rule) => [rule.path, rule.stackMode])),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
344
455
|
function inferEnvFiles(productDir, explicitService, local) {
|
|
345
456
|
if (explicitService.envFile || explicitService.envFiles) {
|
|
346
457
|
const files = [];
|
|
@@ -476,6 +587,18 @@ function normalizeTelemetryConfig(telemetry) {
|
|
|
476
587
|
};
|
|
477
588
|
}
|
|
478
589
|
|
|
590
|
+
function normalizeRepoExecution(execution) {
|
|
591
|
+
if (!execution) {
|
|
592
|
+
return normalizeExecutionConfig({
|
|
593
|
+
workers: 1,
|
|
594
|
+
fileTimeoutSeconds: DEFAULT_FILE_TIMEOUT_SECONDS,
|
|
595
|
+
stackMode: "isolated",
|
|
596
|
+
stackCount: 1,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return normalizeExecutionConfig(execution);
|
|
600
|
+
}
|
|
601
|
+
|
|
479
602
|
function normalizePath(value) {
|
|
480
603
|
return String(value || "")
|
|
481
604
|
.split(path.sep)
|
|
@@ -9,6 +9,10 @@ import { determineDefaultRuntimeFailure } from "./default-runtime-errors.mjs";
|
|
|
9
9
|
import { formatBatchDescriptor } from "./formatting.mjs";
|
|
10
10
|
import { buildExecutionEnv } from "./template.mjs";
|
|
11
11
|
import { readDatabaseUrl } from "./state-io.mjs";
|
|
12
|
+
import {
|
|
13
|
+
buildFileTimeoutEnv,
|
|
14
|
+
formatFileTimeoutBudgetError,
|
|
15
|
+
} from "../shared/file-timeout.mjs";
|
|
12
16
|
|
|
13
17
|
export async function runHttpK6Batch(targetConfig, batch, lifecycle) {
|
|
14
18
|
const baseUrl = targetConfig.testkit.local?.baseUrl;
|
|
@@ -17,7 +21,7 @@ export async function runHttpK6Batch(targetConfig, batch, lifecycle) {
|
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
console.log(
|
|
20
|
-
`\n── ${targetConfig.
|
|
24
|
+
`\n── ${targetConfig.stackLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
|
|
21
25
|
);
|
|
22
26
|
|
|
23
27
|
return Promise.all(
|
|
@@ -32,7 +36,7 @@ export async function runDalBatch(targetConfig, batch, lifecycle) {
|
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
console.log(
|
|
35
|
-
`\n── ${targetConfig.
|
|
39
|
+
`\n── ${targetConfig.stackLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
|
|
36
40
|
);
|
|
37
41
|
|
|
38
42
|
return Promise.all(
|
|
@@ -46,7 +50,7 @@ async function runHttpK6Task(targetConfig, task, baseUrl, lifecycle) {
|
|
|
46
50
|
serviceName: targetConfig.name,
|
|
47
51
|
sourceFile: path.join(targetConfig.productDir, task.file),
|
|
48
52
|
});
|
|
49
|
-
console.log(`·· ${targetConfig.
|
|
53
|
+
console.log(`·· ${targetConfig.stackLabel}:${task.suiteName} → ${task.file}`);
|
|
50
54
|
return runDefaultRuntimeTask(
|
|
51
55
|
targetConfig,
|
|
52
56
|
task,
|
|
@@ -61,7 +65,7 @@ async function runDalTask(targetConfig, task, databaseUrl, lifecycle) {
|
|
|
61
65
|
serviceName: targetConfig.name,
|
|
62
66
|
sourceFile: path.join(targetConfig.productDir, task.file),
|
|
63
67
|
});
|
|
64
|
-
console.log(`·· ${targetConfig.
|
|
68
|
+
console.log(`·· ${targetConfig.stackLabel}:${task.suiteName} → ${task.file}`);
|
|
65
69
|
return runDefaultRuntimeTask(
|
|
66
70
|
targetConfig,
|
|
67
71
|
task,
|
|
@@ -72,20 +76,27 @@ async function runDalTask(targetConfig, task, databaseUrl, lifecycle) {
|
|
|
72
76
|
|
|
73
77
|
export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle, firstLine) {
|
|
74
78
|
const k6Binary = resolveK6Binary();
|
|
79
|
+
const getFirstLine = firstLine || defaultFirstLine;
|
|
75
80
|
const summaryFile = buildDefaultRuntimeSummaryPath(targetConfig, task);
|
|
76
81
|
fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
|
|
77
82
|
const startedAt = Date.now();
|
|
78
|
-
const
|
|
83
|
+
const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
|
|
84
|
+
const subprocess = execa(
|
|
79
85
|
k6Binary,
|
|
80
86
|
[...args.slice(0, 1), "--summary-export", summaryFile, ...args.slice(1)],
|
|
81
87
|
{
|
|
82
88
|
cwd: targetConfig.productDir,
|
|
83
|
-
env: buildExecutionEnv(
|
|
89
|
+
env: buildExecutionEnv(
|
|
90
|
+
targetConfig,
|
|
91
|
+
buildFileTimeoutEnv(fileTimeoutSeconds, startedAt),
|
|
92
|
+
process.env
|
|
93
|
+
),
|
|
84
94
|
reject: false,
|
|
85
95
|
cancelSignal: lifecycle.signal,
|
|
86
96
|
forceKillAfterDelay: 5_000,
|
|
87
97
|
}
|
|
88
98
|
);
|
|
99
|
+
const { result, timedOut } = await settleDefaultRuntimeProcess(subprocess, fileTimeoutSeconds);
|
|
89
100
|
|
|
90
101
|
const stdout = parseDefaultRuntimeOutput(result.stdout || "");
|
|
91
102
|
const stderr = parseDefaultRuntimeOutput(result.stderr || "");
|
|
@@ -98,7 +109,9 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
|
|
|
98
109
|
task,
|
|
99
110
|
[...stdout.artifacts, ...stderr.artifacts]
|
|
100
111
|
);
|
|
101
|
-
const runtimeError =
|
|
112
|
+
const runtimeError = timedOut
|
|
113
|
+
? formatFileTimeoutBudgetError(fileTimeoutSeconds)
|
|
114
|
+
: determineDefaultRuntimeFailure(result, summary, getFirstLine);
|
|
102
115
|
const finishedAt = Date.now();
|
|
103
116
|
|
|
104
117
|
return {
|
|
@@ -112,6 +125,35 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
|
|
|
112
125
|
};
|
|
113
126
|
}
|
|
114
127
|
|
|
128
|
+
function defaultFirstLine(output) {
|
|
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) {
|
|
136
|
+
const timeoutMs = fileTimeoutSeconds * 1000 + 1_000;
|
|
137
|
+
let timeoutHandle = null;
|
|
138
|
+
let timedOut = false;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
return await Promise.race([
|
|
142
|
+
subprocess.then((result) => ({ result, timedOut })),
|
|
143
|
+
new Promise((resolve) => {
|
|
144
|
+
timeoutHandle = setTimeout(async () => {
|
|
145
|
+
timedOut = true;
|
|
146
|
+
subprocess.kill("SIGTERM");
|
|
147
|
+
const result = await subprocess.catch((error) => error);
|
|
148
|
+
resolve({ result, timedOut: true });
|
|
149
|
+
}, timeoutMs);
|
|
150
|
+
}),
|
|
151
|
+
]);
|
|
152
|
+
} finally {
|
|
153
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
115
157
|
export function buildDefaultRuntimeSummaryPath(targetConfig, task) {
|
|
116
158
|
return path.join(
|
|
117
159
|
targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit"),
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_FILE_TIMEOUT_SECONDS,
|
|
3
|
+
normalizeFileTimeoutSeconds,
|
|
4
|
+
parseFileTimeoutOption,
|
|
5
|
+
} from "../shared/file-timeout.mjs";
|
|
6
|
+
|
|
7
|
+
export const STACK_MODES = new Set(["shared", "pooled", "isolated"]);
|
|
8
|
+
|
|
9
|
+
export { DEFAULT_FILE_TIMEOUT_SECONDS, parseFileTimeoutOption };
|
|
10
|
+
|
|
11
|
+
export function parseWorkersOption(value) {
|
|
12
|
+
return parsePositiveInteger(value, "--workers");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseStackCountOption(value) {
|
|
16
|
+
return parsePositiveInteger(value, "--stack-count");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseStackModeOption(value) {
|
|
20
|
+
return normalizeStackModeValue(value, "--stack-mode");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function normalizeStackModeValue(value, label = "execution.stackMode") {
|
|
24
|
+
const normalized = String(value || "").trim();
|
|
25
|
+
if (!STACK_MODES.has(normalized)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Invalid ${label} value "${value}". Expected one of: shared, pooled, isolated.`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return normalized;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveExecutionConfig({ cli = {}, repo = {} } = {}) {
|
|
34
|
+
return normalizeExecutionConfig({
|
|
35
|
+
workers: cli.workers ?? repo.workers ?? 1,
|
|
36
|
+
stackMode: cli.stackMode ?? repo.stackMode ?? "isolated",
|
|
37
|
+
stackCount: cli.stackCount ?? repo.stackCount ?? null,
|
|
38
|
+
fileTimeoutSeconds:
|
|
39
|
+
cli.fileTimeoutSeconds ?? repo.fileTimeoutSeconds ?? DEFAULT_FILE_TIMEOUT_SECONDS,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function normalizeExecutionConfig(input = {}) {
|
|
44
|
+
const workers = normalizePositiveInteger(input.workers, "execution.workers");
|
|
45
|
+
const stackMode = normalizeStackMode(input.stackMode);
|
|
46
|
+
const explicitStackCount =
|
|
47
|
+
input.stackCount == null ? null : normalizePositiveInteger(input.stackCount, "execution.stackCount");
|
|
48
|
+
const fileTimeoutSeconds = normalizeFileTimeoutSeconds(
|
|
49
|
+
input.fileTimeoutSeconds ?? DEFAULT_FILE_TIMEOUT_SECONDS
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (stackMode === "shared") {
|
|
53
|
+
if (explicitStackCount !== null && explicitStackCount !== 1) {
|
|
54
|
+
throw new Error(`execution.stackCount must be 1 when stackMode is "shared".`);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
workers,
|
|
58
|
+
stackMode,
|
|
59
|
+
stackCount: 1,
|
|
60
|
+
fileTimeoutSeconds,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (stackMode === "pooled") {
|
|
65
|
+
return {
|
|
66
|
+
workers,
|
|
67
|
+
stackMode,
|
|
68
|
+
stackCount: explicitStackCount ?? 1,
|
|
69
|
+
fileTimeoutSeconds,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (explicitStackCount !== null && explicitStackCount !== workers) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`execution.stackCount must equal execution.workers when stackMode is "isolated".`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
workers,
|
|
80
|
+
stackMode,
|
|
81
|
+
stackCount: workers,
|
|
82
|
+
fileTimeoutSeconds,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildStackIds(execution) {
|
|
87
|
+
if (execution.stackMode === "shared") {
|
|
88
|
+
return ["shared"];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Array.from({ length: execution.stackCount }, (_unused, index) => `stack-${index + 1}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveBatchStackMode(defaultMode, suiteMode = null) {
|
|
95
|
+
if (suiteMode == null) return defaultMode;
|
|
96
|
+
return normalizeStackMode(suiteMode);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function resolveBatchAccessMode({ framework, type, stackMode }) {
|
|
100
|
+
if (stackMode === "isolated") return "exclusive";
|
|
101
|
+
if ((framework || "k6") === "playwright") return "exclusive";
|
|
102
|
+
if (type === "dal") return "exclusive";
|
|
103
|
+
return "shared";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeStackMode(value) {
|
|
107
|
+
return normalizeStackModeValue(value, "execution.stackMode");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parsePositiveInteger(value, label) {
|
|
111
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
112
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
113
|
+
throw new Error(`Invalid ${label} value "${value}". Expected a positive integer.`);
|
|
114
|
+
}
|
|
115
|
+
return parsed;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizePositiveInteger(value, label) {
|
|
119
|
+
const parsed = Number(value);
|
|
120
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
121
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
122
|
+
}
|
|
123
|
+
return parsed;
|
|
124
|
+
}
|