@elench/testkit 0.1.11 → 0.1.12

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 CHANGED
@@ -28,6 +28,13 @@ npx @elench/testkit e2e
28
28
  npx @elench/testkit --framework playwright
29
29
  npx @elench/testkit --framework k6
30
30
 
31
+ # Parallelize with isolated worker stacks
32
+ npx @elench/testkit --jobs 3
33
+
34
+ # Run a deterministic shard
35
+ npx @elench/testkit --shard 1/3
36
+ npx @elench/testkit --jobs 2 --shard 2/3
37
+
31
38
  # Specific service / suite
32
39
  npx @elench/testkit frontend e2e -s auth
33
40
  npx @elench/testkit bourne int -s health
@@ -44,7 +51,7 @@ npx @elench/testkit destroy
44
51
  3. **Database** — provisions a Neon branch when a service declares one
45
52
  4. **Seed** — runs optional product seed commands against the provisioned database
46
53
  5. **Runtime** — starts required local services, waits for readiness, and injects test env
47
- 6. **Execution** — runs `k6` suites file-by-file and Playwright suites suite-by-suite
54
+ 6. **Execution** — distributes suites across isolated worker stacks, runs `k6` suites file-by-file, and runs Playwright suites suite-by-suite
48
55
  7. **Cleanup** — stops local processes and preserves `.testkit/` state for inspection or later destroy
49
56
 
50
57
  ## File roles
@@ -52,6 +59,37 @@ npx @elench/testkit destroy
52
59
  - `runner.manifest.json`: canonical test inventory
53
60
  - `testkit.config.json`: local execution and provisioning config
54
61
 
62
+ ## Parallel execution
63
+
64
+ `@elench/testkit` can run suites in parallel with `--jobs <n>`.
65
+
66
+ Each worker gets its own:
67
+ - Neon branch
68
+ - `.testkit` state subtree
69
+ - local service ports
70
+
71
+ This keeps suites isolated while still reusing one stack per worker across multiple assigned suites.
72
+
73
+ Use `--shard <i/n>` to split the selected suite set deterministically before worker scheduling.
74
+
75
+ ## Suite metadata
76
+
77
+ `runner.manifest.json` remains the source of truth for suites. Optional per-suite `testkit` metadata can tune execution:
78
+
79
+ ```json
80
+ {
81
+ "name": "health",
82
+ "files": ["tests/example.js"],
83
+ "testkit": {
84
+ "maxFileConcurrency": 2,
85
+ "weight": 3
86
+ }
87
+ }
88
+ ```
89
+
90
+ - `maxFileConcurrency`: k6-only opt-in for running files within the suite concurrently
91
+ - `weight`: optional scheduling weight when distributing suites across workers
92
+
55
93
  ## Schema
56
94
 
57
95
  See [testkit-config-schema.md](testkit-config-schema.md).
package/lib/cli.mjs CHANGED
@@ -13,6 +13,10 @@ export function run() {
13
13
  .command("[first] [second] [third]", "Run test suites (int, e2e, dal, all)")
14
14
  .option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
15
15
  .option("--dir <path>", "Explicit product directory")
16
+ .option("--jobs <n>", "Number of isolated worker stacks per service", {
17
+ default: "1",
18
+ })
19
+ .option("--shard <i/n>", "Run only shard i of n at suite granularity")
16
20
  .option("--framework <name>", "Filter by framework (k6, playwright, all)", {
17
21
  default: "all",
18
22
  })
@@ -84,9 +88,42 @@ export function run() {
84
88
  );
85
89
  }
86
90
 
91
+ const jobs = Number.parseInt(String(options.jobs), 10);
92
+ if (!Number.isInteger(jobs) || jobs <= 0) {
93
+ throw new Error(`Invalid --jobs value "${options.jobs}". Expected a positive integer.`);
94
+ }
95
+
96
+ let shard = null;
97
+ if (options.shard) {
98
+ const match = String(options.shard).match(/^(\d+)\/(\d+)$/);
99
+ if (!match) {
100
+ throw new Error(
101
+ `Invalid --shard value "${options.shard}". Expected the form "i/n", e.g. 1/3.`
102
+ );
103
+ }
104
+ const index = Number.parseInt(match[1], 10);
105
+ const total = Number.parseInt(match[2], 10);
106
+ if (index <= 0 || total <= 0 || index > total) {
107
+ throw new Error(
108
+ `Invalid --shard value "${options.shard}". Expected 1 <= i <= n.`
109
+ );
110
+ }
111
+ shard = { index, total };
112
+ }
113
+
87
114
  const suiteType = type || "all";
88
115
  const suiteNames = Array.isArray(options.suite) ? options.suite : [options.suite].filter(Boolean);
89
- await runner.runAll(configs, suiteType, suiteNames, options, allConfigs);
116
+ await runner.runAll(
117
+ configs,
118
+ suiteType,
119
+ suiteNames,
120
+ {
121
+ ...options,
122
+ jobs,
123
+ shard,
124
+ },
125
+ allConfigs
126
+ );
90
127
  });
91
128
 
