@elench/testkit 0.1.38 → 0.1.39
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 +12 -3
- package/lib/cli/args.mjs +6 -7
- package/lib/cli/args.test.mjs +9 -4
- package/lib/cli/index.mjs +15 -4
- package/lib/config/index.mjs +118 -0
- package/lib/runner/default-runtime-runner.mjs +4 -4
- package/lib/runner/execution-config.mjs +108 -0
- package/lib/runner/execution-config.test.mjs +101 -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 +5 -2
- package/lib/runner/reporting.test.mjs +12 -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/setup/index.d.ts +23 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,8 +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 --
|
|
24
|
+
# Shared local stack with parallel workers
|
|
25
|
+
npx @elench/testkit --workers 8 --stack-mode shared
|
|
26
|
+
|
|
27
|
+
# Two reusable local stacks for browser-heavy suites
|
|
28
|
+
npx @elench/testkit --workers 6 --stack-mode pooled --stack-count 2
|
|
26
29
|
|
|
27
30
|
# Run a deterministic shard
|
|
28
31
|
npx @elench/testkit --shard 1/3
|
|
@@ -61,6 +64,11 @@ import {
|
|
|
61
64
|
} from "@elench/testkit/setup";
|
|
62
65
|
|
|
63
66
|
export default defineTestkitSetup({
|
|
67
|
+
execution: {
|
|
68
|
+
workers: 8,
|
|
69
|
+
stackMode: "shared",
|
|
70
|
+
stackCount: 1,
|
|
71
|
+
},
|
|
64
72
|
services: {
|
|
65
73
|
api: service({
|
|
66
74
|
...tsxService({
|
|
@@ -109,6 +117,7 @@ export default defineTestkitSetup({
|
|
|
109
117
|
`testkit.setup.ts` is optional for simple repos, but it is the primary escape hatch
|
|
110
118
|
for:
|
|
111
119
|
|
|
120
|
+
- worker and stack topology
|
|
112
121
|
- multi-service graphs
|
|
113
122
|
- local DB configuration
|
|
114
123
|
- migrate / seed commands
|
|
@@ -197,7 +206,7 @@ Suite names are inferred from the colocated path:
|
|
|
197
206
|
services that define `database: localDatabase(...)`.
|
|
198
207
|
|
|
199
208
|
- template databases are cached
|
|
200
|
-
-
|
|
209
|
+
- stack databases are cloned from templates
|
|
201
210
|
- template fingerprints are derived automatically from env files, migrate/seed
|
|
202
211
|
config, and repo contents
|
|
203
212
|
|
package/lib/cli/args.mjs
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { normalizeTypeValues, parseSuiteSelectors } from "../runner/suite-selection.mjs";
|
|
3
|
+
import {
|
|
4
|
+
parseStackCountOption,
|
|
5
|
+
parseStackModeOption,
|
|
6
|
+
parseWorkersOption,
|
|
7
|
+
} from "../runner/execution-config.mjs";
|
|
3
8
|
|
|
4
9
|
export const POSITIONAL_TYPES = new Set(["int", "e2e", "dal", "load", "pw", "all"]);
|
|
5
10
|
export const LIFECYCLE = new Set(["status", "destroy", "cleanup"]);
|
|
@@ -40,13 +45,7 @@ export function parseSuiteOption(values) {
|
|
|
40
45
|
return parseSuiteSelectors(input);
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
export
|
|
44
|
-
const jobs = Number.parseInt(String(value), 10);
|
|
45
|
-
if (!Number.isInteger(jobs) || jobs <= 0) {
|
|
46
|
-
throw new Error(`Invalid --jobs value "${value}". Expected a positive integer.`);
|
|
47
|
-
}
|
|
48
|
-
return jobs;
|
|
49
|
-
}
|
|
48
|
+
export { parseWorkersOption, parseStackModeOption, parseStackCountOption };
|
|
50
49
|
|
|
51
50
|
export function parseShardOption(value) {
|
|
52
51
|
if (!value) return null;
|
package/lib/cli/args.test.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
parseJobsOption,
|
|
4
3
|
parseShardOption,
|
|
4
|
+
parseStackCountOption,
|
|
5
|
+
parseStackModeOption,
|
|
5
6
|
parseSuiteOption,
|
|
6
7
|
parseTypeOption,
|
|
8
|
+
parseWorkersOption,
|
|
7
9
|
resolveRequestedFiles,
|
|
8
10
|
resolveCliSelection,
|
|
9
11
|
} from "./args.mjs";
|
|
@@ -55,9 +57,12 @@ describe("cli-args", () => {
|
|
|
55
57
|
]);
|
|
56
58
|
});
|
|
57
59
|
|
|
58
|
-
it("parses and validates
|
|
59
|
-
expect(
|
|
60
|
-
expect((
|
|
60
|
+
it("parses and validates execution options", () => {
|
|
61
|
+
expect(parseWorkersOption("3")).toBe(3);
|
|
62
|
+
expect(parseStackCountOption("2")).toBe(2);
|
|
63
|
+
expect(parseStackModeOption("pooled")).toBe("pooled");
|
|
64
|
+
expect(() => parseWorkersOption("0")).toThrow("Invalid --workers value");
|
|
65
|
+
expect(() => parseStackModeOption("legacy")).toThrow("Invalid --stack-mode value");
|
|
61
66
|
});
|
|
62
67
|
|
|
63
68
|
it("parses and validates shards", () => {
|
package/lib/cli/index.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { cac } from "cac";
|
|
2
2
|
import { loadConfigs } from "../config/index.mjs";
|
|
3
3
|
import {
|
|
4
|
-
parseJobsOption,
|
|
5
4
|
parseShardOption,
|
|
5
|
+
parseStackCountOption,
|
|
6
|
+
parseStackModeOption,
|
|
6
7
|
parseSuiteOption,
|
|
7
8
|
parseTypeOption,
|
|
9
|
+
parseWorkersOption,
|
|
8
10
|
resolveRequestedFiles,
|
|
9
11
|
resolveCliSelection,
|
|
10
12
|
} from "./args.mjs";
|
|
@@ -22,9 +24,13 @@ export function run() {
|
|
|
22
24
|
.option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
|
|
23
25
|
.option("-f, --file <path>", "Run specific file(s)", { default: [] })
|
|
24
26
|
.option("--dir <path>", "Explicit product directory")
|
|
25
|
-
.option("--
|
|
27
|
+
.option("--workers <n>", "Number of test executors for the whole run", {
|
|
26
28
|
default: "1",
|
|
27
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")
|
|
28
34
|
.option("--shard <i/n>", "Run only shard i of n at suite granularity")
|
|
29
35
|
.option("--write-status", "Write a deterministic testkit.status.json snapshot")
|
|
30
36
|
.option("--allow-partial-status", "Allow --write-status for filtered runs")
|
|
@@ -62,7 +68,10 @@ export function run() {
|
|
|
62
68
|
return;
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
const
|
|
71
|
+
const workers = parseWorkersOption(options.workers);
|
|
72
|
+
const stackMode = parseStackModeOption(options.stackMode);
|
|
73
|
+
const stackCount =
|
|
74
|
+
options.stackCount == null ? null : parseStackCountOption(options.stackCount);
|
|
66
75
|
const shard = parseShardOption(options.shard);
|
|
67
76
|
const typeValues = parseTypeOption(options.type, positionalType);
|
|
68
77
|
const suiteSelectors = parseSuiteOption(options.suite);
|
|
@@ -77,7 +86,9 @@ export function run() {
|
|
|
77
86
|
...options,
|
|
78
87
|
typeValues,
|
|
79
88
|
fileNames,
|
|
80
|
-
|
|
89
|
+
workers,
|
|
90
|
+
stackMode,
|
|
91
|
+
stackCount,
|
|
81
92
|
shard,
|
|
82
93
|
serviceFilter: options.service || null,
|
|
83
94
|
},
|
package/lib/config/index.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
parseSuiteSelectors,
|
|
9
9
|
suiteSelectionType,
|
|
10
10
|
} from "../runner/suite-selection.mjs";
|
|
11
|
+
import { normalizeExecutionConfig, normalizeStackModeValue } from "../runner/execution-config.mjs";
|
|
11
12
|
|
|
12
13
|
const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
|
|
13
14
|
const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
|
|
@@ -22,6 +23,7 @@ export function parseDotenv(filePath) {
|
|
|
22
23
|
export async function loadConfigs(opts = {}) {
|
|
23
24
|
const productDir = resolveProductDir(process.cwd(), opts.dir);
|
|
24
25
|
const { setup, setupFile } = await loadTestkitSetup(productDir);
|
|
26
|
+
const execution = normalizeRepoExecution(setup.execution);
|
|
25
27
|
const explicitServices = setup.services || {};
|
|
26
28
|
const discovery = discoverProject(productDir, explicitServices);
|
|
27
29
|
const serviceNames = new Set([
|
|
@@ -37,6 +39,7 @@ export async function loadConfigs(opts = {}) {
|
|
|
37
39
|
productDir,
|
|
38
40
|
setup,
|
|
39
41
|
setupFile,
|
|
42
|
+
execution,
|
|
40
43
|
explicitService: explicitServices[name] || {},
|
|
41
44
|
discoveredService: discovery.services[name] || null,
|
|
42
45
|
suites: discovery.suitesByService[name] || {},
|
|
@@ -95,6 +98,7 @@ function normalizeServiceConfig({
|
|
|
95
98
|
productDir,
|
|
96
99
|
setup,
|
|
97
100
|
setupFile,
|
|
101
|
+
execution,
|
|
98
102
|
explicitService,
|
|
99
103
|
discoveredService,
|
|
100
104
|
suites,
|
|
@@ -113,6 +117,10 @@ function normalizeServiceConfig({
|
|
|
113
117
|
productDir,
|
|
114
118
|
suites,
|
|
115
119
|
});
|
|
120
|
+
const serviceExecution = normalizeServiceExecution(explicitService.execution, {
|
|
121
|
+
name,
|
|
122
|
+
suites,
|
|
123
|
+
});
|
|
116
124
|
|
|
117
125
|
if (!explicitService.databaseFrom && !database && (migrate || seed)) {
|
|
118
126
|
throw new Error(
|
|
@@ -140,10 +148,12 @@ function normalizeServiceConfig({
|
|
|
140
148
|
telemetry: normalizeTelemetryConfig(setup.telemetry),
|
|
141
149
|
suites,
|
|
142
150
|
testkit: {
|
|
151
|
+
execution,
|
|
143
152
|
dependsOn: explicitService.dependsOn || [],
|
|
144
153
|
database,
|
|
145
154
|
databaseFrom: explicitService.databaseFrom,
|
|
146
155
|
envFiles,
|
|
156
|
+
serviceExecution,
|
|
147
157
|
serviceEnv,
|
|
148
158
|
migrate,
|
|
149
159
|
seed,
|
|
@@ -341,6 +351,103 @@ function normalizeSkipConfig(value, { name, productDir, suites }) {
|
|
|
341
351
|
};
|
|
342
352
|
}
|
|
343
353
|
|
|
354
|
+
function normalizeServiceExecution(value, { name, suites }) {
|
|
355
|
+
if (!value) return { suites: [], files: [], fileStackModeByPath: new Map() };
|
|
356
|
+
|
|
357
|
+
const discoveredSuites = [];
|
|
358
|
+
const discoveredFiles = new Set();
|
|
359
|
+
for (const [type, typedSuites] of Object.entries(suites || {})) {
|
|
360
|
+
for (const suite of typedSuites || []) {
|
|
361
|
+
discoveredSuites.push({
|
|
362
|
+
displayType: suiteSelectionType(type, suite.framework || "k6"),
|
|
363
|
+
name: suite.name,
|
|
364
|
+
});
|
|
365
|
+
for (const file of suite.files || []) {
|
|
366
|
+
discoveredFiles.add(file);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const suiteRules = [];
|
|
372
|
+
const seenSelectors = new Set();
|
|
373
|
+
for (const rule of value.suites || []) {
|
|
374
|
+
if (!rule || typeof rule !== "object") {
|
|
375
|
+
throw new Error(`Service "${name}" execution.suites entries must be objects`);
|
|
376
|
+
}
|
|
377
|
+
const selector = String(rule.selector || "").trim();
|
|
378
|
+
if (!selector) {
|
|
379
|
+
throw new Error(`Service "${name}" execution.suites entries require a selector`);
|
|
380
|
+
}
|
|
381
|
+
const parsed = parseSuiteSelectors([selector]);
|
|
382
|
+
if (parsed.length !== 1) {
|
|
383
|
+
throw new Error(`Service "${name}" execution.suites["${selector}"] is invalid`);
|
|
384
|
+
}
|
|
385
|
+
const parsedSelector = parsed[0];
|
|
386
|
+
if (parsedSelector.kind !== "typed") {
|
|
387
|
+
throw new Error(
|
|
388
|
+
`Service "${name}" execution.suites["${selector}"] must use a typed selector like int:health`
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
if (seenSelectors.has(parsedSelector.raw)) {
|
|
392
|
+
throw new Error(
|
|
393
|
+
`Service "${name}" defines duplicate execution.suites selector "${parsedSelector.raw}"`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
const matched = discoveredSuites.some((suite) =>
|
|
397
|
+
matchesSuiteSelectors(suite.displayType, suite.name, [parsedSelector])
|
|
398
|
+
);
|
|
399
|
+
if (!matched) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`Service "${name}" execution.suites selector "${parsedSelector.raw}" did not match any discovered suite`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
seenSelectors.add(parsedSelector.raw);
|
|
405
|
+
suiteRules.push({
|
|
406
|
+
selector: parsedSelector,
|
|
407
|
+
stackMode: normalizeStackModeValue(
|
|
408
|
+
rule.stackMode,
|
|
409
|
+
`Service "${name}" execution.suites["${parsedSelector.raw}"].stackMode`
|
|
410
|
+
),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const fileRules = [];
|
|
415
|
+
const seenFiles = new Set();
|
|
416
|
+
for (const [index, rule] of (value.files || []).entries()) {
|
|
417
|
+
if (!rule || typeof rule !== "object") {
|
|
418
|
+
throw new Error(`Service "${name}" execution.files entries must be objects`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const filePath = String(rule.path || "").trim();
|
|
422
|
+
if (!filePath) {
|
|
423
|
+
throw new Error(`Service "${name}" execution.files[${index}] requires a path`);
|
|
424
|
+
}
|
|
425
|
+
if (!discoveredFiles.has(filePath)) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`Service "${name}" execution.files["${filePath}"] did not match any discovered test file`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (seenFiles.has(filePath)) {
|
|
431
|
+
throw new Error(`Service "${name}" defines duplicate execution.files path "${filePath}"`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
seenFiles.add(filePath);
|
|
435
|
+
fileRules.push({
|
|
436
|
+
path: filePath,
|
|
437
|
+
stackMode: normalizeStackModeValue(
|
|
438
|
+
rule.stackMode,
|
|
439
|
+
`Service "${name}" execution.files["${filePath}"].stackMode`
|
|
440
|
+
),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
suites: suiteRules,
|
|
446
|
+
files: fileRules,
|
|
447
|
+
fileStackModeByPath: new Map(fileRules.map((rule) => [rule.path, rule.stackMode])),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
344
451
|
function inferEnvFiles(productDir, explicitService, local) {
|
|
345
452
|
if (explicitService.envFile || explicitService.envFiles) {
|
|
346
453
|
const files = [];
|
|
@@ -476,6 +583,17 @@ function normalizeTelemetryConfig(telemetry) {
|
|
|
476
583
|
};
|
|
477
584
|
}
|
|
478
585
|
|
|
586
|
+
function normalizeRepoExecution(execution) {
|
|
587
|
+
if (!execution) {
|
|
588
|
+
return normalizeExecutionConfig({
|
|
589
|
+
workers: 1,
|
|
590
|
+
stackMode: "isolated",
|
|
591
|
+
stackCount: 1,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
return normalizeExecutionConfig(execution);
|
|
595
|
+
}
|
|
596
|
+
|
|
479
597
|
function normalizePath(value) {
|
|
480
598
|
return String(value || "")
|
|
481
599
|
.split(path.sep)
|
|
@@ -17,7 +17,7 @@ export async function runHttpK6Batch(targetConfig, batch, lifecycle) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
console.log(
|
|
20
|
-
`\n── ${targetConfig.
|
|
20
|
+
`\n── ${targetConfig.stackLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
|
|
21
21
|
);
|
|
22
22
|
|
|
23
23
|
return Promise.all(
|
|
@@ -32,7 +32,7 @@ export async function runDalBatch(targetConfig, batch, lifecycle) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
console.log(
|
|
35
|
-
`\n── ${targetConfig.
|
|
35
|
+
`\n── ${targetConfig.stackLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
|
|
36
36
|
);
|
|
37
37
|
|
|
38
38
|
return Promise.all(
|
|
@@ -46,7 +46,7 @@ async function runHttpK6Task(targetConfig, task, baseUrl, lifecycle) {
|
|
|
46
46
|
serviceName: targetConfig.name,
|
|
47
47
|
sourceFile: path.join(targetConfig.productDir, task.file),
|
|
48
48
|
});
|
|
49
|
-
console.log(`·· ${targetConfig.
|
|
49
|
+
console.log(`·· ${targetConfig.stackLabel}:${task.suiteName} → ${task.file}`);
|
|
50
50
|
return runDefaultRuntimeTask(
|
|
51
51
|
targetConfig,
|
|
52
52
|
task,
|
|
@@ -61,7 +61,7 @@ async function runDalTask(targetConfig, task, databaseUrl, lifecycle) {
|
|
|
61
61
|
serviceName: targetConfig.name,
|
|
62
62
|
sourceFile: path.join(targetConfig.productDir, task.file),
|
|
63
63
|
});
|
|
64
|
-
console.log(`·· ${targetConfig.
|
|
64
|
+
console.log(`·· ${targetConfig.stackLabel}:${task.suiteName} → ${task.file}`);
|
|
65
65
|
return runDefaultRuntimeTask(
|
|
66
66
|
targetConfig,
|
|
67
67
|
task,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export const STACK_MODES = new Set(["shared", "pooled", "isolated"]);
|
|
2
|
+
|
|
3
|
+
export function parseWorkersOption(value) {
|
|
4
|
+
return parsePositiveInteger(value, "--workers");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function parseStackCountOption(value) {
|
|
8
|
+
return parsePositiveInteger(value, "--stack-count");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseStackModeOption(value) {
|
|
12
|
+
return normalizeStackModeValue(value, "--stack-mode");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function normalizeStackModeValue(value, label = "execution.stackMode") {
|
|
16
|
+
const normalized = String(value || "").trim();
|
|
17
|
+
if (!STACK_MODES.has(normalized)) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Invalid ${label} value "${value}". Expected one of: shared, pooled, isolated.`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return normalized;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveExecutionConfig({ cli = {}, repo = {} } = {}) {
|
|
26
|
+
return normalizeExecutionConfig({
|
|
27
|
+
workers: cli.workers ?? repo.workers ?? 1,
|
|
28
|
+
stackMode: cli.stackMode ?? repo.stackMode ?? "isolated",
|
|
29
|
+
stackCount: cli.stackCount ?? repo.stackCount ?? null,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function normalizeExecutionConfig(input = {}) {
|
|
34
|
+
const workers = normalizePositiveInteger(input.workers, "execution.workers");
|
|
35
|
+
const stackMode = normalizeStackMode(input.stackMode);
|
|
36
|
+
const explicitStackCount =
|
|
37
|
+
input.stackCount == null ? null : normalizePositiveInteger(input.stackCount, "execution.stackCount");
|
|
38
|
+
|
|
39
|
+
if (stackMode === "shared") {
|
|
40
|
+
if (explicitStackCount !== null && explicitStackCount !== 1) {
|
|
41
|
+
throw new Error(`execution.stackCount must be 1 when stackMode is "shared".`);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
workers,
|
|
45
|
+
stackMode,
|
|
46
|
+
stackCount: 1,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (stackMode === "pooled") {
|
|
51
|
+
return {
|
|
52
|
+
workers,
|
|
53
|
+
stackMode,
|
|
54
|
+
stackCount: explicitStackCount ?? 1,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (explicitStackCount !== null && explicitStackCount !== workers) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`execution.stackCount must equal execution.workers when stackMode is "isolated".`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
workers,
|
|
65
|
+
stackMode,
|
|
66
|
+
stackCount: workers,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildStackIds(execution) {
|
|
71
|
+
if (execution.stackMode === "shared") {
|
|
72
|
+
return ["shared"];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return Array.from({ length: execution.stackCount }, (_unused, index) => `stack-${index + 1}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function resolveBatchStackMode(defaultMode, suiteMode = null) {
|
|
79
|
+
if (suiteMode == null) return defaultMode;
|
|
80
|
+
return normalizeStackMode(suiteMode);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function resolveBatchAccessMode({ framework, type, stackMode }) {
|
|
84
|
+
if (stackMode === "isolated") return "exclusive";
|
|
85
|
+
if ((framework || "k6") === "playwright") return "exclusive";
|
|
86
|
+
if (type === "dal") return "exclusive";
|
|
87
|
+
return "shared";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeStackMode(value) {
|
|
91
|
+
return normalizeStackModeValue(value, "execution.stackMode");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parsePositiveInteger(value, label) {
|
|
95
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
96
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
97
|
+
throw new Error(`Invalid ${label} value "${value}". Expected a positive integer.`);
|
|
98
|
+
}
|
|
99
|
+
return parsed;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizePositiveInteger(value, label) {
|
|
103
|
+
const parsed = Number(value);
|
|
104
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
105
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
106
|
+
}
|
|
107
|
+
return parsed;
|
|
108
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildStackIds,
|
|
4
|
+
normalizeExecutionConfig,
|
|
5
|
+
parseStackCountOption,
|
|
6
|
+
parseStackModeOption,
|
|
7
|
+
parseWorkersOption,
|
|
8
|
+
resolveBatchAccessMode,
|
|
9
|
+
resolveBatchStackMode,
|
|
10
|
+
resolveExecutionConfig,
|
|
11
|
+
} from "./execution-config.mjs";
|
|
12
|
+
|
|
13
|
+
describe("execution-config", () => {
|
|
14
|
+
it("parses worker and stack CLI options", () => {
|
|
15
|
+
expect(parseWorkersOption("8")).toBe(8);
|
|
16
|
+
expect(parseStackCountOption("2")).toBe(2);
|
|
17
|
+
expect(parseStackModeOption("pooled")).toBe("pooled");
|
|
18
|
+
expect(() => parseWorkersOption("0")).toThrow('Invalid --workers value "0"');
|
|
19
|
+
expect(() => parseStackModeOption("legacy")).toThrow('Invalid --stack-mode value "legacy"');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("normalizes shared, pooled, and isolated execution shapes", () => {
|
|
23
|
+
expect(normalizeExecutionConfig({ workers: 8, stackMode: "shared" })).toEqual({
|
|
24
|
+
workers: 8,
|
|
25
|
+
stackMode: "shared",
|
|
26
|
+
stackCount: 1,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(normalizeExecutionConfig({ workers: 8, stackMode: "pooled", stackCount: 3 })).toEqual({
|
|
30
|
+
workers: 8,
|
|
31
|
+
stackMode: "pooled",
|
|
32
|
+
stackCount: 3,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(normalizeExecutionConfig({ workers: 8, stackMode: "isolated" })).toEqual({
|
|
36
|
+
workers: 8,
|
|
37
|
+
stackMode: "isolated",
|
|
38
|
+
stackCount: 8,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("rejects invalid stack-count combinations", () => {
|
|
43
|
+
expect(() =>
|
|
44
|
+
normalizeExecutionConfig({ workers: 8, stackMode: "shared", stackCount: 2 })
|
|
45
|
+
).toThrow('execution.stackCount must be 1 when stackMode is "shared"');
|
|
46
|
+
|
|
47
|
+
expect(() =>
|
|
48
|
+
normalizeExecutionConfig({ workers: 8, stackMode: "isolated", stackCount: 2 })
|
|
49
|
+
).toThrow('execution.stackCount must equal execution.workers when stackMode is "isolated"');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("applies CLI values over repo execution defaults", () => {
|
|
53
|
+
expect(
|
|
54
|
+
resolveExecutionConfig({
|
|
55
|
+
repo: { workers: 3, stackMode: "shared" },
|
|
56
|
+
cli: { workers: 6, stackMode: "pooled", stackCount: 2 },
|
|
57
|
+
})
|
|
58
|
+
).toEqual({
|
|
59
|
+
workers: 6,
|
|
60
|
+
stackMode: "pooled",
|
|
61
|
+
stackCount: 2,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("builds stack ids from the normalized execution shape", () => {
|
|
66
|
+
expect(buildStackIds({ stackMode: "shared", stackCount: 1 })).toEqual(["shared"]);
|
|
67
|
+
expect(buildStackIds({ stackMode: "pooled", stackCount: 2 })).toEqual([
|
|
68
|
+
"stack-1",
|
|
69
|
+
"stack-2",
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("resolves batch stack and access modes", () => {
|
|
74
|
+
expect(resolveBatchStackMode("shared", null)).toBe("shared");
|
|
75
|
+
expect(resolveBatchStackMode("shared", "isolated")).toBe("isolated");
|
|
76
|
+
|
|
77
|
+
expect(
|
|
78
|
+
resolveBatchAccessMode({
|
|
79
|
+
framework: "k6",
|
|
80
|
+
type: "integration",
|
|
81
|
+
stackMode: "shared",
|
|
82
|
+
})
|
|
83
|
+
).toBe("shared");
|
|
84
|
+
|
|
85
|
+
expect(
|
|
86
|
+
resolveBatchAccessMode({
|
|
87
|
+
framework: "playwright",
|
|
88
|
+
type: "e2e",
|
|
89
|
+
stackMode: "pooled",
|
|
90
|
+
})
|
|
91
|
+
).toBe("exclusive");
|
|
92
|
+
|
|
93
|
+
expect(
|
|
94
|
+
resolveBatchAccessMode({
|
|
95
|
+
framework: "k6",
|
|
96
|
+
type: "dal",
|
|
97
|
+
stackMode: "shared",
|
|
98
|
+
})
|
|
99
|
+
).toBe("exclusive");
|
|
100
|
+
});
|
|
101
|
+
});
|
package/lib/runner/lifecycle.mjs
CHANGED
|
@@ -21,7 +21,7 @@ export function createRunLifecycle(productDir) {
|
|
|
21
21
|
interruptReason: null,
|
|
22
22
|
services: [],
|
|
23
23
|
graphDirs: [],
|
|
24
|
-
|
|
24
|
+
stackStateDirs: [],
|
|
25
25
|
runtimeStateDirs: [],
|
|
26
26
|
};
|
|
27
27
|
const signalListeners = [];
|
|
@@ -66,7 +66,7 @@ export function createRunLifecycle(productDir) {
|
|
|
66
66
|
trackGraphContext(context) {
|
|
67
67
|
mutate((draft) => {
|
|
68
68
|
pushUnique(draft.graphDirs, context.graphDir);
|
|
69
|
-
pushUnique(draft.
|
|
69
|
+
pushUnique(draft.stackStateDirs, context.stackStateDir);
|
|
70
70
|
for (const runtimeConfig of context.runtimeConfigs || []) {
|
|
71
71
|
if (runtimeConfig.stateDir) pushUnique(draft.runtimeStateDirs, runtimeConfig.stateDir);
|
|
72
72
|
}
|
|
@@ -78,7 +78,7 @@ export function createRunLifecycle(productDir) {
|
|
|
78
78
|
draft.services = draft.services.filter((service) => service.pid !== child.pid);
|
|
79
79
|
draft.services.push({
|
|
80
80
|
serviceName: config.name,
|
|
81
|
-
|
|
81
|
+
stackLabel: config.stackLabel,
|
|
82
82
|
command: config.testkit.local?.start || null,
|
|
83
83
|
cwd,
|
|
84
84
|
pid: child.pid,
|
|
@@ -195,8 +195,8 @@ export function findPortOwner(productDir, { host, port }) {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
export function formatRunSummary(manifest) {
|
|
198
|
-
const
|
|
199
|
-
return `${manifest.runId} pid=${manifest.pid}${
|
|
198
|
+
const stackLabels = [...new Set((manifest.services || []).map((service) => service.stackLabel).filter(Boolean))];
|
|
199
|
+
return `${manifest.runId} pid=${manifest.pid}${stackLabels.length > 0 ? ` stacks=${stackLabels.join(",")}` : ""}`;
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
export function isPidRunning(pid) {
|
|
@@ -220,8 +220,8 @@ async function cleanupRunManifest(productDir, manifest, { removeRuntimeState = f
|
|
|
220
220
|
await destroyRuntimeDatabase({ productDir, stateDir });
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
for (const
|
|
224
|
-
fs.rmSync(
|
|
223
|
+
for (const stackStateDir of [...new Set(manifest.stackStateDirs || [])].sort((a, b) => b.length - a.length)) {
|
|
224
|
+
fs.rmSync(stackStateDir, { recursive: true, force: true });
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
for (const graphDir of [...new Set(manifest.graphDirs || [])].sort((a, b) => b.length - a.length)) {
|