@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.
@@ -35,6 +35,8 @@ import {
35
35
  safeHostname,
36
36
  safeUsername,
37
37
  } from "./metadata.mjs";
38
+ import { resolveExecutionConfig } from "./execution-config.mjs";
39
+ import { createStackManager } from "./stack-manager.mjs";
38
40
  import { createWorker, runWorker } from "./worker-loop.mjs";
39
41
  import { findUnmatchedRequestedFiles, isFullRunSelection } from "./selection.mjs";
40
42
  import { uploadTelemetryArtifact } from "../telemetry/index.mjs";
@@ -88,10 +90,22 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
88
90
  );
89
91
  }
90
92
 
91
- const servicePlans = collectServicePlans(configs, configMap, typeValues, suiteSelectors, opts);
93
+ const execution = resolveExecutionConfig({
94
+ cli: opts,
95
+ repo: configs[0]?.testkit?.execution || null,
96
+ });
97
+ const servicePlans = collectServicePlans(
98
+ configs,
99
+ configMap,
100
+ typeValues,
101
+ suiteSelectors,
102
+ opts,
103
+ execution
104
+ );
92
105
  const trackers = buildServiceTrackers(servicePlans, startedAt);
93
106
  const executedPlans = servicePlans.filter((plan) => !plan.skipped);
94
107
  let workerCount = 0;
108
+ let stackCount = 0;
95
109
  let exitCode = 0;
96
110
  const lifecycle = createRunLifecycle(productDir);
97
111
  lifecycle.markRunning();
@@ -104,36 +118,46 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
104
118
  const timings = loadTimings(productDir);
105
119
  const graphs = buildRuntimeGraphs(executedPlans);
106
120
  const queue = buildTaskQueue(executedPlans, graphs, timings);
107
- workerCount = Math.max(1, Math.min(opts.jobs || 1, queue.length));
108
- const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
121
+ workerCount = Math.max(1, Math.min(execution.workers, queue.length));
122
+ stackCount = execution.stackMode === "shared" ? 1 : execution.stackCount;
109
123
  const workers = Array.from({ length: workerCount }, (_unused, index) =>
110
124
  createWorker(index + 1, productDir)
111
125
  );
126
+ const stackManager = createStackManager({
127
+ productDir,
128
+ graphs,
129
+ execution,
130
+ lifecycle,
131
+ });
112
132
  const timingUpdates = [];
113
133
 
