@elench/testkit 0.1.17 → 0.1.19

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 (53) hide show
  1. package/README.md +76 -16
  2. package/bin/testkit.mjs +1 -1
  3. package/lib/bundler/index.mjs +95 -0
  4. package/lib/bundler/index.test.mjs +79 -0
  5. package/lib/cli/args.mjs +57 -0
  6. package/lib/cli/args.test.mjs +62 -0
  7. package/lib/cli/index.mjs +114 -0
  8. package/lib/config/index.mjs +294 -0
  9. package/lib/config/index.test.mjs +12 -0
  10. package/lib/config/model.mjs +422 -0
  11. package/lib/config/model.test.mjs +193 -0
  12. package/lib/database/fingerprint.mjs +61 -0
  13. package/lib/database/fingerprint.test.mjs +93 -0
  14. package/lib/{database.mjs → database/index.mjs} +45 -160
  15. package/lib/database/naming.mjs +47 -0
  16. package/lib/database/naming.test.mjs +39 -0
  17. package/lib/database/state.mjs +52 -0
  18. package/lib/database/state.test.mjs +66 -0
  19. package/lib/index.mjs +1 -0
  20. package/lib/k6/checks.mjs +1 -0
  21. package/lib/k6/dal-suite.mjs +1 -0
  22. package/lib/k6/dal.mjs +1 -0
  23. package/lib/k6/http.mjs +1 -0
  24. package/lib/k6/index.mjs +30 -0
  25. package/lib/k6/suite.mjs +1 -0
  26. package/lib/reporters/playwright.mjs +125 -0
  27. package/lib/reporters/playwright.test.mjs +73 -0
  28. package/lib/{runner.mjs → runner/index.mjs} +252 -835
  29. package/lib/runner/metadata.mjs +55 -0
  30. package/lib/runner/metadata.test.mjs +52 -0
  31. package/lib/runner/planning.mjs +270 -0
  32. package/lib/runner/planning.test.mjs +127 -0
  33. package/lib/runner/results.mjs +285 -0
  34. package/lib/runner/results.test.mjs +144 -0
  35. package/lib/runner/state.mjs +71 -0
  36. package/lib/runner/state.test.mjs +64 -0
  37. package/lib/runner/template.mjs +320 -0
  38. package/lib/runner/template.test.mjs +150 -0
  39. package/lib/runtime/index.mjs +191 -0
  40. package/lib/runtime-src/k6/checks.js +39 -0
  41. package/lib/runtime-src/k6/dal-suite.js +33 -0
  42. package/lib/runtime-src/k6/dal.js +32 -0
  43. package/lib/runtime-src/k6/http.js +134 -0
  44. package/lib/runtime-src/k6/suite.js +55 -0
  45. package/lib/telemetry/index.mjs +43 -0
  46. package/lib/timing/index.mjs +73 -0
  47. package/lib/timing/index.test.mjs +64 -0
  48. package/package.json +18 -3
  49. package/infra/neon-down.sh +0 -18
  50. package/infra/neon-up.sh +0 -124
  51. package/lib/cli.mjs +0 -132
  52. package/lib/config.mjs +0 -666
  53. package/lib/exec.mjs +0 -20
@@ -3,7 +3,8 @@ import path from "path";
3
3
  import { spawn } from "child_process";
4
4
  import net from "net";
5
5
  import { execa, execaCommand } from "execa";
6
- import { resolveDalBinary, resolveServiceCwd } from "./config.mjs";
6
+ import { bundleK6File } from "../bundler/index.mjs";
7
+ import { resolveDalBinary, resolveServiceCwd } from "../config/index.mjs";
7
8
  import {
8
9
  cleanupOrphanedLocalInfrastructure,
9
10
  destroyRuntimeDatabase,
@@ -11,28 +12,102 @@ import {
11
12
  isDatabaseStateDir,
12
13
  prepareDatabaseRuntime,
13
14
  showServiceDatabaseStatus,
14
- } from "./database.mjs";
15
+ } from "../database/index.mjs";
16
+ import {
17
+ batchNeedsLocalRuntime as batchNeedsLocalRuntimeModel,
18
+ buildGraphDirName as buildGraphDirNameModel,
19
+ buildRuntimeGraphs as buildRuntimeGraphsModel,
20
+ buildTaskQueue as buildTaskQueueModel,
21
+ claimNextBatch as claimNextBatchModel,
22
+ collectSuites as collectSuitesModel,
23
+ compareGraphsForAssignment as compareGraphsForAssignmentModel,
24
+ isRuntimeSuperset as isRuntimeSupersetModel,
25
+ orderedTypes as orderedTypesModel,
26
+ resolveRuntimeConfigs as resolveRuntimeConfigsModel,
27
+ applyShard as applyShardModel,
28
+ } from "./planning.mjs";
29
+ import {
30
+ buildExecutionEnv as buildExecutionEnvModel,
31
+ buildPlaywrightEnv as buildPlaywrightEnvModel,
32
+ buildPortMap as buildPortMapModel,
33
+ finalizeString as finalizeStringModel,
34
+ getWorkerServiceStateDir as getWorkerServiceStateDirModel,
35
+ normalizeSocketHost as normalizeSocketHostModel,
36
+ numericPortFromUrl as numericPortFromUrlModel,
37
+ resolveRuntimeUrl as resolveRuntimeUrlModel,
38
+ resolveServiceStateDir as resolveServiceStateDirModel,
39
+ resolveTemplateString as resolveTemplateStringModel,
40
+ resolveWorkerConfig as resolveWorkerConfigModel,
41
+ resolveWorkerRuntimeConfigs as resolveWorkerRuntimeConfigsModel,
42
+ rewriteUrlPort as rewriteUrlPortModel,
43
+ socketFromUrl as socketFromUrlModel,
44
+ } from "./template.mjs";
45
+ import {
46
+ addTrackerError as addTrackerErrorModel,
47
+ buildRunArtifact as buildRunArtifactModel,
48
+ buildServiceTrackers as buildServiceTrackersModel,
49
+ finalizeServiceResult as finalizeServiceResultModel,
50
+ formatDuration as formatDurationModel,
51
+ formatError as formatErrorModel,
52
+ formatServiceSummary as formatServiceSummaryModel,
53
+ longestServiceName as longestServiceNameModel,
54
+ recordGraphError as recordGraphErrorModel,
55
+ recordTaskOutcome as recordTaskOutcomeModel,
56
+ summarizeDbBackend as summarizeDbBackendModel,
57
+ } from "./results.mjs";
58
+ import {
59
+ applyTimingUpdates,
60
+ buildTimingKey as buildTimingKeyModel,
61
+ createEmptyTimings,
62
+ estimateTaskDuration as estimateTaskDurationModel,
63
+ normalizeTimings,
64
+ } from "../timing/index.mjs";
65
+ import {
66
+ choosePlaywrightFinalResult as choosePlaywrightFinalResultModel,
67
+ collectPlaywrightSpec as collectPlaywrightSpecModel,
68
+ extractPlaywrightFailure as extractPlaywrightFailureModel,
69
+ extractReporterFile as extractReporterFileModel,
70
+ firstLine as firstLineModel,
71
+ formatPlaywrightReporterError as formatPlaywrightReporterErrorModel,
72
+ isPlaywrightPassingStatus as isPlaywrightPassingStatusModel,
73
+ normalizeReportedFile as normalizeReportedFileModel,
74
+ parsePlaywrightJsonResults as parsePlaywrightJsonResultsModel,
75
+ visitPlaywrightSuites as visitPlaywrightSuitesModel,
76
+ } from "../reporters/playwright.mjs";
77
+ import {
78
+ collectGitMetadata as collectGitMetadataModel,
79
+ readPackageMetadata as readPackageMetadataModel,
80
+ safeHostname as safeHostnameModel,
81
+ safeUsername as safeUsernameModel,
82
+ } from "./metadata.mjs";
83
+ import {
84
+ findGraphDirsForService as findGraphDirsForServiceModel,
85
+ findRuntimeStateDirs as findRuntimeStateDirsModel,
86
+ normalizePathSeparators as normalizePathSeparatorsModel,
87
+ readGraphMetadata as readGraphMetadataModel,
88
+ writeGraphMetadata as writeGraphMetadataModel,
89
+ } from "./state.mjs";
90
+ import { uploadTelemetryArtifact } from "../telemetry/index.mjs";
15
91
 
