@elench/testkit 0.1.39 → 0.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +43 -13
  2. package/lib/cli/args.mjs +5 -3
  3. package/lib/cli/args.test.mjs +5 -5
  4. package/lib/cli/index.mjs +9 -15
  5. package/lib/config/index.mjs +72 -24
  6. package/lib/database/index.mjs +19 -7
  7. package/lib/database/naming.mjs +2 -2
  8. package/lib/database/naming.test.mjs +2 -2
  9. package/lib/runner/default-runtime-runner.mjs +63 -43
  10. package/lib/runner/execution-config.mjs +24 -64
  11. package/lib/runner/execution-config.test.mjs +30 -72
  12. package/lib/runner/formatting.mjs +0 -15
  13. package/lib/runner/formatting.test.mjs +0 -18
  14. package/lib/runner/lifecycle.mjs +7 -7
  15. package/lib/runner/orchestrator.mjs +9 -10
  16. package/lib/runner/planning.mjs +42 -136
  17. package/lib/runner/planning.test.mjs +70 -174
  18. package/lib/runner/playwright-config.mjs +8 -2
  19. package/lib/runner/playwright-config.test.mjs +20 -5
  20. package/lib/runner/playwright-runner.mjs +32 -54
  21. package/lib/runner/readiness.mjs +2 -2
  22. package/lib/runner/reporting.mjs +3 -3
  23. package/lib/runner/reporting.test.mjs +4 -5
  24. package/lib/runner/results.mjs +1 -1
  25. package/lib/runner/results.test.mjs +1 -1
  26. package/lib/runner/runtime-contexts.mjs +20 -24
  27. package/lib/runner/runtime-manager.mjs +181 -0
  28. package/lib/runner/runtime-manager.test.mjs +181 -0
  29. package/lib/runner/services.mjs +4 -4
  30. package/lib/runner/state.mjs +1 -2
  31. package/lib/runner/state.test.mjs +2 -4
  32. package/lib/runner/template.mjs +90 -60
  33. package/lib/runner/template.test.mjs +59 -27
  34. package/lib/runner/worker-loop.mjs +29 -32
  35. package/lib/runtime/index.d.ts +11 -0
  36. package/lib/runtime/index.mjs +34 -0
  37. package/lib/setup/index.d.ts +15 -10
  38. package/lib/shared/file-timeout.mjs +107 -0
  39. package/lib/shared/file-timeout.test.mjs +64 -0
  40. package/package.json +1 -1
  41. package/lib/runner/stack-manager.mjs +0 -146
@@ -4,12 +4,11 @@ import {
4
4
  matchesSuiteSelectors,
5
5
  suiteSelectionType,
6
6
  } from "./suite-selection.mjs";
7
- import { resolveBatchAccessMode, resolveBatchStackMode } from "./execution-config.mjs";
8
7
 
9
8
  const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
10
9
 
