@elench/testkit 0.1.40 → 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 (37) hide show
  1. package/README.md +25 -13
  2. package/lib/cli/args.mjs +0 -4
  3. package/lib/cli/args.test.mjs +0 -5
  4. package/lib/cli/index.mjs +0 -9
  5. package/lib/config/index.mjs +67 -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 +31 -53
  10. package/lib/runner/execution-config.mjs +14 -70
  11. package/lib/runner/execution-config.test.mjs +22 -74
  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 +2 -3
  23. package/lib/runner/reporting.test.mjs +2 -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/setup/index.d.ts +14 -10
  36. package/package.json +1 -1
  37. package/lib/runner/stack-manager.mjs +0 -146
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @elench/testkit
2
2
 
3
3
  `@elench/testkit` discovers `*.testkit.ts` files, infers suite ownership from the
4
- filesystem, starts local services, provisions isolated local Postgres databases,
5
- and runs HTTP, DAL, and Playwright suites.
4
+ filesystem, starts local services, provisions Docker-managed local Postgres
5
+ databases, and runs HTTP, DAL, and Playwright suites.
6
6
 
7
7
  The package is now driven by `testkit.setup.ts`, not `testkit.config.json`.
8
8
 
@@ -21,15 +21,12 @@ npx @elench/testkit --type e2e
21
21
  npx @elench/testkit --type int,e2e,dal
22
22
  npx @elench/testkit --type pw
23
23
 
24
- # Shared local stack with parallel workers
25
- npx @elench/testkit --workers 8 --stack-mode shared
24
+ # Parallel file execution
25
+ npx @elench/testkit --workers 8
26
26
 
27
27
  # One file-level wall clock budget for every suite file
28
28
  npx @elench/testkit --file-timeout-seconds 60
29
29
 
30
- # Two reusable local stacks for browser-heavy suites
31
- npx @elench/testkit --workers 6 --stack-mode pooled --stack-count 2
32
-
33
30
  # Run a deterministic shard
34
31
  npx @elench/testkit --shard 1/3
35
32
 