114
- const workerResults = await Promise.allSettled(
115
- workers.map((worker) =>
116
- runWorker(
117
- worker,
118
- queue,
119
- graphByKey,
120
- trackers,
121
- timingUpdates,
122
- lifecycle,
123
- claimNextBatch,
124
- recordTaskOutcome,
125
- recordGraphError
134
+ try {
135
+ const workerResults = await Promise.allSettled(
136
+ workers.map((worker) =>
137
+ runWorker(
138
+ worker,
139
+ queue,
140
+ stackManager,
141
+ trackers,
142
+ timingUpdates,
143
+ lifecycle,
144
+ claimNextBatch,
145
+ recordTaskOutcome,
146
+ recordGraphError
147
+ )
126
148
  )
127
- )
128
- );
149
+ );
129
150
 
130
- for (const result of workerResults) {
131
- if (result.status === "rejected") {
132
- const message = formatError(result.reason);
133
- for (const tracker of trackers.values()) {
134
- if (!tracker.skipped) addTrackerError(tracker, message);
151
+ for (const result of workerResults) {
152
+ if (result.status === "rejected") {
153
+ const message = formatError(result.reason);
154
+ for (const tracker of trackers.values()) {
155
+ if (!tracker.skipped) addTrackerError(tracker, message);
156
+ }
135
157
  }
136
158
  }
159
+ } finally {
160
+ await stackManager.cleanupAll();
137
161
  }
138
162
 
139
163
  saveTimings(productDir, timings, timingUpdates);
@@ -148,8 +172,9 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
148
172
  results,
149
173
  startedAt,
150
174
  finishedAt,
151
- requestedJobs: opts.jobs || 1,
175
+ execution,
152
176
  workerCount,
177
+ stackCount,
153
178
  typeValues,
154
179
  suiteSelectors,
155
180
  fileNames: requestedFiles,
@@ -192,7 +217,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
192
217
  }
193
218
  }
194
219
 
195
- function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opts) {
220
+ function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opts, execution) {
196
221
  return configs.map((config) => {
197
222
  console.log(`\n══ ${config.name} ══`);
198
223
  const suites = applyShard(
@@ -206,6 +231,7 @@ function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opt
206
231
  );
207
232
  return {
208
233
  config,
234
+ execution,
209
235
  skipped: true,
210
236
  suites: [],
211
237
  runtimeConfigs: [],
@@ -217,6 +243,7 @@ function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opt
217
243
  const runtimeConfigs = resolveRuntimeConfigs(config, configMap);
218
244
  return {
219
245
  config,
246
+ execution,
220
247
  skipped: false,
221
248
  suites,
222
249
  runtimeConfigs,
@@ -4,6 +4,7 @@ import {
4
4
  matchesSuiteSelectors,
5
5
  suiteSelectionType,
6
6
  } from "./suite-selection.mjs";
7
+ import { resolveBatchAccessMode, resolveBatchStackMode } from "./execution-config.mjs";
7
8
 
8
9
  const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
9
10
 
@@ -63,7 +64,6 @@ export function collectSuites(config, typeValues, suiteSelectors, fileNames = []
63
64
  selectedSuiteFiles,
64
65
  opts
65
66
  );
66
-
67
67
  suites.push({
68
68
  ...suite,
69
69
  files,
@@ -181,6 +181,7 @@ export function buildTaskQueue(servicePlans, graphs, timings) {
181
181
 
182
182
  for (const suite of plan.suites) {
183
183
  for (const file of suite.files) {
184
+ const stackMode = resolveTaskStackMode(plan.config, plan.execution, suite, file);
184
185
  const timingKey = buildTimingKey(plan.config.name, suite, file);
185
186
  tasks.push({
186
187
  id: nextId,
@@ -191,6 +192,12 @@ export function buildTaskQueue(servicePlans, graphs, timings) {
191
192
  suiteName: suite.name,
192
193
  type: suite.type,
193
194
  framework: suite.framework,
195
+ stackMode,
196
+ accessMode: resolveBatchAccessMode({
197
+ framework: suite.framework,
198
+ type: suite.type,
199
+ stackMode,
200
+ }),
194
201
  orderIndex: suite.orderIndex,
195
202
  file,
196
203
  timingKey,
@@ -233,7 +240,9 @@ export function claimNextBatch(queue, preferredGraphKey) {
233
240
  candidate.type === seed.type &&
234
241
  candidate.graphKey === seed.graphKey &&
235
242
  candidate.targetName === seed.targetName &&
236
- candidate.suiteKey === seed.suiteKey
243
+ candidate.suiteKey === seed.suiteKey &&
244
+ candidate.stackMode === seed.stackMode &&
245
+ candidate.accessMode === seed.accessMode
237
246
  ) {
238
247
  tasks.push(candidate);
239
248
  queue.splice(cursor, 1);
@@ -253,6 +262,8 @@ export function claimNextBatch(queue, preferredGraphKey) {
253
262
  targetName: seed.targetName,
254
263
  framework: seed.framework,
255
264
  type: seed.type,
265
+ stackMode: seed.stackMode,
266
+ accessMode: seed.accessMode,
256
267
  tasks,
257
268
  };
258
269
  }
@@ -313,6 +324,35 @@ function applySkipRules(config, displayType, suiteName, files, opts = {}) {
313
324
  };
314
325
  }
315
326
 
327
+ function resolveSuiteStackMode(config, displayType, suiteName) {
328
+ const defaultStackMode = config.testkit.execution.stackMode;
329
+ const rules = config.testkit.serviceExecution?.suites || [];
330
+ const matchedRule = rules.find((rule) =>
331
+ matchesSuiteSelectors(displayType, suiteName, [rule.selector])
332
+ );
333
+ return resolveBatchStackMode(defaultStackMode, matchedRule?.stackMode || null);
334
+ }
335
+
336
+ function resolveTaskStackMode(config, execution, suite, file) {
337
+ const effectiveExecution = execution || config.testkit.execution;
338
+ const normalizedFile = normalizePathSeparators(file);
339
+ const fileOverride = config.testkit.serviceExecution?.fileStackModeByPath?.get(normalizedFile);
340
+ if (fileOverride) {
341
+ return resolveBatchStackMode(effectiveExecution.stackMode, fileOverride);
342
+ }
343
+ return resolveSuiteStackMode(
344
+ {
345
+ ...config,
346
+ testkit: {
347
+ ...config.testkit,
348
+ execution: effectiveExecution,
349
+ },
350
+ },
351
+ suite.displayType,
352
+ suite.name
353
+ );
354
+ }
355
+
316
356
  export function buildGraphDirName(runtimeNames) {
317
357
  const slug = runtimeNames.map(slugSegment).join("__");
318
358
  return slug.length > 0 ? slug : "graph";
@@ -10,12 +10,24 @@ import {
10
10
  } from "./planning.mjs";
11
11
 
12
12
  function makeConfig(name, extras = {}) {
13
+ const providedTestkit = extras.testkit || {};
13
14
  return {
14
15
  name,
15
16
  suites: extras.suites || {},
16
- testkit: {
17
- dependsOn: extras.dependsOn || [],
18
- },
17
+ testkit: {
18
+ dependsOn: extras.dependsOn || providedTestkit.dependsOn || [],
19
+ execution: providedTestkit.execution || {
20
+ workers: 1,
21
+ stackMode: "isolated",
22
+ stackCount: 1,
23
+ },
24
+ serviceExecution: providedTestkit.serviceExecution || {
25
+ suites: [],
26
+ files: [],
27
+ fileStackModeByPath: new Map(),
28
+ },
29
+ ...providedTestkit,
30
+ },
19
31
  ...extras,
20
32
  };
21
33
  }
@@ -55,6 +67,17 @@ describe("runner-planning", () => {
55
67
  },
56
68
  ],
57
69
  },
70
+ testkit: {
71
+ dependsOn: [],
72
+ execution: {
73
+ workers: 1,
74
+ stackMode: "shared",
75
+ stackCount: 1,
76
+ },
77
+ serviceExecution: {
78
+ suites: [],
79
+ },
80
+ },
58
81
  });
59
82
 
60
83
  expect(collectSuites(config, ["int"], [], [])[0]).toMatchObject({
@@ -100,6 +123,14 @@ describe("runner-planning", () => {
100
123
  },
101
124
  testkit: {
102
125
  dependsOn: [],
126
+ execution: {
127
+ workers: 1,
128
+ stackMode: "shared",
129
+ stackCount: 1,
130
+ },
131
+ serviceExecution: {
132
+ suites: [],
133
+ },
103
134
  skip: {
104
135
  fileReasonByPath: new Map([
105
136
  ["__testkit__/billing/a.int.testkit.ts", "Billing is stubbed"],
@@ -136,6 +167,90 @@ describe("runner-planning", () => {
136
167
  ]);
137
168
  });
138
169
 
170
+ it("applies file-level stack isolation overrides within the same suite", () => {
171
+ const api = makeConfig("api", {
172
+ suites: {
173
+ integration: [
174
+ {
175
+ name: "routes",
176
+ framework: "k6",
177
+ files: [
178
+ "src/api/routes/__testkit__/health.int.testkit.ts",
179
+ "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
180
+ ],
181
+ orderIndex: 0,
182
+ weight: 2,
183
+ maxFileConcurrency: 1,
184
+ },
185
+ ],
186
+ },
187
+ testkit: {
188
+ execution: {
189
+ workers: 8,
190
+ stackMode: "shared",
191
+ stackCount: 1,
192
+ },
193
+ serviceExecution: {
194
+ suites: [],
195
+ files: [
196
+ {
197
+ path: "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
198
+ stackMode: "isolated",
199
+ },
200
+ ],
201
+ fileStackModeByPath: new Map([
202
+ [
203
+ "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
204
+ "isolated",
205
+ ],
206
+ ]),
207
+ },
208
+ },
209
+ });
210
+
211
+ const graphs = buildRuntimeGraphs([
212
+ {
213
+ config: api,
214
+ skipped: false,
215
+ runtimeConfigs: [api],
216
+ runtimeNames: ["api"],
217
+ runtimeKey: "api",
218
+ suites: collectSuites(api, ["int"], [], []),
219
+ },
220
+ ]);
221
+
222
+ const queue = buildTaskQueue(
223
+ [
224
+ {
225
+ config: api,
226
+ skipped: false,
227
+ runtimeConfigs: [api],
228
+ runtimeNames: ["api"],
229
+ runtimeKey: "api",
230
+ assignedGraphKey: "api",
231
+ suites: collectSuites(api, ["int"], [], []),
232
+ },
233
+ ],
234
+ graphs,
235
+ { files: {} }
236
+ );
237
+
238
+ expect(queue).toEqual(
239
+ expect.arrayContaining([
240
+ expect.objectContaining({
241
+ file: "src/api/routes/__testkit__/health.int.testkit.ts",
242
+ stackMode: "shared",
243
+ accessMode: "shared",
244
+ }),
245
+ expect.objectContaining({
246
+ file: "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
247
+ stackMode: "isolated",
248
+ accessMode: "exclusive",
249
+ }),
250
+ ])
251
+ );
252
+ });
253
+
139
254
  it("applies shards, builds graphs, queues tasks, and claims batches", () => {
140
255
  const api = makeConfig("api");
141
256
  const frontend = makeConfig("frontend");
@@ -155,6 +270,8 @@ describe("runner-planning", () => {
155
270
  orderIndex: 0,
156
271
  weight: 2,
157
272
  maxFileConcurrency: 2,
273
+ stackMode: "isolated",
274
+ accessMode: "exclusive",
158
275
  },
159
276
  ],
160
277
  },
@@ -173,6 +290,8 @@ describe("runner-planning", () => {
173
290
  orderIndex: 0,
174
291
  weight: 2,
175
292
  maxFileConcurrency: 1,
293
+ stackMode: "isolated",
294
+ accessMode: "exclusive",
176
295
  },
177
296
  ],
178
297
  },
@@ -191,6 +310,7 @@ describe("runner-planning", () => {
191
310
  const firstBatch = claimNextBatch(queue, "api|frontend");
192
311
  expect(firstBatch.tasks).toHaveLength(1);
193
312
  expect(firstBatch.framework).toBe("playwright");
313
+ expect(firstBatch.accessMode).toBe("exclusive");
194
314
 
195
315
  const secondBatch = claimNextBatch(queue, "api|frontend");
196
316
  expect(secondBatch.tasks).toHaveLength(1);
@@ -219,6 +339,8 @@ describe("runner-planning", () => {
219
339
  orderIndex: 0,
220
340
  weight: 3,
221
341
  maxFileConcurrency: 2,
342
+ stackMode: "isolated",
343
+ accessMode: "exclusive",
222
344
  },
223
345
  ],
224
346
  },
@@ -236,4 +358,54 @@ describe("runner-planning", () => {
236
358
  expect(secondBatch.tasks).toHaveLength(1);
237
359
  expect(secondBatch.framework).toBe("playwright");
238
360
  });
361
+
362
+ it("applies typed suite execution rules from config", () => {
363
+ const config = makeConfig("api", {
364
+ suites: {
365
+ integration: [{ name: "health", files: ["__testkit__/health/health.int.testkit.ts"] }],
366
+ },
367
+ testkit: {
368
+ dependsOn: [],
369
+ execution: {
370
+ workers: 8,
371
+ stackMode: "shared",
372
+ stackCount: 1,
373
+ },
374
+ serviceExecution: {
375
+ suites: [{ selector: { kind: "typed", type: "int", name: "health", raw: "int:health" }, stackMode: "isolated" }],
376
+ },
377
+ },
378
+ });
379
+
380
+ const graphs = buildRuntimeGraphs([
381
+ {
382
+ config,
383
+ skipped: false,
384
+ runtimeConfigs: [config],
385
+ runtimeNames: ["api"],
386
+ runtimeKey: "api",
387
+ suites: collectSuites(config, ["int"], [], []),
388
+ },
389
+ ]);
390
+ const queue = buildTaskQueue(
391
+ [
392
+ {
393
+ config,
394
+ skipped: false,
395
+ runtimeConfigs: [config],
396
+ runtimeNames: ["api"],
397
+ runtimeKey: "api",
398
+ assignedGraphKey: "api",
399
+ suites: collectSuites(config, ["int"], [], []),
400
+ },
401
+ ],
402
+ graphs,
403
+ { files: {} }
404
+ );
405
+
406
+ expect(queue[0]).toMatchObject({
407
+ stackMode: "isolated",
408
+ accessMode: "exclusive",
409
+ });
410
+ });
239
411
  });
@@ -26,7 +26,7 @@ function makeTempDir(prefix) {
26
26
  describe("runner-playwright-config", () => {
27
27
  it("uses a shard-local output directory under the state dir", async () => {
28
28
  const productDir = makeTempDir("testkit-playwright-product-");
29
- const stateDir = path.join(productDir, ".testkit", "worker-3");
29
+ const stateDir = path.join(productDir, ".testkit", "stack-3");
30
30
  const cwd = path.join(productDir, "frontend");
31
31
  fs.mkdirSync(cwd, { recursive: true });
32
32
  fs.writeFileSync(
@@ -19,7 +19,7 @@ export async function runPlaywrightBatch(targetConfig, batch, lifecycle) {
19
19
  }
20
20
 
21
21
  console.log(
22
- `\n── ${targetConfig.workerLabel} playwright:${targetConfig.name} (${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"})${formatPlaywrightBatchFiles(batch)} ──`
22
+ `\n── ${targetConfig.stackLabel} playwright:${targetConfig.name} (${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"})${formatPlaywrightBatchFiles(batch)} ──`
23
23
  );
24
24
 
25
25
  const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
@@ -41,7 +41,7 @@ export async function runPlaywrightBatch(targetConfig, batch, lifecycle) {
41
41
  );
42
42
 
43
43
  if (result.stderr) {
44
- printBufferedOutput(result.stderr, `[${targetConfig.workerLabel}:${targetConfig.name}:playwright]`);
44
+ printBufferedOutput(result.stderr, `[${targetConfig.stackLabel}:${targetConfig.name}:playwright]`);
45
45
  }
46
46
 
47
47
  const parsed = parsePlaywrightJsonResults(result.stdout, cwd);
@@ -54,11 +54,11 @@ export async function assertLocalServicePortsAvailable(config, isPortInUse) {
54
54
  const owner = findPortOwner(config.productDir, socket);
55
55
  const ownerDetail = owner
56
56
  ? owner.active
57
- ? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.workerLabel}:${owner.service.serviceName}.`
57
+ ? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.stackLabel}:${owner.service.serviceName}.`
58
58
  : ` Stale testkit run ${formatRunSummary(owner.manifest)} owns ${key}.`
59
59
  : "";
60
60
  throw new Error(
61
- `Cannot start "${config.workerLabel}:${config.name}" because ${key} is already in use. ` +
61
+ `Cannot start "${config.stackLabel}:${config.name}" because ${key} is already in use. ` +
62
62
  `Stop the existing process and rerun testkit.${ownerDetail}`
63
63
  );
64
64
  }
@@ -94,8 +94,9 @@ export function buildRunArtifact({
94
94
  results,
95
95
  startedAt,
96
96
  finishedAt,
97
- requestedJobs,
97
+ execution,
98
98
  workerCount,
99
+ stackCount,
99
100
  typeValues,
100
101
  suiteSelectors,
101
102
  fileNames,
@@ -133,8 +134,10 @@ export function buildRunArtifact({
133
134
  startedAt: new Date(startedAt).toISOString(),
134
135
  finishedAt: new Date(finishedAt).toISOString(),
135
136
  durationMs: finishedAt - startedAt,
136
- requestedJobs,
137
+ workers: execution.workers,
137
138
  workerCount,
139
+ stackMode: execution.stackMode,
140
+ stackCount,
138
141
  dbBackend,
139
142
  types: typeValues,
140
143
  suiteSelectors: suiteSelectors.map((selector) => selector.raw),
@@ -51,8 +51,13 @@ describe("runner reporting", () => {
51
51
  results,
52
52
  startedAt: 1000,
53
53
  finishedAt: 4000,
54
- requestedJobs: 2,
54
+ execution: {
55
+ workers: 2,
56
+ stackMode: "shared",
57
+ stackCount: 1,
58
+ },
55
59
  workerCount: 1,
60
+ stackCount: 1,
56
61
  typeValues: ["all"],
57
62
  suiteSelectors: [],
58
63
  fileNames: [],
@@ -75,6 +80,12 @@ describe("runner reporting", () => {
75
80
 
76
81
  expect(artifact.product.name).toBe("my-product");
77
82
  expect(artifact.schemaVersion).toBe(3);
83
+ expect(artifact.run).toMatchObject({
84
+ workers: 2,
85
+ workerCount: 1,
86
+ stackMode: "shared",
87
+ stackCount: 1,
88
+ });
78
89
  expect(artifact.summary.services).toEqual({
79
90
  total: 1,
80
91
  passed: 1,