@elench/testkit 0.1.16 → 0.1.18

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 (39) hide show
  1. package/README.md +44 -19
  2. package/bin/testkit.mjs +1 -1
  3. package/lib/cli/args.mjs +57 -0
  4. package/lib/cli/args.test.mjs +62 -0
  5. package/lib/cli/index.mjs +88 -0
  6. package/lib/config/index.mjs +294 -0
  7. package/lib/config/index.test.mjs +12 -0
  8. package/lib/config/model.mjs +422 -0
  9. package/lib/config/model.test.mjs +193 -0
  10. package/lib/database/fingerprint.mjs +61 -0
  11. package/lib/database/fingerprint.test.mjs +93 -0
  12. package/lib/{database.mjs → database/index.mjs} +45 -160
  13. package/lib/database/naming.mjs +47 -0
  14. package/lib/database/naming.test.mjs +39 -0
  15. package/lib/database/state.mjs +52 -0
  16. package/lib/database/state.test.mjs +66 -0
  17. package/lib/reporters/playwright.mjs +125 -0
  18. package/lib/reporters/playwright.test.mjs +73 -0
  19. package/lib/runner/index.mjs +1221 -0
  20. package/lib/runner/metadata.mjs +55 -0
  21. package/lib/runner/metadata.test.mjs +52 -0
  22. package/lib/runner/planning.mjs +270 -0
  23. package/lib/runner/planning.test.mjs +127 -0
  24. package/lib/runner/results.mjs +285 -0
  25. package/lib/runner/results.test.mjs +144 -0
  26. package/lib/runner/state.mjs +71 -0
  27. package/lib/runner/state.test.mjs +64 -0
  28. package/lib/runner/template.mjs +320 -0
  29. package/lib/runner/template.test.mjs +150 -0
  30. package/lib/telemetry/index.mjs +43 -0
  31. package/lib/timing/index.mjs +73 -0
  32. package/lib/timing/index.test.mjs +64 -0
  33. package/package.json +11 -3
  34. package/infra/neon-down.sh +0 -18
  35. package/infra/neon-up.sh +0 -124
  36. package/lib/cli.mjs +0 -132
  37. package/lib/config.mjs +0 -666
  38. package/lib/exec.mjs +0 -20
  39. package/lib/runner.mjs +0 -1165