@@ -70,8 +67,6 @@ export default defineTestkitSetup({
70
67
  execution: {
71
68
  workers: 8,
72
69
  fileTimeoutSeconds: 60,
73
- stackMode: "shared",
74
- stackCount: 1,
75
70
  },
76
71
  services: {
77
72
  api: service({
@@ -86,6 +81,17 @@ export default defineTestkitSetup({
86
81
  migrate: lifecycle("npm run db:migrate", {
87
82
  testkitCmd: "npm run db:migrate:testkit",
88
83
  }),
84
+ runtime: {
85
+ instances: 1,
86
+ },
87
+ requirements: {
88
+ files: [
89
+ {
90
+ path: "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
91
+ locks: ["global-worker-loop"],
92
+ },
93
+ ],
94
+ },
89
95
  }),
90
96
  frontend: service({
91
97
  ...nextService({
@@ -97,6 +103,9 @@ export default defineTestkitSetup({
97
103
  }),
98
104
  dependsOn: ["api"],
99
105
  envFiles: ["frontend/.env.testkit"],
106
+ runtime: {
107
+ instances: 1,
108
+ },
100
109
  }),
101
110
  billing: service({
102
111
  skip: {
@@ -121,10 +130,12 @@ export default defineTestkitSetup({
121
130
  `testkit.setup.ts` is optional for simple repos, but it is the primary escape hatch
122
131
  for:
123
132
 
124
- - worker and stack topology
133
+ - worker count and per-file runtime budget
125
134
  - per-file wall clock timeout budget
126
135
  - multi-service graphs
127
- - local DB configuration
136
+ - local runtime instance counts
137
+ - local DB binding configuration
138
+ - explicit per-file or per-suite locks
128
139
  - migrate / seed commands
129
140
  - test-local migrate / seed overrides
130
141
  - named HTTP suite profiles
@@ -198,7 +209,7 @@ Example layouts:
198
209
  - `src/api/routes/__testkit__/auth/me.int.testkit.ts`
199
210
  - `src/db/__testkit__/sessions/count-type.dal.testkit.ts`
200
211
  - `frontend/__testkit__/navigation/navigation.pw.testkit.ts`
201
- - `avocado_api/internal/handler/__testkit__/repos/crud.int.testkit.ts`
212
+ - `src/internal/handler/__testkit__/repos/crud.int.testkit.ts`
202
213
 
203
214
  `testkit` uses these suffixes automatically:
204
215
 
@@ -224,7 +235,8 @@ Suite names are inferred from the colocated path:
224
235
  services that define `database: localDatabase(...)`.
225
236
 
226
237
  - template databases are cached
227
- - stack databases are cloned from templates
238
+ - runtime databases are cloned from templates when binding is `per-runtime`
239
+ - shared databases are reused when binding is `shared`
228
240
  - template fingerprints are derived automatically from env files, migrate/seed
229
241
  config, and repo contents
230
242
 
package/lib/cli/args.mjs CHANGED
@@ -2,8 +2,6 @@ import path from "path";
2
2
  import { normalizeTypeValues, parseSuiteSelectors } from "../runner/suite-selection.mjs";
3
3
  import {
4
4
  parseFileTimeoutOption,
5
- parseStackCountOption,
6
- parseStackModeOption,
7
5
  parseWorkersOption,
8
6
  } from "../runner/execution-config.mjs";
9
7
 
@@ -49,8 +47,6 @@ export function parseSuiteOption(values) {
49
47
  export {
50
48
  parseFileTimeoutOption,
51
49
  parseWorkersOption,
52
- parseStackModeOption,
53
- parseStackCountOption,
54
50
  };
55
51
 
56
52
  export function parseShardOption(value) {
@@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  parseFileTimeoutOption,
4
4
  parseShardOption,
5
- parseStackCountOption,
6
- parseStackModeOption,
7
5
  parseSuiteOption,
8
6
  parseTypeOption,
9
7
  parseWorkersOption,
@@ -61,13 +59,10 @@ describe("cli-args", () => {
61
59
  it("parses and validates execution options", () => {
62
60
  expect(parseWorkersOption("3")).toBe(3);
63
61
  expect(parseFileTimeoutOption("45")).toBe(45);
64
- expect(parseStackCountOption("2")).toBe(2);
65
- expect(parseStackModeOption("pooled")).toBe("pooled");
66
62
  expect(() => parseWorkersOption("0")).toThrow("Invalid --workers value");
67
63
  expect(() => parseFileTimeoutOption("0")).toThrow(
68
64
  "Invalid --file-timeout-seconds value"
69
65
  );
70
- expect(() => parseStackModeOption("legacy")).toThrow("Invalid --stack-mode value");
71
66
  });
72
67
 
73
68
  it("parses and validates shards", () => {
package/lib/cli/index.mjs CHANGED
@@ -3,8 +3,6 @@ import { loadConfigs } from "../config/index.mjs";
3
3
  import {
4
4
  parseFileTimeoutOption,
5
5
  parseShardOption,
6
- parseStackCountOption,
7
- parseStackModeOption,
8
6
  parseSuiteOption,
9
7
  parseTypeOption,
10
8
  parseWorkersOption,
@@ -27,8 +25,6 @@ export function run() {
27
25
  .option("--dir <path>", "Explicit product directory")
28
26
  .option("--workers <n>", "Number of test executors for the whole run")
29
27
  .option("--file-timeout-seconds <n>", "Per-file wall-clock timeout in seconds")
30
- .option("--stack-mode <mode>", "Stack topology: shared, pooled, or isolated")
31
- .option("--stack-count <n>", "Number of prepared stacks when stack-mode=pooled")
32
28
  .option("--shard <i/n>", "Run only shard i of n at suite granularity")
33
29
  .option("--write-status", "Write a deterministic testkit.status.json snapshot")
34
30
  .option("--allow-partial-status", "Allow --write-status for filtered runs")
@@ -71,9 +67,6 @@ export function run() {
71
67
  options.fileTimeoutSeconds == null
72
68
  ? null
73
69
  : parseFileTimeoutOption(options.fileTimeoutSeconds);
74
- const stackMode = options.stackMode == null ? null : parseStackModeOption(options.stackMode);
75
- const stackCount =
76
- options.stackCount == null ? null : parseStackCountOption(options.stackCount);
77
70
  const shard = parseShardOption(options.shard);
78
71
  const typeValues = parseTypeOption(options.type, positionalType);
79
72
  const suiteSelectors = parseSuiteOption(options.suite);
@@ -90,8 +83,6 @@ export function run() {
90
83
  fileNames,
91
84
  workers,
92
85
  fileTimeoutSeconds,
93
- stackMode,
94
- stackCount,
95
86
  shard,
96
87
  serviceFilter: options.service || null,
97
88
  },
@@ -10,8 +10,9 @@ import {
10
10
  } from "../runner/suite-selection.mjs";
11
11
  import {
12
12
  DEFAULT_FILE_TIMEOUT_SECONDS,
13
+ normalizeDatabaseBinding,
13
14
  normalizeExecutionConfig,
14
- normalizeStackModeValue,
15
+ normalizeRuntimeInstances,
15
16
  } from "../runner/execution-config.mjs";
16
17
 
17
18
  const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
@@ -116,12 +117,13 @@ function normalizeServiceConfig({
116
117
  const database = normalizeDatabaseConfig(explicitService, name);
117
118
  const migrate = normalizeLifecycle(explicitService.migrate);
118
119
  const seed = normalizeLifecycle(explicitService.seed);
120
+ const runtime = normalizeRuntimeConfig(explicitService.runtime, name);
119
121
  const skip = normalizeSkipConfig(explicitService.skip, {
120
122
  name,
121
123
  productDir,
122
124
  suites,
123
125
  });
124
- const serviceExecution = normalizeServiceExecution(explicitService.execution, {
126
+ const requirements = normalizeServiceRequirements(explicitService.requirements, {
125
127
  name,
126
128
  suites,
127
129
  });
@@ -137,6 +139,7 @@ function normalizeServiceConfig({
137
139
  local,
138
140
  database,
139
141
  databaseFrom: explicitService.databaseFrom,
142
+ runtime,
140
143
  migrate,
141
144
  seed,
142
145
  dependsOn: explicitService.dependsOn || [],
@@ -157,11 +160,12 @@ function normalizeServiceConfig({
157
160
  database,
158
161
  databaseFrom: explicitService.databaseFrom,
159
162
  envFiles,
160
- serviceExecution,
163
+ requirements,
161
164
  serviceEnv,
162
165
  migrate,
163
166
  seed,
164
167
  skip,
168
+ runtime,
165
169
  local,
166
170
  },
167
171
  };
@@ -240,6 +244,10 @@ function normalizeDatabaseConfig(explicitService, serviceName) {
240
244
 
241
245
  return {
242
246
  ...database,
247
+ binding: normalizeDatabaseBinding(
248
+ database.binding || "per-runtime",
249
+ `Service "${serviceName}" database.binding`
250
+ ),
243
251
  provider: "local",
244
252
  selectedBackend: "local",
245
253
  reset: database.reset !== false,
@@ -253,6 +261,21 @@ function normalizeDatabaseConfig(explicitService, serviceName) {
253
261
  };
254
262
  }
255
263
 
264
+ function normalizeRuntimeConfig(value, serviceName) {
265
+ if (!value) {
266
+ return {
267
+ instances: 1,
268
+ };
269
+ }
270
+
271
+ return {
272
+ instances: normalizeRuntimeInstances(
273
+ value.instances ?? 1,
274
+ `Service "${serviceName}" runtime.instances`
275
+ ),
276
+ };
277
+ }
278
+
256
279
  function normalizeLifecycle(value) {
257
280
  if (!value) return undefined;
258
281
  if (!value.cmd && !value.testkitCmd) {
@@ -355,8 +378,8 @@ function normalizeSkipConfig(value, { name, productDir, suites }) {
355
378
  };
356
379
  }
357
380
 
358
- function normalizeServiceExecution(value, { name, suites }) {
359
- if (!value) return { suites: [], files: [], fileStackModeByPath: new Map() };
381
+ function normalizeServiceRequirements(value, { name, suites }) {
382
+ if (!value) return { suites: [], files: [], fileLocksByPath: new Map() };
360
383
 
361
384
  const discoveredSuites = [];
362
385
  const discoveredFiles = new Set();
@@ -376,25 +399,25 @@ function normalizeServiceExecution(value, { name, suites }) {
376
399
  const seenSelectors = new Set();
377
400
  for (const rule of value.suites || []) {
378
401
  if (!rule || typeof rule !== "object") {
379
- throw new Error(`Service "${name}" execution.suites entries must be objects`);
402
+ throw new Error(`Service "${name}" requirements.suites entries must be objects`);
380
403
  }
381
404
  const selector = String(rule.selector || "").trim();
382
405
  if (!selector) {
383
- throw new Error(`Service "${name}" execution.suites entries require a selector`);
406
+ throw new Error(`Service "${name}" requirements.suites entries require a selector`);
384
407
  }
385
408
  const parsed = parseSuiteSelectors([selector]);
386
409
  if (parsed.length !== 1) {
387
- throw new Error(`Service "${name}" execution.suites["${selector}"] is invalid`);
410
+ throw new Error(`Service "${name}" requirements.suites["${selector}"] is invalid`);
388
411
  }
389
412
  const parsedSelector = parsed[0];
390
413
  if (parsedSelector.kind !== "typed") {
391
414
  throw new Error(
392
- `Service "${name}" execution.suites["${selector}"] must use a typed selector like int:health`
415
+ `Service "${name}" requirements.suites["${selector}"] must use a typed selector like int:health`
393
416
  );
394
417
  }
395
418
  if (seenSelectors.has(parsedSelector.raw)) {
396
419
  throw new Error(
397
- `Service "${name}" defines duplicate execution.suites selector "${parsedSelector.raw}"`
420
+ `Service "${name}" defines duplicate requirements.suites selector "${parsedSelector.raw}"`
398
421
  );
399
422
  }
400
423
  const matched = discoveredSuites.some((suite) =>
@@ -402,15 +425,15 @@ function normalizeServiceExecution(value, { name, suites }) {
402
425
  );
403
426
  if (!matched) {
404
427
  throw new Error(
405
- `Service "${name}" execution.suites selector "${parsedSelector.raw}" did not match any discovered suite`
428
+ `Service "${name}" requirements.suites selector "${parsedSelector.raw}" did not match any discovered suite`
406
429
  );
407
430
  }
408
431
  seenSelectors.add(parsedSelector.raw);
409
432
  suiteRules.push({
410
433
  selector: parsedSelector,
411
- stackMode: normalizeStackModeValue(
412
- rule.stackMode,
413
- `Service "${name}" execution.suites["${parsedSelector.raw}"].stackMode`
434
+ locks: normalizeRequirementLocks(
435
+ rule.locks,
436
+ `Service "${name}" requirements.suites["${parsedSelector.raw}"].locks`
414
437
  ),
415
438
  });
416
439
  }
@@ -419,28 +442,28 @@ function normalizeServiceExecution(value, { name, suites }) {
419
442
  const seenFiles = new Set();
420
443
  for (const [index, rule] of (value.files || []).entries()) {
421
444
  if (!rule || typeof rule !== "object") {
422
- throw new Error(`Service "${name}" execution.files entries must be objects`);
445
+ throw new Error(`Service "${name}" requirements.files entries must be objects`);
423
446
  }
424
447
 
425
448
  const filePath = String(rule.path || "").trim();
426
449
  if (!filePath) {
427
- throw new Error(`Service "${name}" execution.files[${index}] requires a path`);
450
+ throw new Error(`Service "${name}" requirements.files[${index}] requires a path`);
428
451
  }
429
452
  if (!discoveredFiles.has(filePath)) {
430
453
  throw new Error(
431
- `Service "${name}" execution.files["${filePath}"] did not match any discovered test file`
454
+ `Service "${name}" requirements.files["${filePath}"] did not match any discovered test file`
432
455
  );
433
456
  }
434
457
  if (seenFiles.has(filePath)) {
435
- throw new Error(`Service "${name}" defines duplicate execution.files path "${filePath}"`);
458
+ throw new Error(`Service "${name}" defines duplicate requirements.files path "${filePath}"`);
436
459
  }
437
460
 
438
461
  seenFiles.add(filePath);
439
462
  fileRules.push({
440
463
  path: filePath,
441
- stackMode: normalizeStackModeValue(
442
- rule.stackMode,
443
- `Service "${name}" execution.files["${filePath}"].stackMode`
464
+ locks: normalizeRequirementLocks(
465
+ rule.locks,
466
+ `Service "${name}" requirements.files["${filePath}"].locks`
444
467
  ),
445
468
  });
446
469
  }
@@ -448,10 +471,28 @@ function normalizeServiceExecution(value, { name, suites }) {
448
471
  return {
449
472
  suites: suiteRules,
450
473
  files: fileRules,
451
- fileStackModeByPath: new Map(fileRules.map((rule) => [rule.path, rule.stackMode])),
474
+ fileLocksByPath: new Map(fileRules.map((rule) => [rule.path, rule.locks])),
452
475
  };
453
476
  }
454
477
 
478
+ function normalizeRequirementLocks(value, label) {
479
+ const input = Array.isArray(value) ? value : value == null ? [] : [value];
480
+ const seen = new Set();
481
+ const locks = [];
482
+
483
+ for (const rawLock of input) {
484
+ const lockName = String(rawLock || "").trim();
485
+ if (!lockName) {
486
+ throw new Error(`${label} entries must be non-empty strings`);
487
+ }
488
+ if (seen.has(lockName)) continue;
489
+ seen.add(lockName);
490
+ locks.push(lockName);
491
+ }
492
+
493
+ return locks.sort();
494
+ }
495
+
455
496
  function inferEnvFiles(productDir, explicitService, local) {
456
497
  if (explicitService.envFile || explicitService.envFiles) {
457
498
  const files = [];
@@ -512,6 +553,7 @@ function validateServiceConfig({
512
553
  local,
513
554
  database,
514
555
  databaseFrom,
556
+ runtime,
515
557
  migrate,
516
558
  seed,
517
559
  dependsOn,
@@ -533,6 +575,9 @@ function validateServiceConfig({
533
575
  if (database && databaseFrom) {
534
576
  throw new Error(`Service "${name}" cannot define both database and databaseFrom`);
535
577
  }
578
+ if (runtime.instances < 1) {
579
+ throw new Error(`Service "${name}" runtime.instances must be a positive integer`);
580
+ }
536
581
 
537
582
  for (const depName of dependsOn || []) {
538
583
  if (depName === name) {
@@ -592,8 +637,6 @@ function normalizeRepoExecution(execution) {
592
637
  return normalizeExecutionConfig({
593
638
  workers: 1,
594
639
  fileTimeoutSeconds: DEFAULT_FILE_TIMEOUT_SECONDS,
595
- stackMode: "isolated",
596
- stackCount: 1,
597
640
  });
598
641
  }
599
642
  return normalizeExecutionConfig(execution);
@@ -9,8 +9,8 @@ import {
9
9
  import {
10
10
  buildContainerName as buildContainerNameModel,
11
11
  buildDatabaseUrl as buildDatabaseUrlModel,
12
+ buildRuntimeDatabaseName as buildRuntimeDatabaseNameModel,
12
13
  buildTemplateDatabaseName as buildTemplateDatabaseNameModel,
13
- buildWorkerDatabaseName as buildWorkerDatabaseNameModel,
14
14
  escapeIdentifier as escapeIdentifierModel,
15
15
  escapeSqlLiteral as escapeSqlLiteralModel,
16
16
  hashString as hashStringModel,
@@ -119,6 +119,7 @@ async function prepareLocalDatabase(config, hooks) {
119
119
  const db = config.testkit.database;
120
120
  const productDir = config.productDir;
121
121
  const serviceName = config.name;
122
+ const bindingKey = resolveDatabaseBindingKey(config);
122
123
  const lockDir = getLocalLocksDir(productDir);
123
124
  const cacheDir = getLocalServiceCacheDir(productDir, serviceName);
124
125
  fs.mkdirSync(lockDir, { recursive: true });
@@ -133,8 +134,8 @@ async function prepareLocalDatabase(config, hooks) {
133
134
  await ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint, hooks);
134
135
  });
135
136
 
136
- await withLock(path.join(lockDir, `runtime-${serviceName}-${hashString(config.stateDir, 10)}.lock`), async () => {
137
- await ensureWorkerClone(config, infra, cacheDir, templateFingerprint);
137
+ await withLock(path.join(lockDir, `runtime-${serviceName}-${hashString(bindingKey, 10)}.lock`), async () => {
138
+ await ensureRuntimeClone(config, infra, cacheDir, templateFingerprint, bindingKey);
138
139
  });
139
140
  }
140
141
 
@@ -172,14 +173,14 @@ async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerpri
172
173
  writeLocalCacheState(cacheDir, infra, desiredDbName, templateFingerprint);
173
174
  }
174
175
 
175
- async function ensureWorkerClone(config, infra, cacheDir, templateFingerprint) {
176
+ async function ensureRuntimeClone(config, infra, cacheDir, templateFingerprint, bindingKey) {
176
177
  const serviceName = config.name;
177
178
  const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
178
179
  if (!templateDbName) {
179
180
  throw new Error(`Missing template database for service "${serviceName}"`);
180
181
  }
181
182
 
182
- const desiredDbName = buildWorkerDatabaseName(serviceName, config.stateDir, templateFingerprint);
183
+ const desiredDbName = buildRuntimeDatabaseName(serviceName, bindingKey, templateFingerprint);
183
184
  const existingDbName = readStateValue(path.join(config.stateDir, "local_database_name"));
184
185
  const existingFingerprint = readStateValue(path.join(config.stateDir, "local_template_fingerprint"));
185
186
  const needsReset =
@@ -206,6 +207,17 @@ async function ensureWorkerClone(config, infra, cacheDir, templateFingerprint) {
206
207
  fs.writeFileSync(path.join(config.stateDir, "local_container_name"), infra.containerName);
207
208
  }
208
209
 
210
+ function resolveDatabaseBindingKey(config) {
211
+ const binding = config.testkit.database?.binding || "per-runtime";
212
+ if (binding === "shared") {
213
+ return `${config.name}:shared`;
214
+ }
215
+ if (config.runtimeId) {
216
+ return `${config.name}:${config.runtimeId}`;
217
+ }
218
+ return `${config.name}:${config.stateDir}`;
219
+ }
220
+
209
221
  async function destroyLocalRuntimeDatabase(productDir, stateDir) {
210
222
  const dbName = readStateValue(path.join(stateDir, "local_database_name"));
211
223
  if (!dbName) return;
@@ -420,8 +432,8 @@ function buildTemplateDatabaseName(serviceName, fingerprint) {
420
432
  return buildTemplateDatabaseNameModel(serviceName, fingerprint);
421
433
  }
422
434
 
423
- function buildWorkerDatabaseName(serviceName, stateDir, fingerprint) {
424
- return buildWorkerDatabaseNameModel(serviceName, stateDir, fingerprint);
435
+ function buildRuntimeDatabaseName(serviceName, bindingKey, fingerprint) {
436
+ return buildRuntimeDatabaseNameModel(serviceName, bindingKey, fingerprint);
425
437
  }
426
438
 
427
439
  function writeLocalInfraState(infraDir, infra) {
@@ -19,9 +19,9 @@ export function buildTemplateDatabaseName(serviceName, fingerprint) {
19
19
  );
20
20
  }
21
21
 
22
- export function buildWorkerDatabaseName(serviceName, stateDir, fingerprint) {
22
+ export function buildRuntimeDatabaseName(serviceName, bindingKey, fingerprint) {
23
23
  return limitIdentifier(
24
- `tk_${slugSegment(serviceName)}_${hashString(stateDir, 10)}_${fingerprint.slice(0, 12)}`,
24
+ `tk_${slugSegment(serviceName)}_${hashString(bindingKey, 10)}_${fingerprint.slice(0, 12)}`,
25
25
  63
26
26
  );
27
27
  }
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  buildContainerName,
4
4
  buildDatabaseUrl,
5
+ buildRuntimeDatabaseName,
5
6
  buildTemplateDatabaseName,
6
- buildWorkerDatabaseName,
7
7
  escapeIdentifier,
8
8
  escapeSqlLiteral,
9
9
  slugSegment,
@@ -15,7 +15,7 @@ describe("database-naming", () => {
15
15
  expect(buildTemplateDatabaseName("api", "1234567890abcdef1234")).toBe(
16
16
  "tk_tpl_api_1234567890abcdef"
17
17
  );
18
- expect(buildWorkerDatabaseName("api", "/tmp/state", "abcdef1234567890")).toMatch(
18
+ expect(buildRuntimeDatabaseName("api", "/tmp/state", "abcdef1234567890")).toMatch(
19
19
  /^tk_api_[a-f0-9]{10}_abcdef123456$/
20
20
  );
21
21
  expect(
@@ -3,81 +3,62 @@ import path from "path";
3
3
  import { execa } from "execa";
4
4
  import { bundleK6File } from "../bundler/index.mjs";
5
5
  import { resolveK6Binary } from "../config/index.mjs";
6
- import { persistTaskArtifacts } from "./artifacts.mjs";
7
- import { RUNTIME_ARTIFACT_MARKER } from "../runtime-src/k6/artifacts.js";
8
- import { determineDefaultRuntimeFailure } from "./default-runtime-errors.mjs";
9
- import { formatBatchDescriptor } from "./formatting.mjs";
10
- import { buildExecutionEnv } from "./template.mjs";
11
- import { readDatabaseUrl } from "./state-io.mjs";
12
6
  import {
13
7
  buildFileTimeoutEnv,
14
8
  formatFileTimeoutBudgetError,
15
9
  } from "../shared/file-timeout.mjs";
10
+ import { persistTaskArtifacts } from "./artifacts.mjs";
11
+ import { determineDefaultRuntimeFailure } from "./default-runtime-errors.mjs";
12
+ import { RUNTIME_ARTIFACT_MARKER } from "../runtime-src/k6/artifacts.js";
13
+ import { readDatabaseUrl } from "./state-io.mjs";
14
+ import { buildTaskExecutionEnv } from "./template.mjs";
16
15
 
17
- export async function runHttpK6Batch(targetConfig, batch, lifecycle) {
16
+ export async function runHttpK6Task(targetConfig, task, lifecycle, lease) {
18
17
  const baseUrl = targetConfig.testkit.local?.baseUrl;
19
18
  if (!baseUrl) {
20
19
  throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP suites`);
21
20
  }
22
21
 
23
- console.log(
24
- `\n── ${targetConfig.stackLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
25
- );
26
-
27
- return Promise.all(
28
- batch.tasks.map((task) => runHttpK6Task(targetConfig, task, baseUrl, lifecycle))
29
- );
30
- }
31
-
32
- export async function runDalBatch(targetConfig, batch, lifecycle) {
33
- const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
34
- if (!databaseUrl) {
35
- throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
36
- }
37
-
38
- console.log(
39
- `\n── ${targetConfig.stackLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
40
- );
41
-
42
- return Promise.all(
43
- batch.tasks.map((task) => runDalTask(targetConfig, task, databaseUrl, lifecycle))
44
- );
45
- }
46
-
47
- async function runHttpK6Task(targetConfig, task, baseUrl, lifecycle) {
48
22
  const bundledFile = await bundleK6File({
49
23
  productDir: targetConfig.productDir,
50
24
  serviceName: targetConfig.name,
51
25
  sourceFile: path.join(targetConfig.productDir, task.file),
52
26
  });
53
- console.log(`·· ${targetConfig.stackLabel}:${task.suiteName} → ${task.file}`);
27
+ console.log(`·· ${targetConfig.runtimeLabel}:${task.suiteName} → ${task.file}`);
54
28
  return runDefaultRuntimeTask(
55
29
  targetConfig,
56
30
  task,
31
+ lease,
57
32
  ["run", "--address", "127.0.0.1:0", "-e", `BASE_URL=${baseUrl}`, bundledFile],
58
33
  lifecycle
59
34
  );
60
35
  }
61
36
 
62
- async function runDalTask(targetConfig, task, databaseUrl, lifecycle) {
37
+ export async function runDalTask(targetConfig, task, lifecycle, lease) {
38
+ const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
39
+ if (!databaseUrl) {
40
+ throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
41
+ }
42
+
63
43
  const bundledFile = await bundleK6File({
64
44
  productDir: targetConfig.productDir,
65
45
  serviceName: targetConfig.name,
66
46
  sourceFile: path.join(targetConfig.productDir, task.file),
67
47
  });
68
- console.log(`·· ${targetConfig.stackLabel}:${task.suiteName} → ${task.file}`);
48
+ console.log(`·· ${targetConfig.runtimeLabel}:${task.suiteName} → ${task.file}`);
69
49
  return runDefaultRuntimeTask(
70
50
  targetConfig,
71
51
  task,
52
+ lease,
72
53
  ["run", "--address", "127.0.0.1:0", "-e", `DATABASE_URL=${databaseUrl}`, bundledFile],
73
54
  lifecycle
74
55
  );
75
56
  }
76
57
 
77
- export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle, firstLine) {
58
+ export async function runDefaultRuntimeTask(targetConfig, task, lease, args, lifecycle, firstLine) {
78
59
  const k6Binary = resolveK6Binary();
79
60
  const getFirstLine = firstLine || defaultFirstLine;
80
- const summaryFile = buildDefaultRuntimeSummaryPath(targetConfig, task);
61
+ const summaryFile = buildDefaultRuntimeSummaryPath(lease, task);
81
62
  fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
82
63
  const startedAt = Date.now();
83
64
  const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
@@ -86,8 +67,9 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
86
67
  [...args.slice(0, 1), "--summary-export", summaryFile, ...args.slice(1)],
87
68
  {
88
69
  cwd: targetConfig.productDir,
89
- env: buildExecutionEnv(
70
+ env: buildTaskExecutionEnv(
90
71
  targetConfig,
72
+ lease,
91
73
  buildFileTimeoutEnv(fileTimeoutSeconds, startedAt),
92
74
  process.env
93
75
  ),
@@ -96,7 +78,7 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
96
78
  forceKillAfterDelay: 5_000,
97
79
  }
98
80
  );
99
- const { result, timedOut } = await settleDefaultRuntimeProcess(subprocess, fileTimeoutSeconds);
81
+ const { result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds);
100
82
 
101
83
  const stdout = parseDefaultRuntimeOutput(result.stdout || "");
102
84
  const stderr = parseDefaultRuntimeOutput(result.stderr || "");
@@ -125,14 +107,7 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
125
107
  };
126
108
  }
127
109
 
128
- function defaultFirstLine(output) {
129
- return String(output || "")
130
- .split(/\r?\n/)
131
- .map((line) => line.trim())
132
- .find(Boolean) || null;
133
- }
134
-
135
- async function settleDefaultRuntimeProcess(subprocess, fileTimeoutSeconds) {
110
+ export async function settleSubprocess(subprocess, fileTimeoutSeconds) {
136
111
  const timeoutMs = fileTimeoutSeconds * 1000 + 1_000;
137
112
  let timeoutHandle = null;
138
113
  let timedOut = false;
@@ -154,12 +129,8 @@ async function settleDefaultRuntimeProcess(subprocess, fileTimeoutSeconds) {
154
129
  }
155
130
  }
156
131
 
157
- export function buildDefaultRuntimeSummaryPath(targetConfig, task) {
158
- return path.join(
159
- targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit"),
160
- "_runtime",
161
- `task-${task.id}.summary.json`
162
- );
132
+ export function buildDefaultRuntimeSummaryPath(lease, task) {
133
+ return path.join(lease.leaseDir, "default-runtime", `task-${task.id}.summary.json`);
163
134
  }
164
135
 
165
136
  export function readDefaultRuntimeSummary(filePath) {
@@ -170,6 +141,13 @@ export function readDefaultRuntimeSummary(filePath) {
170
141
  }
171
142
  }
172
143
 
144
+ function defaultFirstLine(output) {
145
+ return String(output || "")
146
+ .split(/\r?\n/)
147
+ .map((line) => line.trim())
148
+ .find(Boolean) || null;
149
+ }
150
+
173
151
  function parseDefaultRuntimeOutput(output) {
174
152
  if (!output) {
175
153
  return {