11
- export function batchNeedsLocalRuntime(batch) {
12
- return batch.tasks.some((task) => task.type !== "dal");
10
+ export function taskNeedsLocalRuntime(task) {
11
+ return task.type !== "dal";
13
12
  }
14
13
 
15
14
  export function resolveRuntimeConfigs(targetConfig, configMap) {
@@ -78,10 +77,6 @@ export function collectSuites(config, typeValues, suiteSelectors, fileNames = []
78
77
  (framework === "playwright"
79
78
  ? Math.max(2, Math.max(1, files.length))
80
79
  : Math.max(1, files.length)),
81
- maxFileConcurrency:
82
- framework === "k6" || framework === "playwright"
83
- ? suite.testkit?.maxFileConcurrency || 1
84
- : 1,
85
80
  totalFileCount: selectedSuiteFiles.length,
86
81
  });
87
82
  orderIndex += 1;
@@ -93,7 +88,7 @@ export function collectSuites(config, typeValues, suiteSelectors, fileNames = []
93
88
 
94
89
  export function applyShard(suites, shard) {
95
90
  if (!shard) return suites;
96
- return suites.filter((unused, index) => index % shard.total === shard.index - 1);
91
+ return suites.filter((_unused, index) => index % shard.total === shard.index - 1);
97
92
  }
98
93
 
99
94
  export function orderedTypes(types) {
@@ -109,12 +104,15 @@ export function orderedTypes(types) {
109
104
 
110
105
  export function buildRuntimeGraphs(servicePlans) {
111
106
  const executed = servicePlans.filter((plan) => !plan.skipped);
112
- const uniqueGraphs = [];
107
+ const graphs = [];
113
108
  const graphByRuntimeKey = new Map();
114
109
 
115
110
  for (const plan of executed) {
116
- if (graphByRuntimeKey.has(plan.runtimeKey)) {
117
- graphByRuntimeKey.get(plan.runtimeKey).exactTargets.push(plan.config.name);
111
+ plan.assignedGraphKey = plan.runtimeKey;
112
+ const existing = graphByRuntimeKey.get(plan.runtimeKey);
113
+ if (existing) {
114
+ existing.targetNames.push(plan.config.name);
115
+ existing.instanceCount = Math.max(existing.instanceCount, plan.config.testkit.runtime.instances);
118
116
  continue;
119
117
  }
120
118
 
@@ -122,48 +120,22 @@ export function buildRuntimeGraphs(servicePlans) {
122
120
  key: plan.runtimeKey,
123
121
  runtimeNames: plan.runtimeNames,
124
122
  runtimeConfigs: plan.runtimeConfigs,
125
- exactTargets: [plan.config.name],
126
- assignedTargets: [],
127
- dirName: null,
128
- rootConfig: null,
123
+ targetNames: [plan.config.name],
124
+ dirName: buildGraphDirName(plan.runtimeNames),
125
+ instanceCount: plan.config.testkit.runtime.instances,
129
126
  };
130
- uniqueGraphs.push(graph);
127
+ graphs.push(graph);
131
128
  graphByRuntimeKey.set(plan.runtimeKey, graph);
132
129
  }
133
130
 
134
- const maximalGraphs = uniqueGraphs.filter(
135
- (graph) =>
136
- !uniqueGraphs.some(
137
- (other) =>
138
- other.key !== graph.key &&
139
- isRuntimeSuperset(other.runtimeNames, graph.runtimeNames)
140
- )
141
- );
142
-
143
- for (const plan of executed) {
144
- const compatible = maximalGraphs.filter((graph) =>
145
- isRuntimeSuperset(graph.runtimeNames, plan.runtimeNames)
146
- );
147
- if (compatible.length === 0) {
148
- throw new Error(`No runtime graph found for service "${plan.config.name}"`);
149
- }
150
-
151
- const assigned = compatible.sort(compareGraphsForAssignment)[0];
152
- plan.assignedGraphKey = assigned.key;
153
- assigned.assignedTargets.push(plan.config.name);
154
- }
131
+ const sortedGraphs = graphs.sort((left, right) => left.dirName.localeCompare(right.dirName));
132
+ const maxInstanceCount = Math.max(1, ...sortedGraphs.map((graph) => graph.instanceCount));
155
133
 
156
- for (const graph of maximalGraphs) {
157
- const rootName = [...graph.exactTargets].sort()[0];
158
- const rootPlan = executed.find((plan) => plan.config.name === rootName);
159
- if (!rootPlan) {
160
- throw new Error(`Missing root plan for graph "${graph.key}"`);
161
- }
162
- graph.rootConfig = rootPlan.config;
163
- graph.dirName = buildGraphDirName(graph.runtimeNames);
164
- }
165
-
166
- return maximalGraphs.sort((a, b) => a.dirName.localeCompare(b.dirName));
134
+ return sortedGraphs.map((graph, index) => ({
135
+ ...graph,
136
+ portNamespaceIndex: index,
137
+ portNamespaceStride: maxInstanceCount,
138
+ }));
167
139
  }
168
140
 
169
141
  export function buildTaskQueue(servicePlans, graphs, timings) {
@@ -181,7 +153,6 @@ export function buildTaskQueue(servicePlans, graphs, timings) {
181
153
 
182
154
  for (const suite of plan.suites) {
183
155
  for (const file of suite.files) {
184
- const stackMode = resolveTaskStackMode(plan.config, plan.execution, suite, file);
185
156
  const timingKey = buildTimingKey(plan.config.name, suite, file);
186
157
  tasks.push({
187
158
  id: nextId,
@@ -192,18 +163,11 @@ export function buildTaskQueue(servicePlans, graphs, timings) {
192
163
  suiteName: suite.name,
193
164
  type: suite.type,
194
165
  framework: suite.framework,
195
- stackMode,
196
- accessMode: resolveBatchAccessMode({
197
- framework: suite.framework,
198
- type: suite.type,
199
- stackMode,
200
- }),
201
166
  orderIndex: suite.orderIndex,
202
167
  file,
168
+ locks: resolveTaskLocks(plan.config, suite, file),
203
169
  timingKey,
204
170
  estimatedDurationMs: estimateTaskDuration(timings, timingKey, suite),
205
- maxBatchSize:
206
- suite.maxFileConcurrency || 1,
207
171
  });
208
172
  nextId += 1;
209
173
  }
@@ -219,7 +183,7 @@ export function buildTaskQueue(servicePlans, graphs, timings) {
219
183
  );
220
184
  }
221
185
 
222
- export function claimNextBatch(queue, preferredGraphKey) {
186
+ export function claimNextTask(queue, preferredGraphKey) {
223
187
  if (queue.length === 0) return null;
224
188
 
225
189
  let index = -1;
@@ -228,59 +192,12 @@ export function claimNextBatch(queue, preferredGraphKey) {
228
192
  }
229
193
  if (index === -1) index = 0;
230
194
 
231
- const seed = queue.splice(index, 1)[0];
232
- const tasks = [seed];
233
-
234
- if (seed.maxBatchSize > 1) {
235
- for (let cursor = 0; cursor < queue.length; cursor += 1) {
236
- if (tasks.length >= seed.maxBatchSize) break;
237
- const candidate = queue[cursor];
238
- if (
239
- candidate.framework === seed.framework &&
240
- candidate.type === seed.type &&
241
- candidate.graphKey === seed.graphKey &&
242
- candidate.targetName === seed.targetName &&
243
- candidate.suiteKey === seed.suiteKey &&
244
- candidate.stackMode === seed.stackMode &&
245
- candidate.accessMode === seed.accessMode
246
- ) {
247
- tasks.push(candidate);
248
- queue.splice(cursor, 1);
249
- cursor -= 1;
250
- }
251
- }
252
- }
253
-
254
- tasks.sort(
255
- (a, b) =>
256
- a.orderIndex - b.orderIndex ||
257
- a.file.localeCompare(b.file)
258
- );
259
-
260
- return {
261
- graphKey: seed.graphKey,
262
- targetName: seed.targetName,
263
- framework: seed.framework,
264
- type: seed.type,
265
- stackMode: seed.stackMode,
266
- accessMode: seed.accessMode,
267
- tasks,
268
- };
269
- }
270
-
271
- export function isRuntimeSuperset(candidate, target) {
272
- return target.every((name) => candidate.includes(name));
195
+ return queue.splice(index, 1)[0];
273
196
  }
274
197
 
275
- export function compareGraphsForAssignment(left, right) {
276
- if (left.runtimeNames.length !== right.runtimeNames.length) {
277
- return left.runtimeNames.length - right.runtimeNames.length;
278
- }
279
- return left.key.localeCompare(right.key);
280
- }
281
-
282
- function normalizePathSeparators(filePath) {
283
- return String(filePath).split("\\").join("/");
198
+ export function buildGraphDirName(runtimeNames) {
199
+ const slug = runtimeNames.map(slugSegment).join("__");
200
+ return slug.length > 0 ? slug : "graph";
284
201
  }
285
202
 
286
203
  function applySkipRules(config, displayType, suiteName, files, opts = {}) {
@@ -324,38 +241,27 @@ function applySkipRules(config, displayType, suiteName, files, opts = {}) {
324
241
  };
325
242
  }
326
243
 
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
- }
244
+ function resolveTaskLocks(config, suite, file) {
245
+ const locks = new Set();
246
+ const matchedSuiteRules = config.testkit.requirements?.suites || [];
247
+ for (const rule of matchedSuiteRules) {
248
+ if (matchesSuiteSelectors(suite.displayType, suite.name, [rule.selector])) {
249
+ for (const lockName of rule.locks || []) {
250
+ locks.add(lockName);
251
+ }
252
+ }
253
+ }
335
254
 
336
- function resolveTaskStackMode(config, execution, suite, file) {
337
- const effectiveExecution = execution || config.testkit.execution;
338
255
  const normalizedFile = normalizePathSeparators(file);
339
- const fileOverride = config.testkit.serviceExecution?.fileStackModeByPath?.get(normalizedFile);
340
- if (fileOverride) {
341
- return resolveBatchStackMode(effectiveExecution.stackMode, fileOverride);
256
+ for (const lockName of config.testkit.requirements?.fileLocksByPath?.get(normalizedFile) || []) {
257
+ locks.add(lockName);
342
258
  }
343
- return resolveSuiteStackMode(
344
- {
345
- ...config,
346
- testkit: {
347
- ...config.testkit,
348
- execution: effectiveExecution,
349
- },
350
- },
351
- suite.displayType,
352
- suite.name
353
- );
259
+
260
+ return [...locks].sort();
354
261
  }
355
262
 
356
- export function buildGraphDirName(runtimeNames) {
357
- const slug = runtimeNames.map(slugSegment).join("__");
358
- return slug.length > 0 ? slug : "graph";
263
+ function normalizePathSeparators(filePath) {
264
+ return String(filePath).split("\\").join("/");
359
265
  }
360
266
 
361
267
  function slugSegment(value) {
@@ -4,7 +4,7 @@ import {
4
4
  buildGraphDirName,
5
5
  buildRuntimeGraphs,
6
6
  buildTaskQueue,
7
- claimNextBatch,
7
+ claimNextTask,
8
8
  collectSuites,
9
9
  resolveRuntimeConfigs,
10
10
  } from "./planning.mjs";
@@ -14,20 +14,22 @@ function makeConfig(name, extras = {}) {
14
14
  return {
15
15
  name,
16
16
  suites: extras.suites || {},
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,
17
+ testkit: {
18
+ dependsOn: extras.dependsOn || providedTestkit.dependsOn || [],
19
+ execution: providedTestkit.execution || {
20
+ workers: 1,
21
+ fileTimeoutSeconds: 60,
30
22
  },
23
+ runtime: providedTestkit.runtime || {
24
+ instances: 1,
25
+ },
26
+ requirements: providedTestkit.requirements || {
27
+ suites: [],
28
+ files: [],
29
+ fileLocksByPath: new Map(),
30
+ },
31
+ ...providedTestkit,
32
+ },
31
33
  ...extras,
32
34
  };
33
35
  }
@@ -67,17 +69,6 @@ describe("runner-planning", () => {
67
69
  },
68
70
  ],
69
71
  },
70
- testkit: {
71
- dependsOn: [],
72
- execution: {
73
- workers: 1,
74
- stackMode: "shared",
75
- stackCount: 1,
76
- },
77
- serviceExecution: {
78
- suites: [],
79
- },
80
- },
81
72
  });
82
73
 
83
74
  expect(collectSuites(config, ["int"], [], [])[0]).toMatchObject({
@@ -122,15 +113,6 @@ describe("runner-planning", () => {
122
113
  ],
123
114
  },
124
115
  testkit: {
125
- dependsOn: [],
126
- execution: {
127
- workers: 1,
128
- stackMode: "shared",
129
- stackCount: 1,
130
- },
131
- serviceExecution: {
132
- suites: [],
133
- },
134
116
  skip: {
135
117
  fileReasonByPath: new Map([
136
118
  ["__testkit__/billing/a.int.testkit.ts", "Billing is stubbed"],
@@ -153,21 +135,9 @@ describe("runner-planning", () => {
153
135
  totalFileCount: 2,
154
136
  }),
155
137
  ]);
156
-
157
- expect(collectSuites(config, ["int"], [], [], { ignoreSkipRules: true })).toEqual([
158
- expect.objectContaining({
159
- name: "billing",
160
- files: [
161
- "__testkit__/billing/a.int.testkit.ts",
162
- "__testkit__/billing/b.int.testkit.ts",
163
- ],
164
- skippedFiles: [],
165
- totalFileCount: 2,
166
- }),
167
- ]);
168
138
  });
169
139
 
170
- it("applies file-level stack isolation overrides within the same suite", () => {
140
+ it("applies lock requirements to matching suites and files", () => {
171
141
  const api = makeConfig("api", {
172
142
  suites: {
173
143
  integration: [
@@ -180,28 +150,30 @@ describe("runner-planning", () => {
180
150
  ],
181
151
  orderIndex: 0,
182
152
  weight: 2,
183
- maxFileConcurrency: 1,
184
153
  },
185
154
  ],
186
155
  },
187
156
  testkit: {
188
- execution: {
189
- workers: 8,
190
- stackMode: "shared",
191
- stackCount: 1,
157
+ runtime: {
158
+ instances: 3,
192
159
  },
193
- serviceExecution: {
194
- suites: [],
160
+ requirements: {
161
+ suites: [
162
+ {
163
+ selector: { kind: "typed", type: "int", name: "routes", raw: "int:routes" },
164
+ locks: ["suite-lock"],
165
+ },
166
+ ],
195
167
  files: [
196
168
  {
197
169
  path: "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
198
- stackMode: "isolated",
170
+ locks: ["worker-loop"],
199
171
  },
200
172
  ],
201
- fileStackModeByPath: new Map([
173
+ fileLocksByPath: new Map([
202
174
  [
203
175
  "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
204
- "isolated",
176
+ ["worker-loop"],
205
177
  ],
206
178
  ]),
207
179
  },
@@ -239,21 +211,33 @@ describe("runner-planning", () => {
239
211
  expect.arrayContaining([
240
212
  expect.objectContaining({
241
213
  file: "src/api/routes/__testkit__/health.int.testkit.ts",
242
- stackMode: "shared",
243
- accessMode: "shared",
214
+ locks: ["suite-lock"],
244
215
  }),
245
216
  expect.objectContaining({
246
217
  file: "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
247
- stackMode: "isolated",
248
- accessMode: "exclusive",
218
+ locks: ["suite-lock", "worker-loop"],
249
219
  }),
250
220
  ])
251
221
  );
252
222
  });
253
223
 
254
- it("applies shards, builds graphs, queues tasks, and claims batches", () => {
255
- const api = makeConfig("api");
256
- const frontend = makeConfig("frontend");
224
+ it("builds exact runtime graphs and claims single file tasks", () => {
225
+ const api = makeConfig("api", {
226
+ testkit: {
227
+ runtime: {
228
+ instances: 2,
229
+ },
230
+ },
231
+ });
232
+ const frontend = makeConfig("frontend", {
233
+ dependsOn: ["api"],
234
+ testkit: {
235
+ runtime: {
236
+ instances: 1,
237
+ },
238
+ },
239
+ });
240
+
257
241
  const plans = [
258
242
  {
259
243
  config: api,
@@ -269,9 +253,6 @@ describe("runner-planning", () => {
269
253
  files: ["a.js", "b.js"],
270
254
  orderIndex: 0,
271
255
  weight: 2,
272
- maxFileConcurrency: 2,
273
- stackMode: "isolated",
274
- accessMode: "exclusive",
275
256
  },
276
257
  ],
277
258
  },
@@ -286,12 +267,9 @@ describe("runner-planning", () => {
286
267
  name: "auth",
287
268
  type: "e2e",
288
269
  framework: "playwright",
289
- files: ["auth.spec.js", "signup.spec.js"],
270
+ files: ["auth.spec.js"],
290
271
  orderIndex: 0,
291
- weight: 2,
292
- maxFileConcurrency: 1,
293
- stackMode: "isolated",
294
- accessMode: "exclusive",
272
+ weight: 1,
295
273
  },
296
274
  ],
297
275
  },
@@ -300,112 +278,30 @@ describe("runner-planning", () => {
300
278
  expect(applyShard(["a", "b", "c", "d"], { index: 2, total: 2 })).toEqual(["b", "d"]);
301
279
 
302
280
  const graphs = buildRuntimeGraphs(plans);
303
- expect(graphs).toHaveLength(1);
304
- expect(plans[0].assignedGraphKey).toBe("api|frontend");
281
+ expect(graphs).toEqual([
282
+ expect.objectContaining({
283
+ key: "api",
284
+ instanceCount: 2,
285
+ targetNames: ["api"],
286
+ }),
287
+ expect.objectContaining({
288
+ key: "api|frontend",
289
+ instanceCount: 1,
290
+ targetNames: ["frontend"],
291
+ }),
292
+ ]);
305
293
  expect(buildGraphDirName(["api", "frontend"])).toBe("api__frontend");
306
294
 
307
295
  const queue = buildTaskQueue(plans, graphs, { files: {} });
308
- expect(queue).toHaveLength(4);
309
-
310
- const firstBatch = claimNextBatch(queue, "api|frontend");
311
- expect(firstBatch.tasks).toHaveLength(1);
312
- expect(firstBatch.framework).toBe("playwright");
313
- expect(firstBatch.accessMode).toBe("exclusive");
314
-
315
- const secondBatch = claimNextBatch(queue, "api|frontend");
316
- expect(secondBatch.tasks).toHaveLength(1);
317
- expect(secondBatch.framework).toBe("playwright");
318
-
319
- const thirdBatch = claimNextBatch(queue, "api|frontend");
320
- expect(thirdBatch.tasks).toHaveLength(2);
321
- expect(thirdBatch.framework).toBe("k6");
322
- });
323
-
324
- it("allows Playwright suites to opt into bounded multi-file batches", () => {
325
- const frontend = makeConfig("frontend");
326
- const plans = [
327
- {
328
- config: frontend,
329
- skipped: false,
330
- runtimeConfigs: [frontend],
331
- runtimeNames: ["frontend"],
332
- runtimeKey: "frontend",
333
- suites: [
334
- {
335
- name: "auth",
336
- type: "e2e",
337
- framework: "playwright",
338
- files: ["a.spec.js", "b.spec.js", "c.spec.js"],
339
- orderIndex: 0,
340
- weight: 3,
341
- maxFileConcurrency: 2,
342
- stackMode: "isolated",
343
- accessMode: "exclusive",
344
- },
345
- ],
346
- },
347
- ];
348
-
349
- const graphs = buildRuntimeGraphs(plans);
350
- const queue = buildTaskQueue(plans, graphs, { files: {} });
351
-
352
- const firstBatch = claimNextBatch(queue, "frontend");
353
- expect(firstBatch.tasks.map((task) => task.file)).toEqual(["a.spec.js", "b.spec.js"]);
354
- expect(firstBatch.tasks).toHaveLength(2);
355
- expect(firstBatch.framework).toBe("playwright");
356
-
357
- const secondBatch = claimNextBatch(queue, "frontend");
358
- expect(secondBatch.tasks).toHaveLength(1);
359
- expect(secondBatch.framework).toBe("playwright");
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
- },
296
+ expect(queue).toHaveLength(3);
297
+ expect(claimNextTask(queue, "api|frontend")).toMatchObject({
298
+ framework: "playwright",
299
+ graphKey: "api|frontend",
300
+ file: "auth.spec.js",
378
301
  });
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",
302
+ expect(claimNextTask(queue, "api")).toMatchObject({
303
+ framework: "k6",
304
+ graphKey: "api",
409
305
  });
410
306
  });
411
307
  });
@@ -3,8 +3,14 @@ import path from "path";
3
3
  import { pathToFileURL } from "url";
4
4
  import { normalizePathSeparators } from "./state.mjs";
5
5
 
6
- export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles) {
7
- const stateDir = targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit");
6
+ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, lease) {
7
+ if (!lease?.leaseDir) {
8
+ throw new Error(
9
+ `Playwright task for service "${targetConfig.name}" requires a lease-scoped directory`
10
+ );
11
+ }
12
+
13
+ const stateDir = lease.leaseDir;
8
14
  const outputDir = resolvePlaywrightOutputDir(stateDir);
9
15
  fs.mkdirSync(stateDir, { recursive: true });
10
16
  fs.mkdirSync(outputDir, { recursive: true });
@@ -24,9 +24,9 @@ function makeTempDir(prefix) {
24
24
  }
25
25
 
26
26
  describe("runner-playwright-config", () => {
27
- it("uses a shard-local output directory under the state dir", async () => {
27
+ it("uses a lease-local output directory under the lease dir", async () => {
28
28
  const productDir = makeTempDir("testkit-playwright-product-");
29
- const stateDir = path.join(productDir, ".testkit", "stack-3");
29
+ const leaseDir = path.join(productDir, ".testkit", "leases", "lease-12");
30
30
  const cwd = path.join(productDir, "frontend");
31
31
  fs.mkdirSync(cwd, { recursive: true });
32
32
  fs.writeFileSync(
@@ -35,13 +35,14 @@ describe("runner-playwright-config", () => {
35
35
  );
36
36
 
37
37
  const configPath = ensurePlaywrightTestConfig(
38
- { productDir, stateDir },
38
+ { name: "frontend", productDir, stateDir: path.join(productDir, ".testkit", "frontend") },
39
39
  cwd,
40
- ["frontend/__testkit__/homepage/homepage.pw.testkit.ts"]
40
+ ["frontend/__testkit__/homepage/homepage.pw.testkit.ts"],
41
+ { leaseDir }
41
42
  );
42
43
  const generated = await import(pathToFileURL(configPath).href + `?t=${Date.now()}`);
43
44
 
44
- const expectedOutputDir = resolvePlaywrightOutputDir(stateDir);
45
+ const expectedOutputDir = resolvePlaywrightOutputDir(leaseDir);
45
46
  expect(generated.default.outputDir).toBe(expectedOutputDir);
46
47
  expect(fs.existsSync(expectedOutputDir)).toBe(true);
47
48
  expect(fs.readFileSync(configPath, "utf8")).toContain(
@@ -49,6 +50,20 @@ describe("runner-playwright-config", () => {
49
50
  );
50
51
  });
51
52
 
53
+ it("requires a lease-scoped directory", () => {
54
+ const productDir = makeTempDir("testkit-playwright-product-");
55
+ const cwd = path.join(productDir, "frontend");
56
+ fs.mkdirSync(cwd, { recursive: true });
57
+
58
+ expect(() =>
59
+ ensurePlaywrightTestConfig(
60
+ { name: "frontend", productDir, stateDir: path.join(productDir, ".testkit", "frontend") },
61
+ cwd,
62
+ ["frontend/__testkit__/homepage/homepage.pw.testkit.ts"]
63
+ )
64
+ ).toThrow('Playwright task for service "frontend" requires a lease-scoped directory');
65
+ });
66
+
52
67
  it("finds a supported playwright config file", () => {
53
68
  const cwd = makeTempDir("testkit-playwright-cwd-");
54
69
  fs.writeFileSync(path.join(cwd, "playwright.config.ts"), "export default {};\n");