package/lib/runner.mjs DELETED
@@ -1,1165 +0,0 @@
1
- import fs from "fs";
2
- import path from "path";
3
- import { spawn } from "child_process";
4
- import net from "net";
5
- import { execa, execaCommand } from "execa";
6
- import { resolveDalBinary, resolveServiceCwd } from "./config.mjs";
7
- import {
8
- cleanupOrphanedLocalInfrastructure,
9
- destroyRuntimeDatabase,
10
- destroyServiceDatabaseCache,
11
- isDatabaseStateDir,
12
- prepareDatabaseRuntime,
13
- showServiceDatabaseStatus,
14
- } from "./database.mjs";
15
-
16
- const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
17
- const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
18
- const DEFAULT_READY_TIMEOUT_MS = 120_000;
19
- const PORT_STRIDE = 100;
20
-
21
- export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
22
- const configMap = new Map(allConfigs.map((config) => [config.name, config]));
23
- const targetSpan = Math.max(1, opts.jobs || 1);
24
- const startedAt = Date.now();
25
- const results = await Promise.all(
26
- configs.map(async (config, targetSlot) => {
27
- console.log(`\n══ ${config.name} ══`);
28
- return runService(config, configMap, suiteType, suiteNames, opts, {
29
- targetSlot,
30
- targetSpan,
31
- });
32
- })
33
- );
34
-
35
- printRunSummary(results, Date.now() - startedAt);
36
- if (results.some((result) => result.failed)) process.exit(1);
37
- }
38
-
39
- export async function destroy(config) {
40
- if (!fs.existsSync(config.stateDir)) return;
41
-
42
- const runtimeStateDirs = findRuntimeStateDirs(config.stateDir);
43
- for (const stateDir of runtimeStateDirs) {
44
- await destroyRuntimeDatabase({
45
- productDir: config.productDir,
46
- stateDir,
47
- });
48
- }
49
-
50
- await destroyServiceDatabaseCache(config.productDir, config.name);
51
- fs.rmSync(config.stateDir, { recursive: true, force: true });
52
- await cleanupOrphanedLocalInfrastructure(config.productDir);
53
- }
54
-
55
- export function showStatus(config) {
56
- if (!fs.existsSync(config.stateDir)) {
57
- console.log("No state — run tests first.");
58
- } else {
59
- printStateDir(config.stateDir, " ");
60
- }
61
- showServiceDatabaseStatus(config.productDir, config.name);
62
- }
63
-
64
- async function runService(targetConfig, configMap, suiteType, suiteNames, opts, runtimeSlot) {
65
- const startedAt = Date.now();
66
- const suites = applyShard(
67
- collectSuites(targetConfig, suiteType, suiteNames, opts.framework),
68
- opts.shard
69
- );
70
- if (suites.length === 0) {
71
- console.log(
72
- `No test files for ${targetConfig.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`
73
- );
74
- return {
75
- name: targetConfig.name,
76
- failed: false,
77
- skipped: true,
78
- suiteCount: 0,
79
- completedSuiteCount: 0,
80
- failedSuiteCount: 0,
81
- durationMs: Date.now() - startedAt,
82
- workers: [],
83
- errors: [],
84
- };
85
- }
86
-
87
- const runtimeConfigs = resolveRuntimeConfigs(targetConfig, configMap);
88
- fs.mkdirSync(targetConfig.stateDir, { recursive: true });
89
-
90
- const jobs = Math.max(1, Math.min(opts.jobs || 1, suites.length));
91
- const workerPlans = buildWorkerPlans(
92
- targetConfig,
93
- runtimeConfigs,
94
- suites,
95
- jobs,
96
- runtimeSlot
97
- );
98
-
99
- const results = await Promise.allSettled(workerPlans.map((plan) => runWorkerPlan(plan)));
100
- let failed = false;
101
- const workers = [];
102
- const errors = [];
103
- let failedSuiteCount = 0;
104
- let completedSuiteCount = 0;
105
-
106
- for (const result of results) {
107
- if (result.status === "rejected") {
108
- failed = true;
109
- console.error(result.reason);
110
- errors.push(formatError(result.reason));
111
- continue;
112
- }
113
- workers.push(result.value);
114
- completedSuiteCount += result.value.completedSuiteCount;
115
- failedSuiteCount += result.value.failedSuiteCount;
116
- if (result.value.failed) failed = true;
117
- }
118
-
119
- return {
120
- name: targetConfig.name,
121
- failed,
122
- skipped: false,
123
- suiteCount: suites.length,
124
- completedSuiteCount,
125
- failedSuiteCount,
126
- durationMs: Date.now() - startedAt,
127
- workers,
128
- errors,
129
- };
130
- }
131
-
132
- function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
133
- const types =
134
- suiteType === "all"
135
- ? orderedTypes(Object.keys(config.suites))
136
- : [suiteType === "int" ? "integration" : suiteType];
137
-
138
- const selectedNames = new Set(suiteNames);
139
- const suites = [];
140
- let orderIndex = 0;
141
-
142
- for (const type of types) {
143
- for (const suite of config.suites[type] || []) {
144
- const framework = suite.framework || "k6";
145
- if (selectedNames.size > 0 && !selectedNames.has(suite.name)) continue;
146
- if (frameworkFilter && frameworkFilter !== "all" && framework !== frameworkFilter) continue;
147
-
148
- suites.push({
149
- ...suite,
150
- framework,
151
- type,
152
- orderIndex,
153
- sortKey: `${type}:${suite.name}`,
154
- weight:
155
- suite.testkit?.weight ||
156
- (framework === "playwright"
157
- ? Math.max(2, suite.files.length)
158
- : Math.max(1, suite.files.length)),
159
- maxFileConcurrency:
160
- framework === "k6" ? suite.testkit?.maxFileConcurrency || 1 : 1,
161
- });
162
- orderIndex += 1;
163
- }
164
- }
165
-
166
- return suites;
167
- }
168
-
169
- function applyShard(suites, shard) {
170
- if (!shard) return suites;
171
- return suites.filter((_, index) => index % shard.total === shard.index - 1);
172
- }
173
-
174
- function orderedTypes(types) {
175
- const ordered = [];
176
- for (const known of TYPE_ORDER) {
177
- if (types.includes(known)) ordered.push(known);
178
- }
179
- for (const type of types) {
180
- if (!ordered.includes(type)) ordered.push(type);
181
- }
182
- return ordered;
183
- }
184
-
185
- function resolveRuntimeConfigs(targetConfig, configMap) {
186
- const ordered = [];
187
- const visiting = new Set();
188
- const seen = new Set();
189
-
190
- const visit = (config) => {
191
- if (seen.has(config.name)) return;
192
- if (visiting.has(config.name)) {
193
- throw new Error(`Dependency cycle detected involving "${config.name}"`);
194
- }
195
-
196
- visiting.add(config.name);
197
- for (const depName of config.testkit.dependsOn || []) {
198
- const dep = configMap.get(depName);
199
- if (!dep) {
200
- throw new Error(`Service "${config.name}" depends on unknown service "${depName}"`);
201
- }
202
- visit(dep);
203
- }
204
- visiting.delete(config.name);
205
- seen.add(config.name);
206
- ordered.push(config);
207
- };
208
-
209
- visit(targetConfig);
210
- return ordered;
211
- }
212
-
213
- function buildWorkerPlans(targetConfig, runtimeConfigs, suites, jobs, runtimeSlot) {
214
- const buckets = distributeSuites(suites, jobs);
215
- return buckets
216
- .map((bucket, index) =>
217
- createWorkerPlan(targetConfig, runtimeConfigs, bucket.suites, index + 1, runtimeSlot)
218
- )
219
- .filter(Boolean);
220
- }
221
-
222
- function distributeSuites(suites, jobs) {
223
- const buckets = Array.from({ length: jobs }, () => ({
224
- suites: [],
225
- totalWeight: 0,
226
- }));
227
- const ordered = [...suites].sort(
228
- (a, b) => b.weight - a.weight || a.sortKey.localeCompare(b.sortKey)
229
- );
230
-
231
- for (const suite of ordered) {
232
- let bestBucket = buckets[0];
233
- for (const bucket of buckets.slice(1)) {
234
- if (bucket.totalWeight < bestBucket.totalWeight) {
235
- bestBucket = bucket;
236
- continue;
237
- }
238
- if (
239
- bucket.totalWeight === bestBucket.totalWeight &&
240
- bucket.suites.length < bestBucket.suites.length
241
- ) {
242
- bestBucket = bucket;
243
- }
244
- }
245
-
246
- bestBucket.suites.push(suite);
247
- bestBucket.totalWeight += suite.weight;
248
- }
249
-
250
- for (const bucket of buckets) {
251
- bucket.suites.sort((a, b) => a.orderIndex - b.orderIndex);
252
- }
253
-
254
- return buckets.filter((bucket) => bucket.suites.length > 0);
255
- }
256
-
257
- function createWorkerPlan(targetConfig, runtimeConfigs, suites, workerId, runtimeSlot) {
258
- if (suites.length === 0) return null;
259
-
260
- const workerStateDir = path.join(targetConfig.stateDir, "workers", `worker-${workerId}`);
261
- const workerRuntimeConfigs = resolveWorkerRuntimeConfigs(
262
- targetConfig,
263
- runtimeConfigs,
264
- workerId,
265
- workerStateDir,
266
- runtimeSlot
267
- );
268
- const workerTargetConfig = workerRuntimeConfigs.find(
269
- (config) => config.name === targetConfig.name
270
- );
271
-
272
- return {
273
- workerId,
274
- suites,
275
- runtimeConfigs: workerRuntimeConfigs,
276
- targetConfig: workerTargetConfig,
277
- };
278
- }
279
-
280
- function resolveWorkerRuntimeConfigs(
281
- targetConfig,
282
- runtimeConfigs,
283
- workerId,
284
- workerStateDir,
285
- runtimeSlot
286
- ) {
287
- const portMap = buildPortMap(runtimeConfigs, workerId, runtimeSlot);
288
- const baseUrlByService = new Map();
289
- const readyUrlByService = new Map();
290
-
291
- for (const config of runtimeConfigs) {
292
- if (!config.testkit.local) continue;
293
- baseUrlByService.set(
294
- config.name,
295
- resolveRuntimeUrl(config.testkit.local.baseUrl, config.name, targetConfig, workerId, {
296
- workerStateDir,
297
- portMap,
298
- baseUrlByService,
299
- readyUrlByService,
300
- })
301
- );
302
- readyUrlByService.set(
303
- config.name,
304
- resolveRuntimeUrl(config.testkit.local.readyUrl, config.name, targetConfig, workerId, {
305
- workerStateDir,
306
- portMap,
307
- baseUrlByService,
308
- readyUrlByService,
309
- })
310
- );
311
- }
312
-
313
- const urlMappings = [];
314
- for (const config of runtimeConfigs) {
315
- if (!config.testkit.local) continue;
316
- const resolvedBaseUrl = baseUrlByService.get(config.name);
317
- const resolvedReadyUrl = readyUrlByService.get(config.name);
318
- if (resolvedBaseUrl) {
319
- urlMappings.push([config.testkit.local.baseUrl, resolvedBaseUrl]);
320
- }
321
- if (resolvedReadyUrl) {
322
- urlMappings.push([config.testkit.local.readyUrl, resolvedReadyUrl]);
323
- }
324
- }
325
-
326
- return runtimeConfigs.map((config) =>
327
- resolveWorkerConfig(
328
- config,
329
- targetConfig,
330
- workerId,
331
- workerStateDir,
332
- portMap,
333
- baseUrlByService,
334
- readyUrlByService,
335
- urlMappings
336
- )
337
- );
338
- }
339
-
340
- function buildPortMap(runtimeConfigs, workerId, runtimeSlot) {
341
- const portMap = new Map();
342
- const seen = new Map();
343
- const offset = PORT_STRIDE * ((workerId - 1) + runtimeSlot.targetSlot * runtimeSlot.targetSpan);
344
-
345
- for (const config of runtimeConfigs) {
346
- if (!config.testkit.local) continue;
347
-
348
- const basePort = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
349
- if (!basePort) continue;
350
-
351
- const actualPort = basePort + offset;
352
- const existing = seen.get(actualPort);
353
- if (existing) {
354
- throw new Error(
355
- `Worker port collision: services "${existing}" and "${config.name}" both resolve to ${actualPort}. ` +
356
- `Assign distinct local.port/baseUrl ports in testkit.config.json.`
357
- );
358
- }
359
- seen.set(actualPort, config.name);
360
- portMap.set(config.name, actualPort);
361
- }
362
-
363
- return portMap;
364
- }
365
-
366
- function resolveWorkerConfig(
367
- config,
368
- targetConfig,
369
- workerId,
370
- workerStateDir,
371
- portMap,
372
- baseUrlByService,
373
- readyUrlByService,
374
- urlMappings
375
- ) {
376
- const stateDir = resolveServiceStateDir(workerStateDir, targetConfig.name, config);
377
- const context = {
378
- workerId,
379
- serviceName: config.name,
380
- targetName: targetConfig.name,
381
- serviceStateDir: stateDir,
382
- portMap,
383
- baseUrlByService,
384
- readyUrlByService,
385
- urlMappings,
386
- };
387
-
388
- const database = config.testkit.database
389
- ? {
390
- ...config.testkit.database,
391
- branchName:
392
- config.testkit.database.provider === "neon" &&
393
- config.testkit.database.branchName !== undefined
394
- ? finalizeString(config.testkit.database.branchName, context)
395
- : config.testkit.database.provider === "neon"
396
- ? `${targetConfig.name}-${config.name}-w${workerId}-testkit`
397
- : undefined,
398
- }
399
- : undefined;
400
-
401
- const migrate = config.testkit.migrate
402
- ? {
403
- ...config.testkit.migrate,
404
- cmd: finalizeString(config.testkit.migrate.cmd, context),
405
- cwd:
406
- config.testkit.migrate.cwd !== undefined
407
- ? finalizeString(config.testkit.migrate.cwd, context)
408
- : config.testkit.migrate.cwd,
409
- }
410
- : undefined;
411
-
412
- const seed = config.testkit.seed
413
- ? {
414
- ...config.testkit.seed,
415
- cmd: finalizeString(config.testkit.seed.cmd, context),
416
- cwd:
417
- config.testkit.seed.cwd !== undefined
418
- ? finalizeString(config.testkit.seed.cwd, context)
419
- : config.testkit.seed.cwd,
420
- }
421
- : undefined;
422
-
423
- const local = config.testkit.local
424
- ? {
425
- ...config.testkit.local,
426
- start: finalizeString(config.testkit.local.start, context),
427
- cwd:
428
- config.testkit.local.cwd !== undefined
429
- ? finalizeString(config.testkit.local.cwd, context)
430
- : config.testkit.local.cwd,
431
- port: portMap.get(config.name) || config.testkit.local.port,
432
- baseUrl: baseUrlByService.get(config.name),
433
- readyUrl: readyUrlByService.get(config.name),
434
- env: Object.fromEntries(
435
- Object.entries(config.testkit.local.env || {}).map(([key, value]) => [
436
- key,
437
- finalizeString(String(value), context),
438
- ])
439
- ),
440
- }
441
- : undefined;
442
-
443
- return {
444
- ...config,
445
- stateDir,
446
- workerId,
447
- workerLabel: `w${workerId}`,
448
- targetName: targetConfig.name,
449
- testkit: {
450
- ...config.testkit,
451
- database,
452
- migrate,
453
- seed,
454
- local,
455
- },
456
- };
457
- }
458
-
459
- async function runWorkerPlan(plan) {
460
- const startedAt = Date.now();
461
- console.log(
462
- `\n══ ${plan.targetConfig.name} worker ${plan.workerId} (${plan.suites.length} suite${plan.suites.length === 1 ? "" : "s"}) ══`
463
- );
464
-
465
- let startedServices = [];
466
- let failed = false;
467
- const suiteResults = [];
468
- let fatalError = null;
469
-
470
- try {
471
- await prepareDatabases(plan.runtimeConfigs);
472
-
473
- if (needsLocalRuntime(plan.suites)) {
474
- startedServices = await startLocalServices(plan.runtimeConfigs);
475
- }
476
-
477
- for (const suite of plan.suites) {
478
- console.log(
479
- `\n── ${plan.targetConfig.workerLabel} ${suite.type}:${suite.name} (${suite.framework}) ──`
480
- );
481
- const result = await runSuite(plan.targetConfig, suite);
482
- suiteResults.push(result);
483
- if (result.failed) failed = true;
484
- }
485
- } catch (error) {
486
- fatalError = error;
487
- failed = true;
488
- throw error;
489
- } finally {
490
- await stopLocalServices(startedServices);
491
- }
492
-
493
- return {
494
- workerId: plan.workerId,
495
- failed,
496
- fatalError: fatalError ? formatError(fatalError) : null,
497
- durationMs: Date.now() - startedAt,
498
- suiteCount: plan.suites.length,
499
- completedSuiteCount: suiteResults.length,
500
- failedSuiteCount: suiteResults.filter((result) => result.failed).length,
501
- suites: suiteResults,
502
- };
503
- }
504
-
505
- async function prepareDatabases(runtimeConfigs) {
506
- for (const config of runtimeConfigs) {
507
- await prepareDatabaseRuntime(config, {
508
- runMigrate: config.testkit.migrate
509
- ? (databaseUrl) => runMigrate(config, databaseUrl)
510
- : null,
511
- runSeed: config.testkit.seed ? (databaseUrl) => runSeed(config, databaseUrl) : null,
512
- });
513
- }
514
- }
515
-
516
- async function runMigrate(config, databaseUrl) {
517
- const migrate = config.testkit.migrate;
518
- if (!migrate) return;
519
-
520
- const env = buildExecutionEnv(config);
521
- if (databaseUrl) env.DATABASE_URL = databaseUrl;
522
-
523
- console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
524
- await execaCommand(migrate.cmd, {
525
- cwd: resolveServiceCwd(config.productDir, migrate.cwd),
526
- env,
527
- stdio: "inherit",
528
- shell: true,
529
- });
530
- }
531
-
532
- async function runSeed(config, databaseUrl) {
533
- const seed = config.testkit.seed;
534
- if (!seed) return;
535
-
536
- const env = buildExecutionEnv(config);
537
- if (databaseUrl) env.DATABASE_URL = databaseUrl;
538
-
539
- console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
540
- await execaCommand(seed.cmd, {
541
- cwd: resolveServiceCwd(config.productDir, seed.cwd),
542
- env,
543
- stdio: "inherit",
544
- shell: true,
545
- });
546
- }
547
-
548
- async function startLocalServices(runtimeConfigs) {
549
- const started = [];
550
-
551
- try {
552
- for (const config of runtimeConfigs) {
553
- if (!config.testkit.local) continue;
554
- const proc = await startLocalService(config);
555
- started.push(proc);
556
- }
557
- } catch (error) {
558
- await stopLocalServices(started);
559
- throw error;
560
- }
561
-
562
- return started;
563
- }
564
-
565
- async function startLocalService(config) {
566
- const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
567
- const env = buildExecutionEnv(config, config.testkit.local.env);
568
- const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
569
- if (port) {
570
- env.PORT = String(port);
571
- }
572
-
573
- const dbUrl = readDatabaseUrl(config.stateDir);
574
- if (dbUrl) {
575
- env.DATABASE_URL = dbUrl;
576
- }
577
-
578
- await assertLocalServicePortsAvailable(config);
579
-
580
- console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
581
- const child = spawn(config.testkit.local.start, {
582
- cwd,
583
- env,
584
- detached: true,
585
- shell: true,
586
- stdio: ["ignore", "pipe", "pipe"],
587
- });
588
-
589
- const outputDrains = [
590
- pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`),
591
- pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`),
592
- ];
593
-
594
- const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
595
-
596
- try {
597
- await waitForReady({
598
- name: `${config.workerLabel}:${config.name}`,
599
- url: config.testkit.local.readyUrl,
600
- timeoutMs: readyTimeoutMs,
601
- process: child,
602
- });
603
- } catch (error) {
604
- await stopChildProcess(child, outputDrains);
605
- throw error;
606
- }
607
-
608
- return { name: config.name, child, outputDrains };
609
- }
610
-
611
- async function runSuite(targetConfig, suite) {
612
- if (suite.type === "dal") {
613
- return runDalSuite(targetConfig, suite);
614
- }
615
-
616
- if (suite.framework === "playwright") {
617
- return runPlaywrightSuite(targetConfig, suite);
618
- }
619
-
620
- if (suite.framework === "k6" && HTTP_K6_TYPES.has(suite.type)) {
621
- return runHttpK6Suite(targetConfig, suite);
622
- }
623
-
624
- throw new Error(
625
- `Unsupported suite combination for ${targetConfig.name}: type=${suite.type} framework=${suite.framework}`
626
- );
627
- }
628
-
629
- async function runHttpK6Suite(targetConfig, suite) {
630
- const baseUrl = targetConfig.testkit.local?.baseUrl;
631
- if (!baseUrl) {
632
- throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP k6 suites`);
633
- }
634
-
635
- const startedAt = Date.now();
636
- let failed = false;
637
- const failedFiles = [];
638
- await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
639
- const absFile = path.join(targetConfig.productDir, file);
640
- console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
641
- try {
642
- await execa("k6", ["run", "-e", `BASE_URL=${baseUrl}`, absFile], {
643
- cwd: targetConfig.productDir,
644
- env: buildExecutionEnv(targetConfig),
645
- stdio: "inherit",
646
- });
647
- } catch {
648
- failed = true;
649
- failedFiles.push(file);
650
- }
651
- });
652
-
653
- return buildSuiteResult(suite, failed, startedAt, failedFiles);
654
- }
655
-
656
- async function runDalSuite(targetConfig, suite) {
657
- const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
658
- if (!databaseUrl) {
659
- throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
660
- }
661
-
662
- const k6Binary = resolveDalBinary();
663
- const startedAt = Date.now();
664
- let failed = false;
665
- const failedFiles = [];
666
- await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
667
- const absFile = path.join(targetConfig.productDir, file);
668
- console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
669
- try {
670
- await execa(k6Binary, ["run", "-e", `DATABASE_URL=${databaseUrl}`, absFile], {
671
- cwd: targetConfig.productDir,
672
- env: buildExecutionEnv(targetConfig),
673
- stdio: "inherit",
674
- });
675
- } catch {
676
- failed = true;
677
- failedFiles.push(file);
678
- }
679
- });
680
-
681
- return buildSuiteResult(suite, failed, startedAt, failedFiles);
682
- }
683
-
684
- async function runPlaywrightSuite(targetConfig, suite) {
685
- const local = targetConfig.testkit.local;
686
- if (!local?.baseUrl) {
687
- throw new Error(
688
- `Service "${targetConfig.name}" requires local.baseUrl for Playwright suites`
689
- );
690
- }
691
-
692
- const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
693
- const files = suite.files.map((file) =>
694
- path.relative(cwd, path.join(targetConfig.productDir, file))
695
- );
696
- const startedAt = Date.now();
697
-
698
- try {
699
- await execa("npx", ["playwright", "test", ...files], {
700
- cwd,
701
- env: buildPlaywrightEnv(targetConfig, local.baseUrl),
702
- stdio: "inherit",
703
- });
704
- return buildSuiteResult(suite, false, startedAt);
705
- } catch {
706
- return buildSuiteResult(suite, true, startedAt);
707
- }
708
- }
709
-
710
- async function stopLocalServices(started) {
711
- for (const service of [...started].reverse()) {
712
- await stopChildProcess(service.child, service.outputDrains);
713
- }
714
- }
715
-
716
- async function stopChildProcess(child, outputDrains = []) {
717
- if (!child) return;
718
- if (child.exitCode !== null) {
719
- await Promise.all(outputDrains);
720
- return;
721
- }
722
-
723
- killChildProcess(child, "SIGTERM");
724
- const exited = await Promise.race([
725
- new Promise((resolve) => child.once("exit", () => resolve(true))),
726
- sleep(5_000).then(() => false),
727
- ]);
728
-
729
- if (!exited && child.exitCode === null) {
730
- killChildProcess(child, "SIGKILL");
731
- await new Promise((resolve) => child.once("exit", resolve));
732
- }
733
-
734
- await Promise.all(outputDrains);
735
- }
736
-
737
- async function waitForReady({ name, url, timeoutMs, process }) {
738
- const start = Date.now();
739
-
740
- while (Date.now() - start < timeoutMs) {
741
- if (process.exitCode !== null) {
742
- throw new Error(`Service "${name}" exited before becoming ready`);
743
- }
744
-
745
- try {
746
- const response = await fetch(url);
747
- if (response.ok) return;
748
- } catch {
749
- // Service still warming up.
750
- }
751
-
752
- await sleep(1_000);
753
- }
754
-
755
- throw new Error(`Timed out waiting for "${name}" to become ready at ${url}`);
756
- }
757
-
758
- function needsLocalRuntime(suites) {
759
- return suites.some((suite) => suite.type !== "dal");
760
- }
761
-
762
- function resolveServiceStateDir(workerStateDir, targetName, config) {
763
- const dbSource = config.testkit.databaseFrom || config.name;
764
- return getWorkerServiceStateDir(workerStateDir, targetName, dbSource);
765
- }
766
-
767
- function getWorkerServiceStateDir(workerStateDir, targetName, serviceName) {
768
- if (targetName === serviceName) {
769
- return workerStateDir;
770
- }
771
- return path.join(workerStateDir, "deps", serviceName);
772
- }
773
-
774
- function buildExecutionEnv(config, extraEnv = {}) {
775
- return {
776
- ...process.env,
777
- ...(config.testkit.serviceEnv || {}),
778
- ...extraEnv,
779
- };
780
- }
781
-
782
- function buildPlaywrightEnv(config, baseUrl) {
783
- return buildExecutionEnv(config, {
784
- BASE_URL: baseUrl,
785
- PLAYWRIGHT_HTML_OPEN: "never",
786
- PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS:
787
- process.env.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS || "1",
788
- TESTKIT_MANAGED_SERVERS: "1",
789
- TESTKIT_WORKER_ID: String(config.workerId),
790
- });
791
- }
792
-
793
- function buildSuiteResult(suite, failed, startedAt, failedFiles = []) {
794
- return {
795
- name: suite.name,
796
- type: suite.type,
797
- framework: suite.framework,
798
- failed,
799
- fileCount: suite.files.length,
800
- failedFiles,
801
- durationMs: Date.now() - startedAt,
802
- };
803
- }
804
-
805
- function printRunSummary(results, durationMs) {
806
- const totalServices = results.length;
807
- const executedServices = results.filter((result) => !result.skipped);
808
- const skippedServices = results.filter((result) => result.skipped);
809
- const failedServices = executedServices.filter((result) => result.failed);
810
- const passedServices = executedServices.filter((result) => !result.failed);
811
- const totalSuites = executedServices.reduce((sum, result) => sum + result.suiteCount, 0);
812
- const completedSuites = executedServices.reduce(
813
- (sum, result) => sum + result.completedSuiteCount,
814
- 0
815
- );
816
- const failedSuites = executedServices.reduce((sum, result) => sum + result.failedSuiteCount, 0);
817
- const passedSuites = completedSuites - failedSuites;
818
-
819
- console.log("\n══ Summary ══");
820
- console.log(
821
- [
822
- `services ${passedServices.length}/${executedServices.length} passed`,
823
- `suites ${passedSuites}/${totalSuites} passed`,
824
- skippedServices.length > 0 ? `${skippedServices.length} skipped` : null,
825
- `duration ${formatDuration(durationMs)}`,
826
- ]
827
- .filter(Boolean)
828
- .join(" · ")
829
- );
830
-
831
- for (const result of results) {
832
- const status = result.skipped ? "SKIP" : result.failed ? "FAIL" : "PASS";
833
- const detail = result.skipped ? "no matching suites" : formatServiceSummary(result);
834
- console.log(
835
- `${status.padEnd(4)} ${result.name.padEnd(longestServiceName(results))} ${detail} · ${formatDuration(result.durationMs)}`
836
- );
837
-
838
- if (result.failed) {
839
- const failedSuiteResults = result.workers.flatMap((worker) =>
840
- worker.suites.filter((suite) => suite.failed)
841
- );
842
- for (const suite of failedSuiteResults) {
843
- const fileDetail =
844
- suite.failedFiles.length > 0 ? ` (${suite.failedFiles.join(", ")})` : "";
845
- console.log(
846
- ` - ${suite.type}:${suite.name} [${suite.framework}]${fileDetail} · ${formatDuration(suite.durationMs)}`
847
- );
848
- }
849
- for (const error of result.errors) {
850
- console.log(` - worker error: ${error}`);
851
- }
852
- }
853
- }
854
-
855
- if (failedServices.length > 0) {
856
- console.log(`\nResult: FAILED (${failedServices.length}/${totalServices} services failed)`);
857
- return;
858
- }
859
-
860
- console.log("\nResult: PASSED");
861
- }
862
-
863
- function longestServiceName(results) {
864
- return results.reduce((max, result) => Math.max(max, result.name.length), 4);
865
- }
866
-
867
- function formatDuration(durationMs) {
868
- const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
869
- const minutes = Math.floor(totalSeconds / 60);
870
- const seconds = totalSeconds % 60;
871
- if (minutes === 0) return `${seconds}s`;
872
- return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
873
- }
874
-
875
- function formatError(error) {
876
- if (error instanceof Error) return error.message;
877
- return String(error);
878
- }
879
-
880
- function formatServiceSummary(result) {
881
- const passedSuites = result.completedSuiteCount - result.failedSuiteCount;
882
- const notRun = result.suiteCount - result.completedSuiteCount;
883
- let detail = `${passedSuites}/${result.suiteCount} suites passed`;
884
- if (notRun > 0) {
885
- detail += `, ${notRun} not run`;
886
- }
887
- return detail;
888
- }
889
-
890
- function killChildProcess(child, signal) {
891
- if (!child?.pid) return;
892
-
893
- try {
894
- process.kill(-child.pid, signal);
895
- return;
896
- } catch (error) {
897
- if (error?.code !== "ESRCH") {
898
- // Fall back to the direct child if process-group signalling is unavailable.
899
- } else {
900
- return;
901
- }
902
- }
903
-
904
- try {
905
- child.kill(signal);
906
- } catch (error) {
907
- if (error?.code !== "ESRCH") throw error;
908
- }
909
- }
910
-
911
- function readDatabaseUrl(stateDir) {
912
- return readStateValue(path.join(stateDir, "database_url"));
913
- }
914
-
915
- function readStateValue(filePath) {
916
- if (!fs.existsSync(filePath)) return null;
917
- return fs.readFileSync(filePath, "utf8").trim();
918
- }
919
-
920
- function printStateDir(dir, indent) {
921
- for (const file of fs.readdirSync(dir)) {
922
- const filePath = path.join(dir, file);
923
- if (fs.statSync(filePath).isDirectory()) {
924
- console.log(`${indent}${file}/`);
925
- printStateDir(filePath, `${indent} `);
926
- continue;
927
- }
928
- const value = fs.readFileSync(filePath, "utf8").trim();
929
- console.log(`${indent}${file}: ${value}`);
930
- }
931
- }
932
-
933
- function pipeOutput(stream, prefix) {
934
- if (!stream) return Promise.resolve();
935
-
936
- let pending = "";
937
- return new Promise((resolve) => {
938
- let settled = false;
939
- const settle = () => {
940
- if (settled) return;
941
- settled = true;
942
- resolve();
943
- };
944
-
945
- stream.on("data", (chunk) => {
946
- pending += chunk.toString();
947
- const lines = pending.split(/\r?\n/);
948
- pending = lines.pop() || "";
949
- for (const line of lines) {
950
- if (line.length > 0) console.log(`${prefix} ${line}`);
951
- }
952
- });
953
- stream.on("end", () => {
954
- if (pending.length > 0) {
955
- console.log(`${prefix} ${pending}`);
956
- }
957
- settle();
958
- });
959
- stream.on("close", settle);
960
- stream.on("error", settle);
961
- });
962
- }
963
-
964
- async function runWithConcurrency(items, limit, handler) {
965
- const concurrency = Math.max(1, Math.min(limit || 1, items.length || 1));
966
- let nextIndex = 0;
967
-
968
- const workers = Array.from({ length: concurrency }, async () => {
969
- while (nextIndex < items.length) {
970
- const current = nextIndex;
971
- nextIndex += 1;
972
- await handler(items[current], current);
973
- }
974
- });
975
-
976
- await Promise.all(workers);
977
- }
978
-
979
- function sleep(ms) {
980
- return new Promise((resolve) => setTimeout(resolve, ms));
981
- }
982
-
983
- function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, workerId, context) {
984
- const resolved = resolveTemplateString(rawUrl, {
985
- ...context,
986
- targetName: targetConfig.name,
987
- workerId,
988
- serviceName,
989
- });
990
- const actualPort = context.portMap.get(serviceName);
991
- return actualPort ? rewriteUrlPort(resolved, actualPort) : resolved;
992
- }
993
-
994
- function finalizeString(value, context) {
995
- let resolved = resolveTemplateString(value, context);
996
- for (const [source, destination] of context.urlMappings || []) {
997
- if (source && destination && source !== destination) {
998
- resolved = resolved.split(source).join(destination);
999
- }
1000
- }
1001
- return resolved;
1002
- }
1003
-
1004
- function resolveTemplateString(value, context) {
1005
- if (typeof value !== "string") return value;
1006
-
1007
- return value.replace(/\{([a-zA-Z]+)(?::([a-zA-Z0-9_-]+))?\}/g, (_match, token, arg) => {
1008
- switch (token) {
1009
- case "worker":
1010
- return String(context.workerId);
1011
- case "target":
1012
- return context.targetName;
1013
- case "service":
1014
- return context.serviceName;
1015
- case "stateDir":
1016
- return context.serviceStateDir;
1017
- case "port": {
1018
- const serviceName = arg || context.serviceName;
1019
- const port = context.portMap.get(serviceName);
1020
- if (!port) {
1021
- throw new Error(`Unknown port placeholder for service "${serviceName}"`);
1022
- }
1023
- return String(port);
1024
- }
1025
- case "baseUrl": {
1026
- const serviceName = arg || context.serviceName;
1027
- const baseUrl = context.baseUrlByService.get(serviceName);
1028
- if (!baseUrl) {
1029
- throw new Error(`Unknown baseUrl placeholder for service "${serviceName}"`);
1030
- }
1031
- return baseUrl;
1032
- }
1033
- case "readyUrl": {
1034
- const serviceName = arg || context.serviceName;
1035
- const readyUrl = context.readyUrlByService.get(serviceName);
1036
- if (!readyUrl) {
1037
- throw new Error(`Unknown readyUrl placeholder for service "${serviceName}"`);
1038
- }
1039
- return readyUrl;
1040
- }
1041
- default:
1042
- throw new Error(`Unsupported template token "{${token}${arg ? `:${arg}` : ""}}"`);
1043
- }
1044
- });
1045
- }
1046
-
1047
- function rewriteUrlPort(rawUrl, port) {
1048
- try {
1049
- const original = new URL(rawUrl);
1050
- if (!original.port) return rawUrl;
1051
-
1052
- const rewritten = new URL(rawUrl);
1053
- rewritten.port = String(port);
1054
-
1055
- let next = rewritten.toString();
1056
- if (!rawUrl.endsWith("/") && original.pathname === "/" && next.endsWith("/")) {
1057
- next = next.slice(0, -1);
1058
- }
1059
- return next;
1060
- } catch {
1061
- return rawUrl;
1062
- }
1063
- }
1064
-
1065
- function numericPortFromUrl(rawUrl) {
1066
- try {
1067
- const url = new URL(rawUrl);
1068
- const port = Number(url.port);
1069
- return Number.isInteger(port) && port > 0 ? port : null;
1070
- } catch {
1071
- return null;
1072
- }
1073
- }
1074
-
1075
- async function assertLocalServicePortsAvailable(config) {
1076
- const endpoints = [config.testkit.local.baseUrl, config.testkit.local.readyUrl];
1077
- const seen = new Set();
1078
-
1079
- for (const endpoint of endpoints) {
1080
- const socket = socketFromUrl(endpoint);
1081
- if (!socket) continue;
1082
-
1083
- const key = `${socket.host}:${socket.port}`;
1084
- if (seen.has(key)) continue;
1085
- seen.add(key);
1086
-
1087
- if (await isPortInUse(socket)) {
1088
- throw new Error(
1089
- `Cannot start "${config.workerLabel}:${config.name}" because ${key} is already in use. ` +
1090
- `Stop the existing process and rerun testkit.`
1091
- );
1092
- }
1093
- }
1094
- }
1095
-
1096
- function socketFromUrl(rawUrl) {
1097
- try {
1098
- const url = new URL(rawUrl);
1099
- const port = Number(url.port);
1100
- if (!Number.isInteger(port) || port <= 0) return null;
1101
-
1102
- const host = normalizeSocketHost(url.hostname);
1103
- return host ? { host, port } : null;
1104
- } catch {
1105
- return null;
1106
- }
1107
- }
1108
-
1109
- function normalizeSocketHost(hostname) {
1110
- if (!hostname || hostname === "localhost") return "127.0.0.1";
1111
- if (hostname === "[::1]") return "::1";
1112
- return hostname;
1113
- }
1114
-
1115
- async function isPortInUse({ host, port }) {
1116
- return new Promise((resolve, reject) => {
1117
- const socket = new net.Socket();
1118
- let settled = false;
1119
-
1120
- const finish = (value, error = null) => {
1121
- if (settled) return;
1122
- settled = true;
1123
- socket.destroy();
1124
- if (error) {
1125
- reject(error);
1126
- return;
1127
- }
1128
- resolve(value);
1129
- };
1130
-
1131
- socket.setTimeout(1_000);
1132
- socket.once("connect", () => finish(true));
1133
- socket.once("timeout", () => finish(false));
1134
- socket.once("error", (error) => {
1135
- if (["ECONNREFUSED", "EHOSTUNREACH", "ENOTFOUND"].includes(error.code)) {
1136
- finish(false);
1137
- return;
1138
- }
1139
- finish(false, error);
1140
- });
1141
-
1142
- socket.connect(port, host);
1143
- });
1144
- }
1145
-
1146
- function findRuntimeStateDirs(rootDir) {
1147
- const found = [];
1148
-
1149
- const visit = (dir) => {
1150
- if (!fs.existsSync(dir)) return;
1151
- const entries = fs.readdirSync(dir, { withFileTypes: true });
1152
- if (isDatabaseStateDir(dir)) {
1153
- found.push(dir);
1154
- }
1155
-
1156
- for (const entry of entries) {
1157
- if (entry.isDirectory()) {
1158
- visit(path.join(dir, entry.name));
1159
- }
1160
- }
1161
- };
1162
-
1163
- visit(rootDir);
1164
- return found.sort((a, b) => b.length - a.length);
1165
- }