92
129
  cli.help();
package/lib/config.mjs CHANGED
@@ -161,6 +161,31 @@ function loadRunnerManifest(productDir) {
161
161
  `Service "${serviceName}" suite "${suite.name}" uses unsupported framework "${framework}"`
162
162
  );
163
163
  }
164
+
165
+ if (suite.testkit !== undefined) {
166
+ if (!isObject(suite.testkit)) {
167
+ throw new Error(
168
+ `Service "${serviceName}" suite "${suite.name}" testkit config must be an object`
169
+ );
170
+ }
171
+ if (
172
+ suite.testkit.maxFileConcurrency !== undefined &&
173
+ (!Number.isInteger(suite.testkit.maxFileConcurrency) ||
174
+ suite.testkit.maxFileConcurrency <= 0)
175
+ ) {
176
+ throw new Error(
177
+ `Service "${serviceName}" suite "${suite.name}" testkit.maxFileConcurrency must be a positive integer`
178
+ );
179
+ }
180
+ if (
181
+ suite.testkit.weight !== undefined &&
182
+ (!Number.isInteger(suite.testkit.weight) || suite.testkit.weight <= 0)
183
+ ) {
184
+ throw new Error(
185
+ `Service "${serviceName}" suite "${suite.name}" testkit.weight must be a positive integer`
186
+ );
187
+ }
188
+ }
164
189
  }
165
190
  }
166
191
  }