16
- const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
17
92
  const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
18
93
  const DEFAULT_READY_TIMEOUT_MS = 120_000;
19
- const PORT_STRIDE = 100;
20
94
  const TIMINGS_FILENAME = "timings.json";
21
- const GRAPH_METADATA = "graph.json";
22
95
 
23
96
  export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
24
97
  const configMap = new Map(allConfigs.map((config) => [config.name, config]));
25
98
  const startedAt = Date.now();
99
+ const telemetry = configs[0]?.telemetry || null;
26
100
  const servicePlans = collectServicePlans(configs, configMap, suiteType, suiteNames, opts);
27
101
  const trackers = buildServiceTrackers(servicePlans, startedAt);
28
102
  const executedPlans = servicePlans.filter((plan) => !plan.skipped);
103
+ let workerCount = 0;
29
104
 
30
105
  if (executedPlans.length > 0) {
31
106
  const productDir = executedPlans[0].config.productDir;
32
107
  const timings = loadTimings(productDir);
33
108
  const graphs = buildRuntimeGraphs(executedPlans);
34
109
  const queue = buildTaskQueue(executedPlans, graphs, timings);
35
- const workerCount = Math.max(1, Math.min(opts.jobs || 1, queue.length));
110
+ workerCount = Math.max(1, Math.min(opts.jobs || 1, queue.length));
36
111
  const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
37
112
  const workers = Array.from({ length: workerCount }, (_unused, index) =>
38
113
  createWorker(index + 1, productDir)
@@ -61,8 +136,24 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
61
136
  const results = configs.map((config) =>
62
137
  finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
63
138
  );
139
+ const artifact = buildRunArtifact({
140
+ productDir: configs[0]?.productDir || process.cwd(),
141
+ results,
142
+ startedAt,
143
+ finishedAt,
144
+ requestedJobs: opts.jobs || 1,
145
+ workerCount,
146
+ suiteType,
147
+ suiteNames,
148
+ framework: opts.framework || "all",
149
+ shard: opts.shard || null,
150
+ serviceFilter: configs.length === 1 ? configs[0].name : null,
151
+ });
152
+
153
+ writeRunArtifact(configs[0]?.productDir || process.cwd(), artifact);
64
154
 
65
155
  printRunSummary(results, finishedAt - startedAt);
156
+ await reportTelemetry(telemetry, artifact);
66
157
  if (results.some((result) => result.failed)) process.exit(1);
67
158
  }
68
159
 
@@ -141,161 +232,15 @@ function collectServicePlans(configs, configMap, suiteType, suiteNames, opts) {
141
232
  }
142
233
 
143
234
  function buildServiceTrackers(servicePlans, startedAt) {
144
- const trackers = new Map();
145
-
146
- for (const plan of servicePlans) {
147
- if (plan.skipped) {
148
- trackers.set(plan.config.name, {
149
- name: plan.config.name,
150
- skipped: true,
151
- suiteCount: 0,
152
- suites: [],
153
- suitesByKey: new Map(),
154
- errors: [],
155
- errorSet: new Set(),
156
- startedAt,
157
- firstTaskAt: null,
158
- lastTaskAt: null,
159
- });
160
- continue;
161
- }
162
-
163
- const suites = plan.suites.map((suite) => ({
164
- key: `${suite.type}:${suite.name}`,
165
- name: suite.name,
166
- type: suite.type,
167
- framework: suite.framework,
168
- orderIndex: suite.orderIndex,
169
- fileCount: suite.files.length,
170
- completedFileCount: 0,
171
- failedFiles: [],
172
- failedFileSet: new Set(),
173
- durationMs: 0,
174
- error: null,
175
- }));
176
-
177
- trackers.set(plan.config.name, {
178
- name: plan.config.name,
179
- skipped: false,
180
- suiteCount: suites.length,
181
- suites,
182
- suitesByKey: new Map(suites.map((suite) => [suite.key, suite])),
183
- errors: [],
184
- errorSet: new Set(),
185
- startedAt,
186
- firstTaskAt: null,
187
- lastTaskAt: null,
188
- });
189
- }
190
-
191
- return trackers;
235
+ return buildServiceTrackersModel(servicePlans, startedAt);
192
236
  }
193
237
 
194
238
  function buildRuntimeGraphs(servicePlans) {
195
- const executed = servicePlans.filter((plan) => !plan.skipped);
196
- const uniqueGraphs = [];
197
- const graphByRuntimeKey = new Map();
198
-
199
- for (const plan of executed) {
200
- if (graphByRuntimeKey.has(plan.runtimeKey)) {
201
- graphByRuntimeKey.get(plan.runtimeKey).exactTargets.push(plan.config.name);
202
- continue;
203
- }
204
-
205
- const graph = {
206
- key: plan.runtimeKey,
207
- runtimeNames: plan.runtimeNames,
208
- runtimeConfigs: plan.runtimeConfigs,
209
- exactTargets: [plan.config.name],
210
- assignedTargets: [],
211
- dirName: null,
212
- rootConfig: null,
213
- };
214
- uniqueGraphs.push(graph);
215
- graphByRuntimeKey.set(plan.runtimeKey, graph);
216
- }
217
-
218
- const maximalGraphs = uniqueGraphs.filter(
219
- (graph) =>
220
- !uniqueGraphs.some(
221
- (other) =>
222
- other.key !== graph.key &&
223
- isRuntimeSuperset(other.runtimeNames, graph.runtimeNames)
224
- )
225
- );
226
-
227
- for (const plan of executed) {
228
- const compatible = maximalGraphs.filter((graph) =>
229
- isRuntimeSuperset(graph.runtimeNames, plan.runtimeNames)
230
- );
231
- if (compatible.length === 0) {
232
- throw new Error(`No runtime graph found for service "${plan.config.name}"`);
233
- }
234
-
235
- const assigned = compatible.sort(compareGraphsForAssignment)[0];
236
- plan.assignedGraphKey = assigned.key;
237
- assigned.assignedTargets.push(plan.config.name);
238
- }
239
-
240
- for (const graph of maximalGraphs) {
241
- const rootName = [...graph.exactTargets].sort()[0];
242
- const rootPlan = executed.find((plan) => plan.config.name === rootName);
243
- if (!rootPlan) {
244
- throw new Error(`Missing root plan for graph "${graph.key}"`);
245
- }
246
- graph.rootConfig = rootPlan.config;
247
- graph.dirName = buildGraphDirName(graph.runtimeNames);
248
- }
249
-
250
- return maximalGraphs.sort((a, b) => a.dirName.localeCompare(b.dirName));
239
+ return buildRuntimeGraphsModel(servicePlans);
251
240
  }
252
241
 
253
242
  function buildTaskQueue(servicePlans, graphs, timings) {
254
- const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
255
- const tasks = [];
256
- let nextId = 1;
257
-
258
- for (const plan of servicePlans) {
259
- if (plan.skipped) continue;
260
-
261
- const graph = graphByKey.get(plan.assignedGraphKey);
262
- if (!graph) {
263
- throw new Error(`Assigned graph "${plan.assignedGraphKey}" not found for ${plan.config.name}`);
264
- }
265
-
266
- for (const suite of plan.suites) {
267
- for (const file of suite.files) {
268
- const timingKey = buildTimingKey(plan.config.name, suite, file);
269
- tasks.push({
270
- id: nextId,
271
- graphKey: graph.key,
272
- targetName: plan.config.name,
273
- serviceName: plan.config.name,
274
- suiteKey: `${suite.type}:${suite.name}`,
275
- suiteName: suite.name,
276
- type: suite.type,
277
- framework: suite.framework,
278
- orderIndex: suite.orderIndex,
279
- file,
280
- timingKey,
281
- estimatedDurationMs: estimateTaskDuration(timings, timingKey, suite),
282
- maxBatchSize:
283
- suite.framework === "playwright"
284
- ? Number.POSITIVE_INFINITY
285
- : suite.maxFileConcurrency || 1,
286
- });
287
- nextId += 1;
288
- }
289
- }
290
- }
291
-
292
- return tasks.sort(
293
- (a, b) =>
294
- b.estimatedDurationMs - a.estimatedDurationMs ||
295
- a.serviceName.localeCompare(b.serviceName) ||
296
- a.suiteKey.localeCompare(b.suiteKey) ||
297
- a.file.localeCompare(b.file)
298
- );
243
+ return buildTaskQueueModel(servicePlans, graphs, timings);
299
244
  }
300
245
 
301
246
  function createWorker(workerId, productDir) {
@@ -352,58 +297,7 @@ async function runWorker(worker, queue, graphByKey, trackers, timingUpdates) {
352
297
  }
353
298
 
354
299
  function claimNextBatch(queue, preferredGraphKey) {
355
- if (queue.length === 0) return null;
356
-
357
- let index = -1;
358
- if (preferredGraphKey) {
359
- index = queue.findIndex((task) => task.graphKey === preferredGraphKey);
360
- }
361
- if (index === -1) index = 0;
362
-
363
- const seed = queue.splice(index, 1)[0];
364
- const tasks = [seed];
365
-
366
- if (seed.framework === "playwright") {
367
- for (let cursor = queue.length - 1; cursor >= 0; cursor -= 1) {
368
- const candidate = queue[cursor];
369
- if (
370
- candidate.framework === "playwright" &&
371
- candidate.graphKey === seed.graphKey &&
372
- candidate.targetName === seed.targetName
373
- ) {
374
- tasks.push(candidate);
375
- queue.splice(cursor, 1);
376
- }
377
- }
378
- } else if (seed.maxBatchSize > 1) {
379
- for (let cursor = queue.length - 1; cursor >= 0 && tasks.length < seed.maxBatchSize; cursor -= 1) {
380
- const candidate = queue[cursor];
381
- if (
382
- candidate.framework === seed.framework &&
383
- candidate.type === seed.type &&
384
- candidate.graphKey === seed.graphKey &&
385
- candidate.targetName === seed.targetName &&
386
- candidate.suiteKey === seed.suiteKey
387
- ) {
388
- tasks.push(candidate);
389
- queue.splice(cursor, 1);
390
- }
391
- }
392
- }
393
-
394
- tasks.sort(
395
- (a, b) =>
396
- a.orderIndex - b.orderIndex ||
397
- a.file.localeCompare(b.file)
398
- );
399
-
400
- return {
401
- graphKey: seed.graphKey,
402
- targetName: seed.targetName,
403
- framework: seed.framework,
404
- type: seed.type,
405
- tasks,
406
- };
300
+ return claimNextBatchModel(queue, preferredGraphKey);
407
301
  }
408
302
 
409
303
  async function ensureWorkerGraph(worker, batch, graphByKey) {
@@ -627,10 +521,15 @@ async function runHttpK6Batch(targetConfig, batch) {
627
521
 
628
522
  async function runHttpK6Task(targetConfig, task, baseUrl) {
629
523
  const absFile = path.join(targetConfig.productDir, task.file);
524
+ const bundledFile = await bundleK6File({
525
+ productDir: targetConfig.productDir,
526
+ serviceName: targetConfig.name,
527
+ sourceFile: absFile,
528
+ });
630
529
  console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
631
530
  const startedAt = Date.now();
632
531
  try {
633
- await execa("k6", ["run", "--address", "127.0.0.1:0", "-e", `BASE_URL=${baseUrl}`, absFile], {
532
+ await execa("k6", ["run", "--address", "127.0.0.1:0", "-e", `BASE_URL=${baseUrl}`, bundledFile], {
634
533
  cwd: targetConfig.productDir,
635
534
  env: buildExecutionEnv(targetConfig),
636
535
  stdio: "inherit",
@@ -669,12 +568,17 @@ async function runDalBatch(targetConfig, batch) {
669
568
  async function runDalTask(targetConfig, task, databaseUrl) {
670
569
  const absFile = path.join(targetConfig.productDir, task.file);
671
570
  const k6Binary = resolveDalBinary();
571
+ const bundledFile = await bundleK6File({
572
+ productDir: targetConfig.productDir,
573
+ serviceName: targetConfig.name,
574
+ sourceFile: absFile,
575
+ });
672
576
  console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
673
577
  const startedAt = Date.now();
674
578
  try {
675
579
  await execa(
676
580
  k6Binary,
677
- ["run", "--address", "127.0.0.1:0", "-e", `DATABASE_URL=${databaseUrl}`, absFile],
581
+ ["run", "--address", "127.0.0.1:0", "-e", `DATABASE_URL=${databaseUrl}`, bundledFile],
678
582
  {
679
583
  cwd: targetConfig.productDir,
680
584
  env: buildExecutionEnv(targetConfig),
@@ -812,168 +716,31 @@ async function waitForReady({ name, url, timeoutMs, process }) {
812
716
  }
813
717
 
814
718
  function batchNeedsLocalRuntime(batch) {
815
- return batch.tasks.some((task) => task.type !== "dal");
719
+ return batchNeedsLocalRuntimeModel(batch);
816
720
  }
817
721
 
818
722
  function resolveRuntimeConfigs(targetConfig, configMap) {
819
- const ordered = [];
820
- const visiting = new Set();
821
- const seen = new Set();
822
-
823
- const visit = (config) => {
824
- if (seen.has(config.name)) return;
825
- if (visiting.has(config.name)) {
826
- throw new Error(`Dependency cycle detected involving "${config.name}"`);
827
- }
828
-
829
- visiting.add(config.name);
830
- for (const depName of config.testkit.dependsOn || []) {
831
- const dep = configMap.get(depName);
832
- if (!dep) {
833
- throw new Error(`Service "${config.name}" depends on unknown service "${depName}"`);
834
- }
835
- visit(dep);
836
- }
837
- visiting.delete(config.name);
838
- seen.add(config.name);
839
- ordered.push(config);
840
- };
841
-
842
- visit(targetConfig);
843
- return ordered;
723
+ return resolveRuntimeConfigsModel(targetConfig, configMap);
844
724
  }
845
725
 
846
726
  function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
847
- const types =
848
- suiteType === "all"
849
- ? orderedTypes(Object.keys(config.suites))
850
- : [suiteType === "int" ? "integration" : suiteType];
851
-
852
- const selectedNames = new Set(suiteNames);
853
- const suites = [];
854
- let orderIndex = 0;
855
-
856
- for (const type of types) {
857
- for (const suite of config.suites[type] || []) {
858
- const framework = suite.framework || "k6";
859
- if (selectedNames.size > 0 && !selectedNames.has(suite.name)) continue;
860
- if (frameworkFilter && frameworkFilter !== "all" && framework !== frameworkFilter) continue;
861
-
862
- suites.push({
863
- ...suite,
864
- framework,
865
- type,
866
- orderIndex,
867
- sortKey: `${type}:${suite.name}`,
868
- weight:
869
- suite.testkit?.weight ||
870
- (framework === "playwright"
871
- ? Math.max(2, suite.files.length)
872
- : Math.max(1, suite.files.length)),
873
- maxFileConcurrency:
874
- framework === "k6" ? suite.testkit?.maxFileConcurrency || 1 : 1,
875
- });
876
- orderIndex += 1;
877
- }
878
- }
879
-
880
- return suites;
727
+ return collectSuitesModel(config, suiteType, suiteNames, frameworkFilter);
881
728
  }
882
729
 
883
730
  function applyShard(suites, shard) {
884
- if (!shard) return suites;
885
- return suites.filter((unused, index) => index % shard.total === shard.index - 1);
731
+ return applyShardModel(suites, shard);
886
732
  }
887
733
 
888
734
  function orderedTypes(types) {
889
- const ordered = [];
890
- for (const known of TYPE_ORDER) {
891
- if (types.includes(known)) ordered.push(known);
892
- }
893
- for (const type of types) {
894
- if (!ordered.includes(type)) ordered.push(type);
895
- }
896
- return ordered;
735
+ return orderedTypesModel(types);
897
736
  }
898
737
 
899
738
  function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, workerId, workerStateDir) {
900
- const portMap = buildPortMap(runtimeConfigs, workerId);
901
- const baseUrlByService = new Map();
902
- const readyUrlByService = new Map();
903
-
904
- for (const config of runtimeConfigs) {
905
- if (!config.testkit.local) continue;
906
- baseUrlByService.set(
907
- config.name,
908
- resolveRuntimeUrl(config.testkit.local.baseUrl, config.name, targetConfig, workerId, {
909
- workerStateDir,
910
- portMap,
911
- baseUrlByService,
912
- readyUrlByService,
913
- })
914
- );
915
- readyUrlByService.set(
916
- config.name,
917
- resolveRuntimeUrl(config.testkit.local.readyUrl, config.name, targetConfig, workerId, {
918
- workerStateDir,
919
- portMap,
920
- baseUrlByService,
921
- readyUrlByService,
922
- })
923
- );
924
- }
925
-
926
- const urlMappings = [];
927
- for (const config of runtimeConfigs) {
928
- if (!config.testkit.local) continue;
929
- const resolvedBaseUrl = baseUrlByService.get(config.name);
930
- const resolvedReadyUrl = readyUrlByService.get(config.name);
931
- if (resolvedBaseUrl) {
932
- urlMappings.push([config.testkit.local.baseUrl, resolvedBaseUrl]);
933
- }
934
- if (resolvedReadyUrl) {
935
- urlMappings.push([config.testkit.local.readyUrl, resolvedReadyUrl]);
936
- }
937
- }
938
-
939
- return runtimeConfigs.map((config) =>
940
- resolveWorkerConfig(
941
- config,
942
- targetConfig,
943
- workerId,
944
- workerStateDir,
945
- portMap,
946
- baseUrlByService,
947
- readyUrlByService,
948
- urlMappings
949
- )
950
- );
739
+ return resolveWorkerRuntimeConfigsModel(targetConfig, runtimeConfigs, workerId, workerStateDir);
951
740
  }
952
741
 
953
742
  function buildPortMap(runtimeConfigs, workerId) {
954
- const portMap = new Map();
955
- const seen = new Map();
956
- const offset = PORT_STRIDE * (workerId - 1);
957
-
958
- for (const config of runtimeConfigs) {
959
- if (!config.testkit.local) continue;
960
-
961
- const basePort = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
962
- if (!basePort) continue;
963
-
964
- const actualPort = basePort + offset;
965
- const existing = seen.get(actualPort);
966
- if (existing) {
967
- throw new Error(
968
- `Worker port collision: services "${existing}" and "${config.name}" both resolve to ${actualPort}. ` +
969
- `Assign distinct local.port/baseUrl ports in testkit.config.json.`
970
- );
971
- }
972
- seen.set(actualPort, config.name);
973
- portMap.set(config.name, actualPort);
974
- }
975
-
976
- return portMap;
743
+ return buildPortMapModel(runtimeConfigs, workerId);
977
744
  }
978
745
 
979
746
  function resolveWorkerConfig(
@@ -986,210 +753,48 @@ function resolveWorkerConfig(
986
753
  readyUrlByService,
987
754
  urlMappings
988
755
  ) {
989
- const stateDir = resolveServiceStateDir(workerStateDir, targetConfig.name, config);
990
- const context = {
756
+ return resolveWorkerConfigModel(
757
+ config,
758
+ targetConfig,
991
759
  workerId,
992
- serviceName: config.name,
993
- targetName: targetConfig.name,
994
- serviceStateDir: stateDir,
760
+ workerStateDir,
995
761
  portMap,
996
762
  baseUrlByService,
997
763
  readyUrlByService,
998
- urlMappings,
999
- };
1000
-
1001
- const database = config.testkit.database
1002
- ? {
1003
- ...config.testkit.database,
1004
- branchName:
1005
- config.testkit.database.provider === "neon" &&
1006
- config.testkit.database.branchName !== undefined
1007
- ? finalizeString(config.testkit.database.branchName, context)
1008
- : config.testkit.database.provider === "neon"
1009
- ? `${targetConfig.name}-${config.name}-w${workerId}-testkit`
1010
- : undefined,
1011
- }
1012
- : undefined;
1013
-
1014
- const migrate = config.testkit.migrate
1015
- ? {
1016
- ...config.testkit.migrate,
1017
- cmd: finalizeString(config.testkit.migrate.cmd, context),
1018
- cwd:
1019
- config.testkit.migrate.cwd !== undefined
1020
- ? finalizeString(config.testkit.migrate.cwd, context)
1021
- : config.testkit.migrate.cwd,
1022
- }
1023
- : undefined;
1024
-
1025
- const seed = config.testkit.seed
1026
- ? {
1027
- ...config.testkit.seed,
1028
- cmd: finalizeString(config.testkit.seed.cmd, context),
1029
- cwd:
1030
- config.testkit.seed.cwd !== undefined
1031
- ? finalizeString(config.testkit.seed.cwd, context)
1032
- : config.testkit.seed.cwd,
1033
- }
1034
- : undefined;
1035
-
1036
- const local = config.testkit.local
1037
- ? {
1038
- ...config.testkit.local,
1039
- start: finalizeString(config.testkit.local.start, context),
1040
- cwd:
1041
- config.testkit.local.cwd !== undefined
1042
- ? finalizeString(config.testkit.local.cwd, context)
1043
- : config.testkit.local.cwd,
1044
- port: portMap.get(config.name) || config.testkit.local.port,
1045
- baseUrl: baseUrlByService.get(config.name),
1046
- readyUrl: readyUrlByService.get(config.name),
1047
- env: Object.fromEntries(
1048
- Object.entries(config.testkit.local.env || {}).map(([key, value]) => [
1049
- key,
1050
- finalizeString(String(value), context),
1051
- ])
1052
- ),
1053
- }
1054
- : undefined;
1055
-
1056
- return {
1057
- ...config,
1058
- stateDir,
1059
- workerId,
1060
- workerLabel: `w${workerId}`,
1061
- targetName: targetConfig.name,
1062
- testkit: {
1063
- ...config.testkit,
1064
- database,
1065
- migrate,
1066
- seed,
1067
- local,
1068
- },
1069
- };
764
+ urlMappings
765
+ );
1070
766
  }
1071
767
 
1072
768
  function resolveServiceStateDir(workerStateDir, targetName, config) {
1073
- const dbSource = config.testkit.databaseFrom || config.name;
1074
- return getWorkerServiceStateDir(workerStateDir, targetName, dbSource);
769
+ return resolveServiceStateDirModel(workerStateDir, targetName, config);
1075
770
  }
1076
771
 
1077
772
  function getWorkerServiceStateDir(workerStateDir, targetName, serviceName) {
1078
- if (targetName === serviceName) {
1079
- return workerStateDir;
1080
- }
1081
- return path.join(workerStateDir, "deps", serviceName);
773
+ return getWorkerServiceStateDirModel(workerStateDir, targetName, serviceName);
1082
774
  }
1083
775
 
1084
776
  function buildExecutionEnv(config, extraEnv = {}) {
1085
- return {
1086
- ...process.env,
1087
- ...(config.testkit.serviceEnv || {}),
1088
- ...extraEnv,
1089
- };
777
+ return buildExecutionEnvModel(config, extraEnv, process.env);
1090
778
  }
1091
779
 
1092
780
  function buildPlaywrightEnv(config, baseUrl) {
1093
- return buildExecutionEnv(config, {
1094
- BASE_URL: baseUrl,
1095
- PLAYWRIGHT_HTML_OPEN: "never",
1096
- PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS:
1097
- process.env.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS || "1",
1098
- TESTKIT_MANAGED_SERVERS: "1",
1099
- TESTKIT_WORKER_ID: String(config.workerId),
1100
- });
781
+ return buildPlaywrightEnvModel(config, baseUrl, process.env);
1101
782
  }
1102
783
 
1103
784
  function recordTaskOutcome(trackers, task, outcome) {
1104
- const tracker = trackers.get(task.serviceName);
1105
- if (!tracker || tracker.skipped) return;
1106
-
1107
- const finishedAt = Date.now();
1108
- if (!tracker.firstTaskAt) tracker.firstTaskAt = finishedAt;
1109
- tracker.lastTaskAt = finishedAt;
1110
-
1111
- const suite = tracker.suitesByKey.get(task.suiteKey);
1112
- if (!suite) return;
1113
-
1114
- suite.completedFileCount += 1;
1115
- suite.durationMs += outcome.durationMs;
1116
- if (outcome.failed && !suite.failedFileSet.has(task.file)) {
1117
- suite.failedFileSet.add(task.file);
1118
- suite.failedFiles.push(task.file);
1119
- }
1120
- if (outcome.error && !suite.error) {
1121
- suite.error = outcome.error;
1122
- }
785
+ return recordTaskOutcomeModel(trackers, task, outcome);
1123
786
  }
1124
787
 
1125
788
  function recordGraphError(trackers, graph, message) {
1126
- const targetNames = graph?.assignedTargets || [];
1127
- for (const targetName of targetNames) {
1128
- const tracker = trackers.get(targetName);
1129
- if (tracker && !tracker.skipped) {
1130
- addTrackerError(tracker, message);
1131
- tracker.lastTaskAt = Date.now();
1132
- }
1133
- }
789
+ return recordGraphErrorModel(trackers, graph, message);
1134
790
  }
1135
791
 
1136
792
  function addTrackerError(tracker, message) {
1137
- if (tracker.errorSet.has(message)) return;
1138
- tracker.errorSet.add(message);
1139
- tracker.errors.push(message);
793
+ return addTrackerErrorModel(tracker, message);
1140
794
  }
1141
795
 
1142
796
  function finalizeServiceResult(tracker, startedAt, finishedAt) {
1143
- if (!tracker || tracker.skipped) {
1144
- return {
1145
- name: tracker?.name || "unknown",
1146
- failed: false,
1147
- skipped: true,
1148
- suiteCount: 0,
1149
- completedSuiteCount: 0,
1150
- failedSuiteCount: 0,
1151
- durationMs: 0,
1152
- suites: [],
1153
- errors: [],
1154
- };
1155
- }
1156
-
1157
- const suites = [...tracker.suites].sort(
1158
- (a, b) => a.orderIndex - b.orderIndex || a.name.localeCompare(b.name)
1159
- );
1160
- const completedSuiteCount = suites.filter(
1161
- (suite) => suite.completedFileCount === suite.fileCount
1162
- ).length;
1163
- const failedSuiteCount = suites.filter((suite) => suite.failedFiles.length > 0).length;
1164
- const accumulatedDurationMs = suites.reduce((sum, suite) => sum + suite.durationMs, 0);
1165
- const durationMs =
1166
- tracker.firstTaskAt && tracker.lastTaskAt
1167
- ? Math.max(tracker.lastTaskAt - tracker.firstTaskAt, accumulatedDurationMs)
1168
- : Math.max(finishedAt - startedAt, accumulatedDurationMs);
1169
-
1170
- return {
1171
- name: tracker.name,
1172
- failed:
1173
- failedSuiteCount > 0 ||
1174
- tracker.errors.length > 0 ||
1175
- completedSuiteCount < tracker.suiteCount,
1176
- skipped: false,
1177
- suiteCount: tracker.suiteCount,
1178
- completedSuiteCount,
1179
- failedSuiteCount,
1180
- durationMs,
1181
- suites: suites.map((suite) => ({
1182
- name: suite.name,
1183
- type: suite.type,
1184
- framework: suite.framework,
1185
- failed: suite.failedFiles.length > 0,
1186
- fileCount: suite.fileCount,
1187
- failedFiles: suite.failedFiles,
1188
- durationMs: suite.durationMs,
1189
- error: suite.error,
1190
- })),
1191
- errors: tracker.errors,
1192
- };
797
+ return finalizeServiceResultModel(tracker, startedAt, finishedAt);
1193
798
  }
1194
799
 
1195
800
  function printRunSummary(results, durationMs) {
@@ -1251,86 +856,121 @@ function printRunSummary(results, durationMs) {
1251
856
  console.log("\nResult: PASSED");
1252
857
  }
1253
858
 
859
+ async function reportTelemetry(telemetry, artifact) {
860
+ if (!telemetry?.enabled) return;
861
+
862
+ try {
863
+ const outcome = await uploadTelemetryArtifact(telemetry, artifact);
864
+ if (outcome?.ok) {
865
+ console.log("Telemetry: uploaded run artifact");
866
+ return;
867
+ }
868
+ if (outcome?.reason === "missing-token") {
869
+ console.log(
870
+ `Telemetry: skipped upload because ${telemetry.tokenEnv || "configured token env"} is not set`
871
+ );
872
+ return;
873
+ }
874
+ if (outcome?.reason && !outcome.skipped) return;
875
+ } catch (error) {
876
+ console.log(`Telemetry: upload failed (${formatError(error)})`);
877
+ }
878
+ }
879
+
1254
880
  function longestServiceName(results) {
1255
- return results.reduce((max, result) => Math.max(max, result.name.length), 4);
881
+ return longestServiceNameModel(results);
882
+ }
883
+
884
+ function buildRunArtifact({
885
+ productDir,
886
+ results,
887
+ startedAt,
888
+ finishedAt,
889
+ requestedJobs,
890
+ workerCount,
891
+ suiteType,
892
+ suiteNames,
893
+ framework,
894
+ shard,
895
+ serviceFilter,
896
+ }) {
897
+ return buildRunArtifactModel({
898
+ productDir,
899
+ results,
900
+ startedAt,
901
+ finishedAt,
902
+ requestedJobs,
903
+ workerCount,
904
+ suiteType,
905
+ suiteNames,
906
+ framework,
907
+ shard,
908
+ serviceFilter,
909
+ metadata: {
910
+ git: collectGitMetadata(productDir),
911
+ host: {
912
+ hostname: safeHostname(),
913
+ username: safeUsername(),
914
+ },
915
+ testkitVersion: readPackageMetadata().version,
916
+ },
917
+ });
918
+ }
919
+
920
+ function writeRunArtifact(productDir, artifact) {
921
+ const resultsDir = path.join(productDir, ".testkit", "results");
922
+ fs.mkdirSync(resultsDir, { recursive: true });
923
+ fs.writeFileSync(path.join(resultsDir, "latest.json"), JSON.stringify(artifact, null, 2));
924
+ }
925
+
926
+ function summarizeDbBackend(results) {
927
+ return summarizeDbBackendModel(results);
928
+ }
929
+
930
+ function collectGitMetadata(productDir) {
931
+ return collectGitMetadataModel(productDir);
932
+ }
933
+
934
+ function readPackageMetadata() {
935
+ return readPackageMetadataModel();
936
+ }
937
+
938
+ function safeHostname() {
939
+ return safeHostnameModel();
940
+ }
941
+
942
+ function safeUsername() {
943
+ return safeUsernameModel();
1256
944
  }
1257
945
 
1258
946
  function formatDuration(durationMs) {
1259
- const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
1260
- const minutes = Math.floor(totalSeconds / 60);
1261
- const seconds = totalSeconds % 60;
1262
- if (minutes === 0) return `${seconds}s`;
1263
- return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
947
+ return formatDurationModel(durationMs);
1264
948
  }
1265
949
 
1266
950
  function formatServiceSummary(result) {
1267
- const passedSuites = result.completedSuiteCount - result.failedSuiteCount;
1268
- const notRun = result.suiteCount - result.completedSuiteCount;
1269
- let detail = `${passedSuites}/${result.suiteCount} suites passed`;
1270
- if (notRun > 0) {
1271
- detail += `, ${notRun} not run`;
1272
- }
1273
- return detail;
951
+ return formatServiceSummaryModel(result);
1274
952
  }
1275
953
 
1276
954
  function formatError(error) {
1277
- if (error instanceof Error) return error.message;
1278
- return String(error);
955
+ return formatErrorModel(error);
1279
956
  }
1280
957
 
1281
958
  function loadTimings(productDir) {
1282
959
  const filePath = path.join(productDir, ".testkit", TIMINGS_FILENAME);
1283
960
  if (!fs.existsSync(filePath)) {
1284
- return {
1285
- version: 1,
1286
- files: {},
1287
- };
961
+ return createEmptyTimings();
1288
962
  }
1289
963
 
1290
964
  try {
1291
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
1292
- return {
1293
- version: 1,
1294
- files: parsed.files && typeof parsed.files === "object" ? parsed.files : {},
1295
- };
965
+ return normalizeTimings(JSON.parse(fs.readFileSync(filePath, "utf8")));
1296
966
  } catch {
1297
- return {
1298
- version: 1,
1299
- files: {},
1300
- };
967
+ return createEmptyTimings();
1301
968
  }
1302
969
  }
1303
970
 
1304
971
  function saveTimings(productDir, timings, updates) {
1305
972
  if (updates.length === 0) return;
1306
-
1307
- const next = {
1308
- version: 1,
1309
- files: { ...timings.files },
1310
- };
1311
-
1312
- for (const update of updates) {
1313
- const existing = next.files[update.key];
1314
- if (!existing) {
1315
- next.files[update.key] = {
1316
- durationMs: Math.max(1, Math.round(update.durationMs)),
1317
- runs: 1,
1318
- updatedAt: new Date().toISOString(),
1319
- };
1320
- continue;
1321
- }
1322
-
1323
- const runs = Number(existing.runs || 0) + 1;
1324
- const durationMs = Math.max(
1325
- 1,
1326
- Math.round(((existing.durationMs || update.durationMs) * (runs - 1) + update.durationMs) / runs)
1327
- );
1328
- next.files[update.key] = {
1329
- durationMs,
1330
- runs,
1331
- updatedAt: new Date().toISOString(),
1332
- };
1333
- }
973
+ const next = applyTimingUpdates(timings, updates);
1334
974
 
1335
975
  const rootDir = path.join(productDir, ".testkit");
1336
976
  fs.mkdirSync(rootDir, { recursive: true });
@@ -1341,140 +981,51 @@ function saveTimings(productDir, timings, updates) {
1341
981
  }
1342
982
 
1343
983
  function estimateTaskDuration(timings, timingKey, suite) {
1344
- const cached = timings.files[timingKey];
1345
- if (cached?.durationMs) return cached.durationMs;
1346
-
1347
- const base =
1348
- suite.framework === "playwright"
1349
- ? 20_000
1350
- : suite.type === "dal"
1351
- ? 4_000
1352
- : 8_000;
1353
- return Math.max(1_000, Math.round((base * suite.weight) / Math.max(1, suite.files.length)));
984
+ return estimateTaskDurationModel(timings, timingKey, suite);
1354
985
  }
1355
986
 
1356
987
  function buildTimingKey(serviceName, suite, file) {
1357
- return [
1358
- serviceName,
1359
- suite.framework,
1360
- suite.type,
1361
- normalizePathSeparators(file),
1362
- ].join("|");
988
+ return buildTimingKeyModel(serviceName, suite, file);
1363
989
  }
1364
990
 
1365
991
  function parsePlaywrightJsonResults(stdout, cwd) {
1366
- if (!stdout.trim()) {
1367
- return { fileResults: new Map(), errors: [] };
1368
- }
1369
-
1370
- let parsed;
1371
- try {
1372
- parsed = JSON.parse(stdout);
1373
- } catch (error) {
1374
- return {
1375
- fileResults: new Map(),
1376
- errors: [`Could not parse Playwright JSON output: ${formatError(error)}`],
1377
- };
1378
- }
1379
-
1380
- const fileResults = new Map();
1381
- visitPlaywrightSuites(parsed.suites || [], null, fileResults, cwd);
1382
- return {
1383
- fileResults,
1384
- errors: (parsed.errors || []).map(formatPlaywrightReporterError).filter(Boolean),
1385
- };
992
+ return parsePlaywrightJsonResultsModel(stdout, cwd);
1386
993
  }
1387
994
 
1388
995
  function visitPlaywrightSuites(suites, inheritedFile, fileResults, cwd) {
1389
- for (const suite of suites || []) {
1390
- const suiteFile = normalizeReportedFile(extractReporterFile(suite) || inheritedFile, cwd);
1391
- for (const child of suite.suites || []) {
1392
- visitPlaywrightSuites([child], suiteFile, fileResults, cwd);
1393
- }
1394
- for (const spec of suite.specs || []) {
1395
- collectPlaywrightSpec(spec, suiteFile, fileResults, cwd);
1396
- }
1397
- }
996
+ return visitPlaywrightSuitesModel(suites, inheritedFile, fileResults, cwd);
1398
997
  }
1399
998
 
1400
999
  function collectPlaywrightSpec(spec, inheritedFile, fileResults, cwd) {
1401
- const file = normalizeReportedFile(extractReporterFile(spec) || inheritedFile, cwd);
1402
- if (!file) return;
1403
-
1404
- const current = fileResults.get(file) || {
1405
- failed: false,
1406
- error: null,
1407
- durationMs: 0,
1408
- };
1409
-
1410
- for (const test of spec.tests || []) {
1411
- const results = Array.isArray(test.results) ? test.results : [];
1412
- current.durationMs += results.reduce(
1413
- (sum, result) => sum + Number(result?.duration || 0),
1414
- 0
1415
- );
1416
-
1417
- const final = choosePlaywrightFinalResult(results);
1418
- const failed =
1419
- test.outcome === "unexpected" ||
1420
- !isPlaywrightPassingStatus(final?.status);
1421
-
1422
- if (failed) {
1423
- current.failed = true;
1424
- current.error ||= extractPlaywrightFailure(final, spec, test);
1425
- }
1426
- }
1427
-
1428
- fileResults.set(file, current);
1000
+ return collectPlaywrightSpecModel(spec, inheritedFile, fileResults, cwd);
1429
1001
  }
1430
1002
 
1431
1003
  function choosePlaywrightFinalResult(results) {
1432
- if (!results || results.length === 0) return null;
1433
- return results[results.length - 1];
1004
+ return choosePlaywrightFinalResultModel(results);
1434
1005
  }
1435
1006
 
1436
1007
  function isPlaywrightPassingStatus(status) {
1437
- return !status || ["passed", "skipped", "expected"].includes(status);
1008
+ return isPlaywrightPassingStatusModel(status);
1438
1009
  }
1439
1010
 
1440
1011
  function extractPlaywrightFailure(finalResult, spec, test) {
1441
- const fromResult =
1442
- finalResult?.error?.message ||
1443
- finalResult?.error?.value ||
1444
- finalResult?.error?.stack;
1445
- if (fromResult) return firstLine(fromResult);
1446
-
1447
- const fromTest = test?.errors?.[0]?.message;
1448
- if (fromTest) return firstLine(fromTest);
1449
-
1450
- return firstLine(spec?.title || "Playwright test failed");
1012
+ return extractPlaywrightFailureModel(finalResult, spec, test);
1451
1013
  }
1452
1014
 
1453
1015
  function formatPlaywrightReporterError(error) {
1454
- if (!error) return null;
1455
- if (typeof error === "string") return firstLine(error);
1456
- if (typeof error.message === "string") return firstLine(error.message);
1457
- if (typeof error.value === "string") return firstLine(error.value);
1458
- return null;
1016
+ return formatPlaywrightReporterErrorModel(error);
1459
1017
  }
1460
1018
 
1461
1019
  function extractReporterFile(node) {
1462
- if (!node || typeof node !== "object") return null;
1463
- if (typeof node.file === "string" && node.file.length > 0) return node.file;
1464
- if (node.location && typeof node.location.file === "string" && node.location.file.length > 0) {
1465
- return node.location.file;
1466
- }
1467
- return null;
1020
+ return extractReporterFileModel(node);
1468
1021
  }
1469
1022
 
1470
1023
  function normalizeReportedFile(filePath, cwd) {
1471
- if (!filePath) return null;
1472
- const absolute = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
1473
- return normalizePathSeparators(path.relative(cwd, absolute));
1024
+ return normalizeReportedFileModel(filePath, cwd);
1474
1025
  }
1475
1026
 
1476
1027
  function firstLine(value) {
1477
- return String(value).split(/\r?\n/).find((line) => line.trim().length > 0)?.trim() || null;
1028
+ return firstLineModel(value);
1478
1029
  }
1479
1030
 
1480
1031
  function printBufferedOutput(output, prefix) {
@@ -1486,95 +1037,23 @@ function printBufferedOutput(output, prefix) {
1486
1037
  }
1487
1038
 
1488
1039
  function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, workerId, context) {
1489
- const resolved = resolveTemplateString(rawUrl, {
1490
- ...context,
1491
- targetName: targetConfig.name,
1492
- workerId,
1493
- serviceName,
1494
- });
1495
- const actualPort = context.portMap.get(serviceName);
1496
- return actualPort ? rewriteUrlPort(resolved, actualPort) : resolved;
1040
+ return resolveRuntimeUrlModel(rawUrl, serviceName, targetConfig, workerId, context);
1497
1041
  }
1498
1042
 
1499
1043
  function finalizeString(value, context) {
1500
- let resolved = resolveTemplateString(value, context);
1501
- for (const [source, destination] of context.urlMappings || []) {
1502
- if (source && destination && source !== destination) {
1503
- resolved = resolved.split(source).join(destination);
1504
- }
1505
- }
1506
- return resolved;
1044
+ return finalizeStringModel(value, context);
1507
1045
  }
1508
1046
 
1509
1047
  function resolveTemplateString(value, context) {
1510
- if (typeof value !== "string") return value;
1511
-
1512
- return value.replace(/\{([a-zA-Z]+)(?::([a-zA-Z0-9_-]+))?\}/g, (_match, token, arg) => {
1513
- switch (token) {
1514
- case "worker":
1515
- return String(context.workerId);
1516
- case "target":
1517
- return context.targetName;
1518
- case "service":
1519
- return context.serviceName;
1520
- case "stateDir":
1521
- return context.serviceStateDir;
1522
- case "port": {
1523
- const serviceName = arg || context.serviceName;
1524
- const port = context.portMap.get(serviceName);
1525
- if (!port) {
1526
- throw new Error(`Unknown port placeholder for service "${serviceName}"`);
1527
- }
1528
- return String(port);
1529
- }
1530
- case "baseUrl": {
1531
- const serviceName = arg || context.serviceName;
1532
- const baseUrl = context.baseUrlByService.get(serviceName);
1533
- if (!baseUrl) {
1534
- throw new Error(`Unknown baseUrl placeholder for service "${serviceName}"`);
1535
- }
1536
- return baseUrl;
1537
- }
1538
- case "readyUrl": {
1539
- const serviceName = arg || context.serviceName;
1540
- const readyUrl = context.readyUrlByService.get(serviceName);
1541
- if (!readyUrl) {
1542
- throw new Error(`Unknown readyUrl placeholder for service "${serviceName}"`);
1543
- }
1544
- return readyUrl;
1545
- }
1546
- default:
1547
- throw new Error(`Unsupported template token "{${token}${arg ? `:${arg}` : ""}}"`);
1548
- }
1549
- });
1048
+ return resolveTemplateStringModel(value, context);
1550
1049
  }
1551
1050
 
1552
1051
  function rewriteUrlPort(rawUrl, port) {
1553
- try {
1554
- const original = new URL(rawUrl);
1555
- if (!original.port) return rawUrl;
1556
-
1557
- const rewritten = new URL(rawUrl);
1558
- rewritten.port = String(port);
1559
-
1560
- let next = rewritten.toString();
1561
- if (!rawUrl.endsWith("/") && original.pathname === "/" && next.endsWith("/")) {
1562
- next = next.slice(0, -1);
1563
- }
1564
- return next;
1565
- } catch {
1566
- return rawUrl;
1567
- }
1052
+ return rewriteUrlPortModel(rawUrl, port);
1568
1053
  }
1569
1054
 
1570
1055
  function numericPortFromUrl(rawUrl) {
1571
- try {
1572
- const url = new URL(rawUrl);
1573
- const port = Number(url.port);
1574
- return Number.isInteger(port) && port > 0 ? port : null;
1575
- } catch {
1576
- return null;
1577
- }
1056
+ return numericPortFromUrlModel(rawUrl);
1578
1057
  }
1579
1058
 
1580
1059
  async function assertLocalServicePortsAvailable(config) {
@@ -1599,22 +1078,11 @@ async function assertLocalServicePortsAvailable(config) {
1599
1078
  }
1600
1079
 
1601
1080
  function socketFromUrl(rawUrl) {
1602
- try {
1603
- const url = new URL(rawUrl);
1604
- const port = Number(url.port);
1605
- if (!Number.isInteger(port) || port <= 0) return null;
1606
-
1607
- const host = normalizeSocketHost(url.hostname);
1608
- return host ? { host, port } : null;
1609
- } catch {
1610
- return null;
1611
- }
1081
+ return socketFromUrlModel(rawUrl);
1612
1082
  }
1613
1083
 
1614
1084
  function normalizeSocketHost(hostname) {
1615
- if (!hostname || hostname === "localhost") return "127.0.0.1";
1616
- if (hostname === "[::1]") return "::1";
1617
- return hostname;
1085
+ return normalizeSocketHostModel(hostname);
1618
1086
  }
1619
1087
 
1620
1088
  async function isPortInUse({ host, port }) {
@@ -1728,82 +1196,31 @@ function sleep(ms) {
1728
1196
  }
1729
1197
 
1730
1198
  function findRuntimeStateDirs(rootDir) {
1731
- const found = [];
1732
-
1733
- const visit = (dir) => {
1734
- if (!fs.existsSync(dir)) return;
1735
- const entries = fs.readdirSync(dir, { withFileTypes: true });
1736
- if (isDatabaseStateDir(dir)) {
1737
- found.push(dir);
1738
- }
1739
-
1740
- for (const entry of entries) {
1741
- if (entry.isDirectory()) {
1742
- visit(path.join(dir, entry.name));
1743
- }
1744
- }
1745
- };
1746
-
1747
- visit(rootDir);
1748
- return found.sort((a, b) => b.length - a.length);
1199
+ return findRuntimeStateDirsModel(rootDir, isDatabaseStateDir);
1749
1200
  }
1750
1201
 
1751
1202
  function findGraphDirsForService(productDir, serviceName) {
1752
- const graphsRoot = path.join(productDir, ".testkit", "_graphs");
1753
- if (!fs.existsSync(graphsRoot)) return [];
1754
-
1755
- const matches = [];
1756
- for (const entry of fs.readdirSync(graphsRoot, { withFileTypes: true })) {
1757
- if (!entry.isDirectory()) continue;
1758
- const graphDir = path.join(graphsRoot, entry.name);
1759
- const metadata = readGraphMetadata(graphDir);
1760
- if (!metadata) continue;
1761
- if ((metadata.runtimeServices || []).includes(serviceName)) {
1762
- matches.push(graphDir);
1763
- }
1764
- }
1765
-
1766
- return matches.sort();
1203
+ return findGraphDirsForServiceModel(productDir, serviceName);
1767
1204
  }
1768
1205
 
1769
1206
  function writeGraphMetadata(graphDir, graph) {
1770
- fs.mkdirSync(graphDir, { recursive: true });
1771
- const metadata = {
1772
- runtimeServices: graph.runtimeNames,
1773
- assignedTargets: [...graph.assignedTargets].sort(),
1774
- rootService: graph.rootConfig.name,
1775
- };
1776
- fs.writeFileSync(
1777
- path.join(graphDir, GRAPH_METADATA),
1778
- JSON.stringify(metadata, null, 2)
1779
- );
1207
+ return writeGraphMetadataModel(graphDir, graph);
1780
1208
  }
1781
1209
 
1782
1210
  function readGraphMetadata(graphDir) {
1783
- const metadataPath = path.join(graphDir, GRAPH_METADATA);
1784
- if (!fs.existsSync(metadataPath)) return null;
1785
-
1786
- try {
1787
- return JSON.parse(fs.readFileSync(metadataPath, "utf8"));
1788
- } catch {
1789
- return null;
1790
- }
1211
+ return readGraphMetadataModel(graphDir);
1791
1212
  }
1792
1213
 
1793
1214
  function isRuntimeSuperset(candidate, target) {
1794
- return target.every((name) => candidate.includes(name));
1215
+ return isRuntimeSupersetModel(candidate, target);
1795
1216
  }
1796
1217
 
1797
1218
  function compareGraphsForAssignment(left, right) {
1798
- if (left.runtimeNames.length !== right.runtimeNames.length) {
1799
- return left.runtimeNames.length - right.runtimeNames.length;
1800
- }
1801
- return left.key.localeCompare(right.key);
1219
+ return compareGraphsForAssignmentModel(left, right);
1802
1220
  }
1803
1221
 
1804
1222
  function buildGraphDirName(runtimeNames) {
1805
- const slug = runtimeNames.map(slugSegment).join("__");
1806
- return slug.length > 0 ? slug : "graph";
1223
+ return buildGraphDirNameModel(runtimeNames);
1807
1224
  }
1808
1225
 
1809
1226
  function slugSegment(value) {
@@ -1811,5 +1228,5 @@ function slugSegment(value) {
1811
1228
  }
1812
1229
 
1813
1230
  function normalizePathSeparators(filePath) {
1814
- return filePath.split(path.sep).join("/");
1231
+ return normalizePathSeparatorsModel(filePath);
1815
1232
  }