@elench/testkit 0.1.37 → 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 +126 -2
- 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/state-io.mjs +23 -0
- package/lib/runner/template.mjs +107 -39
- package/lib/runner/template.test.mjs +68 -41
- package/lib/runner/worker-loop.mjs +17 -14
- package/lib/setup/index.d.ts +25 -1
- 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,13 +98,17 @@ function normalizeServiceConfig({
|
|
|
95
98
|
productDir,
|
|
96
99
|
setup,
|
|
97
100
|
setupFile,
|
|
101
|
+
execution,
|
|
98
102
|
explicitService,
|
|
99
103
|
discoveredService,
|
|
100
104
|
suites,
|
|
101
105
|
}) {
|
|
102
106
|
const local = normalizeLocalConfig(name, explicitService, discoveredService, productDir);
|
|
103
107
|
const envFiles = inferEnvFiles(productDir, explicitService, local);
|
|
104
|
-
const serviceEnv =
|
|
108
|
+
const serviceEnv = {
|
|
109
|
+
...loadServiceEnv(productDir, envFiles),
|
|
110
|
+
...(explicitService.env || {}),
|
|
111
|
+
};
|
|
105
112
|
const database = normalizeDatabaseConfig(explicitService, name);
|
|
106
113
|
const migrate = normalizeLifecycle(explicitService.migrate);
|
|
107
114
|
const seed = normalizeLifecycle(explicitService.seed);
|
|
@@ -110,6 +117,10 @@ function normalizeServiceConfig({
|
|
|
110
117
|
productDir,
|
|
111
118
|
suites,
|
|
112
119
|
});
|
|
120
|
+
const serviceExecution = normalizeServiceExecution(explicitService.execution, {
|
|
121
|
+
name,
|
|
122
|
+
suites,
|
|
123
|
+
});
|
|
113
124
|
|
|
114
125
|
if (!explicitService.databaseFrom && !database && (migrate || seed)) {
|
|
115
126
|
throw new Error(
|
|
@@ -137,10 +148,12 @@ function normalizeServiceConfig({
|
|
|
137
148
|
telemetry: normalizeTelemetryConfig(setup.telemetry),
|
|
138
149
|
suites,
|
|
139
150
|
testkit: {
|
|
151
|
+
execution,
|
|
140
152
|
dependsOn: explicitService.dependsOn || [],
|
|
141
153
|
database,
|
|
142
154
|
databaseFrom: explicitService.databaseFrom,
|
|
143
155
|
envFiles,
|
|
156
|
+
serviceExecution,
|
|
144
157
|
serviceEnv,
|
|
145
158
|
migrate,
|
|
146
159
|
seed,
|
|
@@ -151,7 +164,10 @@ function normalizeServiceConfig({
|
|
|
151
164
|
}
|
|
152
165
|
|
|
153
166
|
function normalizeLocalConfig(name, explicitService, discoveredService, productDir) {
|
|
154
|
-
if (explicitService
|
|
167
|
+
if (Object.prototype.hasOwnProperty.call(explicitService, "local")) {
|
|
168
|
+
if (explicitService.local === false) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
155
171
|
return {
|
|
156
172
|
...explicitService.local,
|
|
157
173
|
cwd: explicitService.local.cwd || ".",
|
|
@@ -335,6 +351,103 @@ function normalizeSkipConfig(value, { name, productDir, suites }) {
|
|
|
335
351
|
};
|
|
336
352
|
}
|
|
337
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
|
+
|
|
338
451
|
function inferEnvFiles(productDir, explicitService, local) {
|
|
339
452
|
if (explicitService.envFile || explicitService.envFiles) {
|
|
340
453
|
const files = [];
|
|
@@ -470,6 +583,17 @@ function normalizeTelemetryConfig(telemetry) {
|
|
|
470
583
|
};
|
|
471
584
|
}
|
|
472
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
|
+
|
|
473
597
|
function normalizePath(value) {
|
|
474
598
|
return String(value || "")
|
|
475
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)) {
|