@@ -339,6 +364,12 @@ function validateServiceConfig(name, service, configPath) {
339
364
  requireString(service.local, "start", `Service "${name}" local.start`);
340
365
  requireString(service.local, "baseUrl", `Service "${name}" local.baseUrl`);
341
366
  requireString(service.local, "readyUrl", `Service "${name}" local.readyUrl`);
367
+ if (
368
+ service.local.port !== undefined &&
369
+ (!Number.isInteger(service.local.port) || service.local.port <= 0)
370
+ ) {
371
+ throw new Error(`Service "${name}" local.port must be a positive integer`);
372
+ }
342
373
  if (service.local.cwd !== undefined && typeof service.local.cwd !== "string") {
343
374
  throw new Error(`Service "${name}" local.cwd must be a string`);
344
375
  }
package/lib/runner.mjs CHANGED
@@ -13,54 +13,36 @@ import {
13
13
  const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
14
14
  const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
15
15
  const DEFAULT_READY_TIMEOUT_MS = 120_000;
16
+ const PORT_STRIDE = 100;
16
17
 
17
18
  export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
18
19
  const configMap = new Map(allConfigs.map((config) => [config.name, config]));
19
- let failed = false;
20
-
21
- for (const config of configs) {
22
- console.log(`\n══ ${config.name} ══`);
23
- const serviceFailed = await runService(config, configMap, suiteType, suiteNames, opts);
24
- if (serviceFailed) failed = true;
25
- }
20
+ const targetSpan = Math.max(1, opts.jobs || 1);
21
+ const results = await Promise.all(
22
+ configs.map(async (config, targetSlot) => {
23
+ console.log(`\n══ ${config.name} ══`);
24
+ return runService(config, configMap, suiteType, suiteNames, opts, {
25
+ targetSlot,
26
+ targetSpan,
27
+ });
28
+ })
29
+ );
26
30
 
27
- if (failed) process.exit(1);
31
+ if (results.some(Boolean)) process.exit(1);
28
32
  }
29
33
 
30
34
  export async function destroy(config) {
31
- const runtimeServices = [];
32
- if (config.testkit.dependsOn) {
33
- for (const depName of config.testkit.dependsOn) {
34
- runtimeServices.push({
35
- name: depName,
36
- stateDir: path.join(config.stateDir, "deps", depName),
37
- testkit: null,
38
- });
39
- }
40
- }
41
- runtimeServices.push({
42
- name: config.name,
43
- stateDir: config.stateDir,
44
- testkit: config.testkit,
45
- });
35
+ if (!fs.existsSync(config.stateDir)) return;
46
36
 
47
- for (const runtime of runtimeServices) {
48
- if (!fs.existsSync(runtime.stateDir)) continue;
49
- const dbFile = path.join(runtime.stateDir, "neon_branch_id");
50
- if (fs.existsSync(dbFile)) {
51
- const serviceConfig =
52
- runtime.name === config.name
53
- ? config.testkit
54
- : { database: { projectId: readStateValue(path.join(runtime.stateDir, "neon_project_id")) } };
55
-
56
- const projectId = serviceConfig.database?.projectId;
57
- if (projectId) {
58
- await runScript("neon-down.sh", {
59
- NEON_PROJECT_ID: projectId,
60
- STATE_DIR: runtime.stateDir,
61
- });
62
- }
63
- }
37
+ const runtimeStateDirs = findRuntimeStateDirs(config.stateDir);
38
+ for (const stateDir of runtimeStateDirs) {
39
+ const projectId = readStateValue(path.join(stateDir, "neon_project_id"));
40
+ if (!projectId) continue;
41
+
42
+ await runScript("neon-down.sh", {
43
+ NEON_PROJECT_ID: projectId,
44
+ STATE_DIR: stateDir,
45
+ });
64
46
  }
65
47
 
66
48
  fs.rmSync(config.stateDir, { recursive: true, force: true });
@@ -75,8 +57,11 @@ export function showStatus(config) {
75
57
  printStateDir(config.stateDir, " ");
76
58
  }
77
59
 
78
- async function runService(targetConfig, configMap, suiteType, suiteNames, opts) {
79
- const suites = collectSuites(targetConfig, suiteType, suiteNames, opts.framework);
60
+ async function runService(targetConfig, configMap, suiteType, suiteNames, opts, runtimeSlot) {
61
+ const suites = applyShard(
62
+ collectSuites(targetConfig, suiteType, suiteNames, opts.framework),
63
+ opts.shard
64
+ );
80
65
  if (suites.length === 0) {
81
66
  console.log(
82
67
  `No test files for ${targetConfig.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`
@@ -87,25 +72,25 @@ async function runService(targetConfig, configMap, suiteType, suiteNames, opts)
87
72
  const runtimeConfigs = resolveRuntimeConfigs(targetConfig, configMap);
88
73
  fs.mkdirSync(targetConfig.stateDir, { recursive: true });
89
74
 
90
- let startedServices = [];
91
- let failed = false;
92
-
93
- try {
94
- await prepareDatabases(runtimeConfigs, targetConfig);
95
- await runMigrations(runtimeConfigs, targetConfig);
96
- await runSeeds(runtimeConfigs, targetConfig);
75
+ const jobs = Math.max(1, Math.min(opts.jobs || 1, suites.length));
76
+ const workerPlans = buildWorkerPlans(
77
+ targetConfig,
78
+ runtimeConfigs,
79
+ suites,
80
+ jobs,
81
+ runtimeSlot
82
+ );
97
83
 
98
- if (needsLocalRuntime(suites)) {
99
- startedServices = await startLocalServices(runtimeConfigs, targetConfig);
100
- }
84
+ const results = await Promise.allSettled(workerPlans.map((plan) => runWorkerPlan(plan)));
85
+ let failed = false;
101
86
 
102
- for (const suite of suites) {
103
- console.log(`\n── ${suite.type}:${suite.name} (${suite.framework}) ──`);
104
- const result = await runSuite(targetConfig, suite, targetConfig.stateDir);
105
- if (result.failed) failed = true;
87
+ for (const result of results) {
88
+ if (result.status === "rejected") {
89
+ failed = true;
90
+ console.error(result.reason);
91
+ continue;
106
92
  }
107
- } finally {
108
- await stopLocalServices(startedServices);
93
+ if (result.value) failed = true;
109
94
  }
110
95
 
111
96
  return failed;
@@ -119,23 +104,40 @@ function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
119
104
 
120
105
  const selectedNames = new Set(suiteNames);
121
106
  const suites = [];
107
+ let orderIndex = 0;
122
108
 
123
109
  for (const type of types) {
124
110
  for (const suite of config.suites[type] || []) {
125
111
  const framework = suite.framework || "k6";
126
112
  if (selectedNames.size > 0 && !selectedNames.has(suite.name)) continue;
127
113
  if (frameworkFilter && frameworkFilter !== "all" && framework !== frameworkFilter) continue;
114
+
128
115
  suites.push({
129
116
  ...suite,
130
117
  framework,
131
118
  type,
119
+ orderIndex,
120
+ sortKey: `${type}:${suite.name}`,
121
+ weight:
122
+ suite.testkit?.weight ||
123
+ (framework === "playwright"
124
+ ? Math.max(2, suite.files.length)
125
+ : Math.max(1, suite.files.length)),
126
+ maxFileConcurrency:
127
+ framework === "k6" ? suite.testkit?.maxFileConcurrency || 1 : 1,
132
128
  });
129
+ orderIndex += 1;
133
130
  }
134
131
  }
135
132
 
136
133
  return suites;
137
134
  }
138
135
 
136
+ function applyShard(suites, shard) {
137
+ if (!shard) return suites;
138
+ return suites.filter((_, index) => index % shard.total === shard.index - 1);
139
+ }
140
+
139
141
  function orderedTypes(types) {
140
142
  const ordered = [];
141
143
  for (const known of TYPE_ORDER) {
@@ -175,39 +177,310 @@ function resolveRuntimeConfigs(targetConfig, configMap) {
175
177
  return ordered;
176
178
  }
177
179
 
178
- async function prepareDatabases(runtimeConfigs, targetConfig) {
180
+ function buildWorkerPlans(targetConfig, runtimeConfigs, suites, jobs, runtimeSlot) {
181
+ const buckets = distributeSuites(suites, jobs);
182
+ return buckets
183
+ .map((bucket, index) =>
184
+ createWorkerPlan(targetConfig, runtimeConfigs, bucket.suites, index + 1, runtimeSlot)
185
+ )
186
+ .filter(Boolean);
187
+ }
188
+
189
+ function distributeSuites(suites, jobs) {
190
+ const buckets = Array.from({ length: jobs }, () => ({
191
+ suites: [],
192
+ totalWeight: 0,
193
+ }));
194
+ const ordered = [...suites].sort(
195
+ (a, b) => b.weight - a.weight || a.sortKey.localeCompare(b.sortKey)
196
+ );
197
+
198
+ for (const suite of ordered) {
199
+ let bestBucket = buckets[0];
200
+ for (const bucket of buckets.slice(1)) {
201
+ if (bucket.totalWeight < bestBucket.totalWeight) {
202
+ bestBucket = bucket;
203
+ continue;
204
+ }
205
+ if (
206
+ bucket.totalWeight === bestBucket.totalWeight &&
207
+ bucket.suites.length < bestBucket.suites.length
208
+ ) {
209
+ bestBucket = bucket;
210
+ }
211
+ }
212
+
213
+ bestBucket.suites.push(suite);
214
+ bestBucket.totalWeight += suite.weight;
215
+ }
216
+
217
+ for (const bucket of buckets) {
218
+ bucket.suites.sort((a, b) => a.orderIndex - b.orderIndex);
219
+ }
220
+
221
+ return buckets.filter((bucket) => bucket.suites.length > 0);
222
+ }
223
+
224
+ function createWorkerPlan(targetConfig, runtimeConfigs, suites, workerId, runtimeSlot) {
225
+ if (suites.length === 0) return null;
226
+
227
+ const workerStateDir = path.join(targetConfig.stateDir, "workers", `worker-${workerId}`);
228
+ const workerRuntimeConfigs = resolveWorkerRuntimeConfigs(
229
+ targetConfig,
230
+ runtimeConfigs,
231
+ workerId,
232
+ workerStateDir,
233
+ runtimeSlot
234
+ );
235
+ const workerTargetConfig = workerRuntimeConfigs.find(
236
+ (config) => config.name === targetConfig.name
237
+ );
238
+
239
+ return {
240
+ workerId,
241
+ suites,
242
+ runtimeConfigs: workerRuntimeConfigs,
243
+ targetConfig: workerTargetConfig,
244
+ };
245
+ }
246
+
247
+ function resolveWorkerRuntimeConfigs(
248
+ targetConfig,
249
+ runtimeConfigs,
250
+ workerId,
251
+ workerStateDir,
252
+ runtimeSlot
253
+ ) {
254
+ const portMap = buildPortMap(runtimeConfigs, workerId, runtimeSlot);
255
+ const baseUrlByService = new Map();
256
+ const readyUrlByService = new Map();
257
+
258
+ for (const config of runtimeConfigs) {
259
+ if (!config.testkit.local) continue;
260
+ baseUrlByService.set(
261
+ config.name,
262
+ resolveRuntimeUrl(config.testkit.local.baseUrl, config.name, targetConfig, workerId, {
263
+ workerStateDir,
264
+ portMap,
265
+ baseUrlByService,
266
+ readyUrlByService,
267
+ })
268
+ );
269
+ readyUrlByService.set(
270
+ config.name,
271
+ resolveRuntimeUrl(config.testkit.local.readyUrl, config.name, targetConfig, workerId, {
272
+ workerStateDir,
273
+ portMap,
274
+ baseUrlByService,
275
+ readyUrlByService,
276
+ })
277
+ );
278
+ }
279
+
280
+ const urlMappings = [];
281
+ for (const config of runtimeConfigs) {
282
+ if (!config.testkit.local) continue;
283
+ const resolvedBaseUrl = baseUrlByService.get(config.name);
284
+ const resolvedReadyUrl = readyUrlByService.get(config.name);
285
+ if (resolvedBaseUrl) {
286
+ urlMappings.push([config.testkit.local.baseUrl, resolvedBaseUrl]);
287
+ }
288
+ if (resolvedReadyUrl) {
289
+ urlMappings.push([config.testkit.local.readyUrl, resolvedReadyUrl]);
290
+ }
291
+ }
292
+
293
+ return runtimeConfigs.map((config) =>
294
+ resolveWorkerConfig(
295
+ config,
296
+ targetConfig,
297
+ workerId,
298
+ workerStateDir,
299
+ portMap,
300
+ baseUrlByService,
301
+ readyUrlByService,
302
+ urlMappings
303
+ )
304
+ );
305
+ }
306
+
307
+ function buildPortMap(runtimeConfigs, workerId, runtimeSlot) {
308
+ const portMap = new Map();
309
+ const seen = new Map();
310
+ const offset = PORT_STRIDE * ((workerId - 1) + runtimeSlot.targetSlot * runtimeSlot.targetSpan);
311
+
312
+ for (const config of runtimeConfigs) {
313
+ if (!config.testkit.local) continue;
314
+
315
+ const basePort = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
316
+ if (!basePort) continue;
317
+
318
+ const actualPort = basePort + offset;
319
+ const existing = seen.get(actualPort);
320
+ if (existing) {
321
+ throw new Error(
322
+ `Worker port collision: services "${existing}" and "${config.name}" both resolve to ${actualPort}. ` +
323
+ `Assign distinct local.port/baseUrl ports in testkit.config.json.`
324
+ );
325
+ }
326
+ seen.set(actualPort, config.name);
327
+ portMap.set(config.name, actualPort);
328
+ }
329
+
330
+ return portMap;
331
+ }
332
+
333
+ function resolveWorkerConfig(
334
+ config,
335
+ targetConfig,
336
+ workerId,
337
+ workerStateDir,
338
+ portMap,
339
+ baseUrlByService,
340
+ readyUrlByService,
341
+ urlMappings
342
+ ) {
343
+ const stateDir = getWorkerServiceStateDir(workerStateDir, targetConfig.name, config.name);
344
+ const context = {
345
+ workerId,
346
+ serviceName: config.name,
347
+ targetName: targetConfig.name,
348
+ serviceStateDir: stateDir,
349
+ portMap,
350
+ baseUrlByService,
351
+ readyUrlByService,
352
+ urlMappings,
353
+ };
354
+
355
+ const database = config.testkit.database
356
+ ? {
357
+ ...config.testkit.database,
358
+ branchName:
359
+ config.testkit.database.branchName !== undefined
360
+ ? finalizeString(config.testkit.database.branchName, context)
361
+ : `${targetConfig.name}-${config.name}-w${workerId}-testkit`,
362
+ }
363
+ : undefined;
364
+
365
+ const migrate = config.testkit.migrate
366
+ ? {
367
+ ...config.testkit.migrate,
368
+ cmd: finalizeString(config.testkit.migrate.cmd, context),
369
+ cwd:
370
+ config.testkit.migrate.cwd !== undefined
371
+ ? finalizeString(config.testkit.migrate.cwd, context)
372
+ : config.testkit.migrate.cwd,
373
+ }
374
+ : undefined;
375
+
376
+ const seed = config.testkit.seed
377
+ ? {
378
+ ...config.testkit.seed,
379
+ cmd: finalizeString(config.testkit.seed.cmd, context),
380
+ cwd:
381
+ config.testkit.seed.cwd !== undefined
382
+ ? finalizeString(config.testkit.seed.cwd, context)
383
+ : config.testkit.seed.cwd,
384
+ }
385
+ : undefined;
386
+
387
+ const local = config.testkit.local
388
+ ? {
389
+ ...config.testkit.local,
390
+ start: finalizeString(config.testkit.local.start, context),
391
+ cwd:
392
+ config.testkit.local.cwd !== undefined
393
+ ? finalizeString(config.testkit.local.cwd, context)
394
+ : config.testkit.local.cwd,
395
+ port: portMap.get(config.name) || config.testkit.local.port,
396
+ baseUrl: baseUrlByService.get(config.name),
397
+ readyUrl: readyUrlByService.get(config.name),
398
+ env: Object.fromEntries(
399
+ Object.entries(config.testkit.local.env || {}).map(([key, value]) => [
400
+ key,
401
+ finalizeString(String(value), context),
402
+ ])
403
+ ),
404
+ }
405
+ : undefined;
406
+
407
+ return {
408
+ ...config,
409
+ stateDir,
410
+ workerId,
411
+ workerLabel: `w${workerId}`,
412
+ targetName: targetConfig.name,
413
+ testkit: {
414
+ ...config.testkit,
415
+ database,
416
+ migrate,
417
+ seed,
418
+ local,
419
+ },
420
+ };
421
+ }
422
+
423
+ async function runWorkerPlan(plan) {
424
+ console.log(
425
+ `\n══ ${plan.targetConfig.name} worker ${plan.workerId} (${plan.suites.length} suite${plan.suites.length === 1 ? "" : "s"}) ══`
426
+ );
427
+
428
+ let startedServices = [];
429
+ let failed = false;
430
+
431
+ try {
432
+ await prepareDatabases(plan.runtimeConfigs);
433
+ await runMigrations(plan.runtimeConfigs);
434
+ await runSeeds(plan.runtimeConfigs);
435
+
436
+ if (needsLocalRuntime(plan.suites)) {
437
+ startedServices = await startLocalServices(plan.runtimeConfigs);
438
+ }
439
+
440
+ for (const suite of plan.suites) {
441
+ console.log(
442
+ `\n── ${plan.targetConfig.workerLabel} ${suite.type}:${suite.name} (${suite.framework}) ──`
443
+ );
444
+ const result = await runSuite(plan.targetConfig, suite);
445
+ if (result.failed) failed = true;
446
+ }
447
+ } finally {
448
+ await stopLocalServices(startedServices);
449
+ }
450
+
451
+ return failed;
452
+ }
453
+
454
+ async function prepareDatabases(runtimeConfigs) {
179
455
  for (const config of runtimeConfigs) {
180
456
  const db = config.testkit.database;
181
457
  if (!db) continue;
182
458
 
183
459
  requireNeonApiKey();
184
-
185
- const stateDir = getServiceStateDir(targetConfig, config.name);
186
- fs.mkdirSync(stateDir, { recursive: true });
460
+ fs.mkdirSync(config.stateDir, { recursive: true });
187
461
 
188
462
  await runScript("neon-up.sh", {
189
463
  NEON_PROJECT_ID: db.projectId,
190
464
  NEON_DB_NAME: db.dbName,
191
- NEON_BRANCH_NAME: db.branchName || `${targetConfig.name}-${config.name}-testkit`,
465
+ NEON_BRANCH_NAME: db.branchName,
192
466
  NEON_RESET: db.reset === false ? "false" : "true",
193
- STATE_DIR: stateDir,
467
+ STATE_DIR: config.stateDir,
194
468
  });
195
469
 
196
- fs.writeFileSync(path.join(stateDir, "neon_project_id"), db.projectId);
470
+ fs.writeFileSync(path.join(config.stateDir, "neon_project_id"), db.projectId);
197
471
  }
198
472
  }
199
473
 
200
- async function runMigrations(runtimeConfigs, targetConfig) {
474
+ async function runMigrations(runtimeConfigs) {
201
475
  for (const config of runtimeConfigs) {
202
476
  const migrate = config.testkit.migrate;
203
477
  if (!migrate) continue;
204
478
 
205
- const stateDir = getServiceStateDir(targetConfig, config.name);
206
479
  const env = { ...process.env };
207
- const dbUrl = readDatabaseUrl(stateDir);
480
+ const dbUrl = readDatabaseUrl(config.stateDir);
208
481
  if (dbUrl) env.DATABASE_URL = dbUrl;
209
482
 
210
- console.log(`\n── migrate:${config.name} ──`);
483
+ console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
211
484
  await execaCommand(migrate.cmd, {
212
485
  cwd: resolveServiceCwd(config.productDir, migrate.cwd),
213
486
  env,
@@ -217,17 +490,16 @@ async function runMigrations(runtimeConfigs, targetConfig) {
217
490
  }
218
491
  }
219
492
 
220
- async function runSeeds(runtimeConfigs, targetConfig) {
493
+ async function runSeeds(runtimeConfigs) {
221
494
  for (const config of runtimeConfigs) {
222
495
  const seed = config.testkit.seed;
223
496
  if (!seed) continue;
224
497
 
225
- const stateDir = getServiceStateDir(targetConfig, config.name);
226
498
  const env = { ...process.env };
227
- const dbUrl = readDatabaseUrl(stateDir);
499
+ const dbUrl = readDatabaseUrl(config.stateDir);
228
500
  if (dbUrl) env.DATABASE_URL = dbUrl;
229
501
 
230
- console.log(`\n── seed:${config.name} ──`);
502
+ console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
231
503
  await execaCommand(seed.cmd, {
232
504
  cwd: resolveServiceCwd(config.productDir, seed.cwd),
233
505
  env,
@@ -237,14 +509,13 @@ async function runSeeds(runtimeConfigs, targetConfig) {
237
509
  }
238
510
  }
239
511
 
240
- async function startLocalServices(runtimeConfigs, targetConfig) {
512
+ async function startLocalServices(runtimeConfigs) {
241
513
  const started = [];
242
514
 
243
515
  try {
244
516
  for (const config of runtimeConfigs) {
245
517
  if (!config.testkit.local) continue;
246
- const stateDir = getServiceStateDir(targetConfig, config.name);
247
- const proc = await startLocalService(config, stateDir);
518
+ const proc = await startLocalService(config);
248
519
  started.push(proc);
249
520
  }
250
521
  } catch (error) {
@@ -255,25 +526,25 @@ async function startLocalServices(runtimeConfigs, targetConfig) {
255
526
  return started;
256
527
  }
257
528
 
258
- async function startLocalService(config, stateDir) {
529
+ async function startLocalService(config) {
259
530
  const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
260
531
  const env = {
261
532
  ...process.env,
262
533
  ...config.testkit.local.env,
263
534
  };
264
- const port = portFromUrl(config.testkit.local.baseUrl);
535
+ const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
265
536
  if (port) {
266
- env.PORT = port;
537
+ env.PORT = String(port);
267
538
  }
268
539
 
269
- const dbUrl = readDatabaseUrl(stateDir);
540
+ const dbUrl = readDatabaseUrl(config.stateDir);
270
541
  if (dbUrl) {
271
542
  env.DATABASE_URL = dbUrl;
272
543
  }
273
544
 
274
545
  await assertLocalServicePortsAvailable(config);
275
546
 
276
- console.log(`Starting ${config.name}: ${config.testkit.local.start}`);
547
+ console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
277
548
  const child = spawn(config.testkit.local.start, {
278
549
  cwd,
279
550
  env,
@@ -281,15 +552,14 @@ async function startLocalService(config, stateDir) {
281
552
  stdio: ["ignore", "pipe", "pipe"],
282
553
  });
283
554
 
284
- pipeOutput(child.stdout, `[${config.name}]`);
285
- pipeOutput(child.stderr, `[${config.name}]`);
555
+ pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`);
556
+ pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`);
286
557
 
287
- const readyTimeoutMs =
288
- config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
558
+ const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
289
559
 
290
560
  try {
291
561
  await waitForReady({
292
- name: config.name,
562
+ name: `${config.workerLabel}:${config.name}`,
293
563
  url: config.testkit.local.readyUrl,
294
564
  timeoutMs: readyTimeoutMs,
295
565
  process: child,
@@ -302,9 +572,9 @@ async function startLocalService(config, stateDir) {
302
572
  return { name: config.name, child };
303
573
  }
304
574
 
305
- async function runSuite(targetConfig, suite, stateDir) {
575
+ async function runSuite(targetConfig, suite) {
306
576
  if (suite.type === "dal") {
307
- return runDalSuite(targetConfig, suite, stateDir);
577
+ return runDalSuite(targetConfig, suite);
308
578
  }
309
579
 
310
580
  if (suite.framework === "playwright") {
@@ -327,8 +597,9 @@ async function runHttpK6Suite(targetConfig, suite) {
327
597
  }
328
598
 
329
599
  let failed = false;
330
- for (const file of suite.files) {
600
+ await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
331
601
  const absFile = path.join(targetConfig.productDir, file);
602
+ console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
332
603
  try {
333
604
  await execa("k6", ["run", "-e", `BASE_URL=${baseUrl}`, absFile], {
334
605
  cwd: targetConfig.productDir,
@@ -338,22 +609,22 @@ async function runHttpK6Suite(targetConfig, suite) {
338
609
  } catch {
339
610
  failed = true;
340
611
  }
341
- }
612
+ });
342
613
 
343
614
  return { failed };
344
615
  }
345
616
 
346
- async function runDalSuite(targetConfig, suite, stateDir) {
347
- const databaseUrl = readDatabaseUrl(stateDir);
617
+ async function runDalSuite(targetConfig, suite) {
618
+ const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
348
619
  if (!databaseUrl) {
349
620
  throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
350
621
  }
351
622
 
352
623
  const k6Binary = resolveDalBinary();
353
624
  let failed = false;
354
-
355
- for (const file of suite.files) {
625
+ await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
356
626
  const absFile = path.join(targetConfig.productDir, file);
627
+ console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
357
628
  try {
358
629
  await execa(k6Binary, ["run", "-e", `DATABASE_URL=${databaseUrl}`, absFile], {
359
630
  cwd: targetConfig.productDir,
@@ -363,7 +634,7 @@ async function runDalSuite(targetConfig, suite, stateDir) {
363
634
  } catch {
364
635
  failed = true;
365
636
  }
366
- }
637
+ });
367
638
 
368
639
  return { failed };
369
640
  }
@@ -377,7 +648,9 @@ async function runPlaywrightSuite(targetConfig, suite) {
377
648
  }
378
649
 
379
650
  const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
380
- const files = suite.files.map((file) => path.relative(cwd, path.join(targetConfig.productDir, file)));
651
+ const files = suite.files.map((file) =>
652
+ path.relative(cwd, path.join(targetConfig.productDir, file))
653
+ );
381
654
 
382
655
  try {
383
656
  await execa("npx", ["playwright", "test", ...files], {
@@ -387,6 +660,7 @@ async function runPlaywrightSuite(targetConfig, suite) {
387
660
  BASE_URL: local.baseUrl,
388
661
  PLAYWRIGHT_HTML_OPEN: "never",
389
662
  TESTKIT_MANAGED_SERVERS: "1",
663
+ TESTKIT_WORKER_ID: String(targetConfig.workerId),
390
664
  },
391
665
  stdio: "inherit",
392
666
  });
@@ -442,11 +716,11 @@ function needsLocalRuntime(suites) {
442
716
  return suites.some((suite) => suite.type !== "dal");
443
717
  }
444
718
 
445
- function getServiceStateDir(targetConfig, serviceName) {
446
- if (targetConfig.name === serviceName) {
447
- return targetConfig.stateDir;
719
+ function getWorkerServiceStateDir(workerStateDir, targetName, serviceName) {
720
+ if (targetName === serviceName) {
721
+ return workerStateDir;
448
722
  }
449
- return path.join(targetConfig.stateDir, "deps", serviceName);
723
+ return path.join(workerStateDir, "deps", serviceName);
450
724
  }
451
725
 
452
726
  function readDatabaseUrl(stateDir) {
@@ -490,14 +764,112 @@ function pipeOutput(stream, prefix) {
490
764
  });
491
765
  }
492
766
 
767
+ async function runWithConcurrency(items, limit, handler) {
768
+ const concurrency = Math.max(1, Math.min(limit || 1, items.length || 1));
769
+ let nextIndex = 0;
770
+
771
+ const workers = Array.from({ length: concurrency }, async () => {
772
+ while (nextIndex < items.length) {
773
+ const current = nextIndex;
774
+ nextIndex += 1;
775
+ await handler(items[current], current);
776
+ }
777
+ });
778
+
779
+ await Promise.all(workers);
780
+ }
781
+
493
782
  function sleep(ms) {
494
783
  return new Promise((resolve) => setTimeout(resolve, ms));
495
784
  }
496
785
 
497
- function portFromUrl(rawUrl) {
786
+ function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, workerId, context) {
787
+ const resolved = resolveTemplateString(rawUrl, {
788
+ ...context,
789
+ targetName: targetConfig.name,
790
+ workerId,
791
+ serviceName,
792
+ });
793
+ const actualPort = context.portMap.get(serviceName);
794
+ return actualPort ? rewriteUrlPort(resolved, actualPort) : resolved;
795
+ }
796
+
797
+ function finalizeString(value, context) {
798
+ let resolved = resolveTemplateString(value, context);
799
+ for (const [source, destination] of context.urlMappings || []) {
800
+ if (source && destination && source !== destination) {
801
+ resolved = resolved.split(source).join(destination);
802
+ }
803
+ }
804
+ return resolved;
805
+ }
806
+
807
+ function resolveTemplateString(value, context) {
808
+ if (typeof value !== "string") return value;
809
+
810
+ return value.replace(/\{([a-zA-Z]+)(?::([a-zA-Z0-9_-]+))?\}/g, (_match, token, arg) => {
811
+ switch (token) {
812
+ case "worker":
813
+ return String(context.workerId);
814
+ case "target":
815
+ return context.targetName;
816
+ case "service":
817
+ return context.serviceName;
818
+ case "stateDir":
819
+ return context.serviceStateDir;
820
+ case "port": {
821
+ const serviceName = arg || context.serviceName;
822
+ const port = context.portMap.get(serviceName);
823
+ if (!port) {
824
+ throw new Error(`Unknown port placeholder for service "${serviceName}"`);
825
+ }
826
+ return String(port);
827
+ }
828
+ case "baseUrl": {
829
+ const serviceName = arg || context.serviceName;
830
+ const baseUrl = context.baseUrlByService.get(serviceName);
831
+ if (!baseUrl) {
832
+ throw new Error(`Unknown baseUrl placeholder for service "${serviceName}"`);
833
+ }
834
+ return baseUrl;
835
+ }
836
+ case "readyUrl": {
837
+ const serviceName = arg || context.serviceName;
838
+ const readyUrl = context.readyUrlByService.get(serviceName);
839
+ if (!readyUrl) {
840
+ throw new Error(`Unknown readyUrl placeholder for service "${serviceName}"`);
841
+ }
842
+ return readyUrl;
843
+ }
844
+ default:
845
+ throw new Error(`Unsupported template token "{${token}${arg ? `:${arg}` : ""}}"`);
846
+ }
847
+ });
848
+ }
849
+
850
+ function rewriteUrlPort(rawUrl, port) {
851
+ try {
852
+ const original = new URL(rawUrl);
853
+ if (!original.port) return rawUrl;
854
+
855
+ const rewritten = new URL(rawUrl);
856
+ rewritten.port = String(port);
857
+
858
+ let next = rewritten.toString();
859
+ if (!rawUrl.endsWith("/") && original.pathname === "/" && next.endsWith("/")) {
860
+ next = next.slice(0, -1);
861
+ }
862
+ return next;
863
+ } catch {
864
+ return rawUrl;
865
+ }
866
+ }
867
+
868
+ function numericPortFromUrl(rawUrl) {
498
869
  try {
499
870
  const url = new URL(rawUrl);
500
- return url.port || null;
871
+ const port = Number(url.port);
872
+ return Number.isInteger(port) && port > 0 ? port : null;
501
873
  } catch {
502
874
  return null;
503
875
  }
@@ -517,7 +889,8 @@ async function assertLocalServicePortsAvailable(config) {
517
889
 
518
890
  if (await isPortInUse(socket)) {
519
891
  throw new Error(
520
- `Cannot start "${config.name}" because ${key} is already in use. Stop the existing process and rerun testkit.`
892
+ `Cannot start "${config.workerLabel}:${config.name}" because ${key} is already in use. ` +
893
+ `Stop the existing process and rerun testkit.`
521
894
  );
522
895
  }
523
896
  }
@@ -572,3 +945,29 @@ async function isPortInUse({ host, port }) {
572
945
  socket.connect(port, host);
573
946
  });
574
947
  }
948
+
949
+ function findRuntimeStateDirs(rootDir) {
950
+ const found = [];
951
+
952
+ const visit = (dir) => {
953
+ if (!fs.existsSync(dir)) return;
954
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
955
+ const hasRuntimeFiles = entries.some(
956
+ (entry) =>
957
+ entry.isFile() &&
958
+ (entry.name === "neon_project_id" || entry.name === "neon_branch_id")
959
+ );
960
+ if (hasRuntimeFiles) {
961
+ found.push(dir);
962
+ }
963
+
964
+ for (const entry of entries) {
965
+ if (entry.isDirectory()) {
966
+ visit(path.join(dir, entry.name));
967
+ }
968
+ }
969
+ };
970
+
971
+ visit(rootDir);
972
+ return found.sort((a, b) => b.length - a.length);
973
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "CLI for running manifest-defined local test suites across k6 and Playwright",
5
5
  "type": "module",
6
6
  "bin": {