@elench/testkit 0.1.17 → 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 (38) hide show
  1. package/README.md +36 -15
  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.mjs → runner/index.mjs} +239 -833
  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
@@ -3,7 +3,7 @@ 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 { resolveDalBinary, resolveServiceCwd } from "../config/index.mjs";
7
7
  import {
8
8
  cleanupOrphanedLocalInfrastructure,
9
9
  destroyRuntimeDatabase,
@@ -11,28 +11,102 @@ import {
11
11
  isDatabaseStateDir,
12
12
  prepareDatabaseRuntime,
13
13
  showServiceDatabaseStatus,
14
- } from "./database.mjs";
14
+ } from "../database/index.mjs";
15
+ import {
16
+ batchNeedsLocalRuntime as batchNeedsLocalRuntimeModel,
17
+ buildGraphDirName as buildGraphDirNameModel,
18
+ buildRuntimeGraphs as buildRuntimeGraphsModel,
19
+ buildTaskQueue as buildTaskQueueModel,
20
+ claimNextBatch as claimNextBatchModel,
21
+ collectSuites as collectSuitesModel,
22
+ compareGraphsForAssignment as compareGraphsForAssignmentModel,
23
+ isRuntimeSuperset as isRuntimeSupersetModel,
24
+ orderedTypes as orderedTypesModel,
25
+ resolveRuntimeConfigs as resolveRuntimeConfigsModel,
26
+ applyShard as applyShardModel,
27
+ } from "./planning.mjs";
28
+ import {
29
+ buildExecutionEnv as buildExecutionEnvModel,
30
+ buildPlaywrightEnv as buildPlaywrightEnvModel,
31
+ buildPortMap as buildPortMapModel,
32
+ finalizeString as finalizeStringModel,
33
+ getWorkerServiceStateDir as getWorkerServiceStateDirModel,
34
+ normalizeSocketHost as normalizeSocketHostModel,
35
+ numericPortFromUrl as numericPortFromUrlModel,
36
+ resolveRuntimeUrl as resolveRuntimeUrlModel,
37
+ resolveServiceStateDir as resolveServiceStateDirModel,
38
+ resolveTemplateString as resolveTemplateStringModel,
39
+ resolveWorkerConfig as resolveWorkerConfigModel,
40
+ resolveWorkerRuntimeConfigs as resolveWorkerRuntimeConfigsModel,
41
+ rewriteUrlPort as rewriteUrlPortModel,
42
+ socketFromUrl as socketFromUrlModel,
43
+ } from "./template.mjs";
44
+ import {
45
+ addTrackerError as addTrackerErrorModel,
46
+ buildRunArtifact as buildRunArtifactModel,
47
+ buildServiceTrackers as buildServiceTrackersModel,
48
+ finalizeServiceResult as finalizeServiceResultModel,
49
+ formatDuration as formatDurationModel,
50
+ formatError as formatErrorModel,
51
+ formatServiceSummary as formatServiceSummaryModel,
52
+ longestServiceName as longestServiceNameModel,
53
+ recordGraphError as recordGraphErrorModel,
54
+ recordTaskOutcome as recordTaskOutcomeModel,
55
+ summarizeDbBackend as summarizeDbBackendModel,
56
+ } from "./results.mjs";
57
+ import {
58
+ applyTimingUpdates,
59
+ buildTimingKey as buildTimingKeyModel,
60
+ createEmptyTimings,
61
+ estimateTaskDuration as estimateTaskDurationModel,
62
+ normalizeTimings,
63
+ } from "../timing/index.mjs";
64
+ import {
65
+ choosePlaywrightFinalResult as choosePlaywrightFinalResultModel,
66
+ collectPlaywrightSpec as collectPlaywrightSpecModel,
67
+ extractPlaywrightFailure as extractPlaywrightFailureModel,
68
+ extractReporterFile as extractReporterFileModel,
69
+ firstLine as firstLineModel,
70
+ formatPlaywrightReporterError as formatPlaywrightReporterErrorModel,
71
+ isPlaywrightPassingStatus as isPlaywrightPassingStatusModel,
72
+ normalizeReportedFile as normalizeReportedFileModel,
73
+ parsePlaywrightJsonResults as parsePlaywrightJsonResultsModel,
74
+ visitPlaywrightSuites as visitPlaywrightSuitesModel,
75
+ } from "../reporters/playwright.mjs";
76
+ import {
77
+ collectGitMetadata as collectGitMetadataModel,
78
+ readPackageMetadata as readPackageMetadataModel,
79
+ safeHostname as safeHostnameModel,
80
+ safeUsername as safeUsernameModel,
81
+ } from "./metadata.mjs";
82
+ import {
83
+ findGraphDirsForService as findGraphDirsForServiceModel,
84
+ findRuntimeStateDirs as findRuntimeStateDirsModel,
85
+ normalizePathSeparators as normalizePathSeparatorsModel,
86
+ readGraphMetadata as readGraphMetadataModel,
87
+ writeGraphMetadata as writeGraphMetadataModel,
88
+ } from "./state.mjs";
89
+ import { uploadTelemetryArtifact } from "../telemetry/index.mjs";
15
90
 
16
- const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
17
91
  const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
18
92
  const DEFAULT_READY_TIMEOUT_MS = 120_000;
19
- const PORT_STRIDE = 100;
20
93
  const TIMINGS_FILENAME = "timings.json";
21
- const GRAPH_METADATA = "graph.json";
22
94
 
23
95
  export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
24
96
  const configMap = new Map(allConfigs.map((config) => [config.name, config]));
25
97
  const startedAt = Date.now();
98
+ const telemetry = configs[0]?.telemetry || null;
26
99
  const servicePlans = collectServicePlans(configs, configMap, suiteType, suiteNames, opts);
27
100
  const trackers = buildServiceTrackers(servicePlans, startedAt);
28
101
  const executedPlans = servicePlans.filter((plan) => !plan.skipped);
102
+ let workerCount = 0;
29
103
 
30
104
  if (executedPlans.length > 0) {
31
105
  const productDir = executedPlans[0].config.productDir;
32
106
  const timings = loadTimings(productDir);
33
107
  const graphs = buildRuntimeGraphs(executedPlans);
34
108
  const queue = buildTaskQueue(executedPlans, graphs, timings);
35
- const workerCount = Math.max(1, Math.min(opts.jobs || 1, queue.length));
109
+ workerCount = Math.max(1, Math.min(opts.jobs || 1, queue.length));
36
110
  const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
37
111
  const workers = Array.from({ length: workerCount }, (_unused, index) =>
38
112
  createWorker(index + 1, productDir)
@@ -61,8 +135,24 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
61
135
  const results = configs.map((config) =>
62
136
  finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
63
137
  );
138
+ const artifact = buildRunArtifact({
139
+ productDir: configs[0]?.productDir || process.cwd(),
140
+ results,
141
+ startedAt,
142
+ finishedAt,
143
+ requestedJobs: opts.jobs || 1,
144
+ workerCount,
145
+ suiteType,
146
+ suiteNames,
147
+ framework: opts.framework || "all",
148
+ shard: opts.shard || null,
149
+ serviceFilter: configs.length === 1 ? configs[0].name : null,
150
+ });
151
+
152
+ writeRunArtifact(configs[0]?.productDir || process.cwd(), artifact);
64
153
 
65
154
  printRunSummary(results, finishedAt - startedAt);
155
+ await reportTelemetry(telemetry, artifact);
66
156
  if (results.some((result) => result.failed)) process.exit(1);
67
157
  }
68
158
 
@@ -141,161 +231,15 @@ function collectServicePlans(configs, configMap, suiteType, suiteNames, opts) {
141
231
  }
142
232
 
143
233
  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;
234
+ return buildServiceTrackersModel(servicePlans, startedAt);
192
235
  }
193
236
 
194
237
  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));
238
+ return buildRuntimeGraphsModel(servicePlans);
251
239
  }
252
240
 
253
241
  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
- );
242
+ return buildTaskQueueModel(servicePlans, graphs, timings);
299
243
  }
300
244
 
301
245
  function createWorker(workerId, productDir) {
@@ -352,58 +296,7 @@ async function runWorker(worker, queue, graphByKey, trackers, timingUpdates) {
352
296
  }
353
297
 
354
298
  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
- };
299
+ return claimNextBatchModel(queue, preferredGraphKey);
407
300
  }
408
301
 
409
302
  async function ensureWorkerGraph(worker, batch, graphByKey) {
@@ -812,168 +705,31 @@ async function waitForReady({ name, url, timeoutMs, process }) {
812
705
  }
813
706
 
814
707
  function batchNeedsLocalRuntime(batch) {
815
- return batch.tasks.some((task) => task.type !== "dal");
708
+ return batchNeedsLocalRuntimeModel(batch);
816
709
  }
817
710
 
818
711
  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;
712
+ return resolveRuntimeConfigsModel(targetConfig, configMap);
844
713
  }
845
714
 
846
715
  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;
716
+ return collectSuitesModel(config, suiteType, suiteNames, frameworkFilter);
881
717
  }
882
718
 
883
719
  function applyShard(suites, shard) {
884
- if (!shard) return suites;
885
- return suites.filter((unused, index) => index % shard.total === shard.index - 1);
720
+ return applyShardModel(suites, shard);
886
721
  }
887
722
 
888
723
  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;
724
+ return orderedTypesModel(types);
897
725
  }
898
726
 
899
727
  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
- );
728
+ return resolveWorkerRuntimeConfigsModel(targetConfig, runtimeConfigs, workerId, workerStateDir);
951
729
  }
952
730
 
953
731
  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;
732
+ return buildPortMapModel(runtimeConfigs, workerId);
977
733
  }
978
734
 
979
735
  function resolveWorkerConfig(
@@ -986,210 +742,48 @@ function resolveWorkerConfig(
986
742
  readyUrlByService,
987
743
  urlMappings
988
744
  ) {
989
- const stateDir = resolveServiceStateDir(workerStateDir, targetConfig.name, config);
990
- const context = {
745
+ return resolveWorkerConfigModel(
746
+ config,
747
+ targetConfig,
991
748
  workerId,
992
- serviceName: config.name,
993
- targetName: targetConfig.name,
994
- serviceStateDir: stateDir,
749
+ workerStateDir,
995
750
  portMap,
996
751
  baseUrlByService,
997
752
  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
- };
753
+ urlMappings
754
+ );
1070
755
  }
1071
756
 
1072
757
  function resolveServiceStateDir(workerStateDir, targetName, config) {
1073
- const dbSource = config.testkit.databaseFrom || config.name;
1074
- return getWorkerServiceStateDir(workerStateDir, targetName, dbSource);
758
+ return resolveServiceStateDirModel(workerStateDir, targetName, config);
1075
759
  }
1076
760
 
1077
761
  function getWorkerServiceStateDir(workerStateDir, targetName, serviceName) {
1078
- if (targetName === serviceName) {
1079
- return workerStateDir;
1080
- }
1081
- return path.join(workerStateDir, "deps", serviceName);
762
+ return getWorkerServiceStateDirModel(workerStateDir, targetName, serviceName);
1082
763
  }
1083
764
 
1084
765
  function buildExecutionEnv(config, extraEnv = {}) {
1085
- return {
1086
- ...process.env,
1087
- ...(config.testkit.serviceEnv || {}),
1088
- ...extraEnv,
1089
- };
766
+ return buildExecutionEnvModel(config, extraEnv, process.env);
1090
767
  }
1091
768
 
1092
769
  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
- });
770
+ return buildPlaywrightEnvModel(config, baseUrl, process.env);
1101
771
  }
1102
772
 
1103
773
  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
- }
774
+ return recordTaskOutcomeModel(trackers, task, outcome);
1123
775
  }
1124
776
 
1125
777
  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
- }
778
+ return recordGraphErrorModel(trackers, graph, message);
1134
779
  }
1135
780
 
1136
781
  function addTrackerError(tracker, message) {
1137
- if (tracker.errorSet.has(message)) return;
1138
- tracker.errorSet.add(message);
1139
- tracker.errors.push(message);
782
+ return addTrackerErrorModel(tracker, message);
1140
783
  }
1141
784
 
1142
785
  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
- };
786
+ return finalizeServiceResultModel(tracker, startedAt, finishedAt);
1193
787
  }
1194
788
 
1195
789
  function printRunSummary(results, durationMs) {
@@ -1251,86 +845,121 @@ function printRunSummary(results, durationMs) {
1251
845
  console.log("\nResult: PASSED");
1252
846
  }
1253
847
 
848
+ async function reportTelemetry(telemetry, artifact) {
849
+ if (!telemetry?.enabled) return;
850
+
851
+ try {
852
+ const outcome = await uploadTelemetryArtifact(telemetry, artifact);
853
+ if (outcome?.ok) {
854
+ console.log("Telemetry: uploaded run artifact");
855
+ return;
856
+ }
857
+ if (outcome?.reason === "missing-token") {
858
+ console.log(
859
+ `Telemetry: skipped upload because ${telemetry.tokenEnv || "configured token env"} is not set`
860
+ );
861
+ return;
862
+ }
863
+ if (outcome?.reason && !outcome.skipped) return;
864
+ } catch (error) {
865
+ console.log(`Telemetry: upload failed (${formatError(error)})`);
866
+ }
867
+ }
868
+
1254
869
  function longestServiceName(results) {
1255
- return results.reduce((max, result) => Math.max(max, result.name.length), 4);
870
+ return longestServiceNameModel(results);
871
+ }
872
+
873
+ function buildRunArtifact({
874
+ productDir,
875
+ results,
876
+ startedAt,
877
+ finishedAt,
878
+ requestedJobs,
879
+ workerCount,
880
+ suiteType,
881
+ suiteNames,
882
+ framework,
883
+ shard,
884
+ serviceFilter,
885
+ }) {
886
+ return buildRunArtifactModel({
887
+ productDir,
888
+ results,
889
+ startedAt,
890
+ finishedAt,
891
+ requestedJobs,
892
+ workerCount,
893
+ suiteType,
894
+ suiteNames,
895
+ framework,
896
+ shard,
897
+ serviceFilter,
898
+ metadata: {
899
+ git: collectGitMetadata(productDir),
900
+ host: {
901
+ hostname: safeHostname(),
902
+ username: safeUsername(),
903
+ },
904
+ testkitVersion: readPackageMetadata().version,
905
+ },
906
+ });
907
+ }
908
+
909
+ function writeRunArtifact(productDir, artifact) {
910
+ const resultsDir = path.join(productDir, ".testkit", "results");
911
+ fs.mkdirSync(resultsDir, { recursive: true });
912
+ fs.writeFileSync(path.join(resultsDir, "latest.json"), JSON.stringify(artifact, null, 2));
913
+ }
914
+
915
+ function summarizeDbBackend(results) {
916
+ return summarizeDbBackendModel(results);
917
+ }
918
+
919
+ function collectGitMetadata(productDir) {
920
+ return collectGitMetadataModel(productDir);
921
+ }
922
+
923
+ function readPackageMetadata() {
924
+ return readPackageMetadataModel();
925
+ }
926
+
927
+ function safeHostname() {
928
+ return safeHostnameModel();
929
+ }
930
+
931
+ function safeUsername() {
932
+ return safeUsernameModel();
1256
933
  }
1257
934
 
1258
935
  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`;
936
+ return formatDurationModel(durationMs);
1264
937
  }
1265
938
 
1266
939
  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;
940
+ return formatServiceSummaryModel(result);
1274
941
  }
1275
942
 
1276
943
  function formatError(error) {
1277
- if (error instanceof Error) return error.message;
1278
- return String(error);
944
+ return formatErrorModel(error);
1279
945
  }
1280
946
 
1281
947
  function loadTimings(productDir) {
1282
948
  const filePath = path.join(productDir, ".testkit", TIMINGS_FILENAME);
1283
949
  if (!fs.existsSync(filePath)) {
1284
- return {
1285
- version: 1,
1286
- files: {},
1287
- };
950
+ return createEmptyTimings();
1288
951
  }
1289
952
 
1290
953
  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
- };
954
+ return normalizeTimings(JSON.parse(fs.readFileSync(filePath, "utf8")));
1296
955
  } catch {
1297
- return {
1298
- version: 1,
1299
- files: {},
1300
- };
956
+ return createEmptyTimings();
1301
957
  }
1302
958
  }
1303
959
 
1304
960
  function saveTimings(productDir, timings, updates) {
1305
961
  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
- }
962
+ const next = applyTimingUpdates(timings, updates);
1334
963
 
1335
964
  const rootDir = path.join(productDir, ".testkit");
1336
965
  fs.mkdirSync(rootDir, { recursive: true });
@@ -1341,140 +970,51 @@ function saveTimings(productDir, timings, updates) {
1341
970
  }
1342
971
 
1343
972
  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)));
973
+ return estimateTaskDurationModel(timings, timingKey, suite);
1354
974
  }
1355
975
 
1356
976
  function buildTimingKey(serviceName, suite, file) {
1357
- return [
1358
- serviceName,
1359
- suite.framework,
1360
- suite.type,
1361
- normalizePathSeparators(file),
1362
- ].join("|");
977
+ return buildTimingKeyModel(serviceName, suite, file);
1363
978
  }
1364
979
 
1365
980
  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
- };
981
+ return parsePlaywrightJsonResultsModel(stdout, cwd);
1386
982
  }
1387
983
 
1388
984
  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
- }
985
+ return visitPlaywrightSuitesModel(suites, inheritedFile, fileResults, cwd);
1398
986
  }
1399
987
 
1400
988
  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);
989
+ return collectPlaywrightSpecModel(spec, inheritedFile, fileResults, cwd);
1429
990
  }
1430
991
 
1431
992
  function choosePlaywrightFinalResult(results) {
1432
- if (!results || results.length === 0) return null;
1433
- return results[results.length - 1];
993
+ return choosePlaywrightFinalResultModel(results);
1434
994
  }
1435
995
 
1436
996
  function isPlaywrightPassingStatus(status) {
1437
- return !status || ["passed", "skipped", "expected"].includes(status);
997
+ return isPlaywrightPassingStatusModel(status);
1438
998
  }
1439
999
 
1440
1000
  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");
1001
+ return extractPlaywrightFailureModel(finalResult, spec, test);
1451
1002
  }
1452
1003
 
1453
1004
  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;
1005
+ return formatPlaywrightReporterErrorModel(error);
1459
1006
  }
1460
1007
 
1461
1008
  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;
1009
+ return extractReporterFileModel(node);
1468
1010
  }
1469
1011
 
1470
1012
  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));
1013
+ return normalizeReportedFileModel(filePath, cwd);
1474
1014
  }
1475
1015
 
1476
1016
  function firstLine(value) {
1477
- return String(value).split(/\r?\n/).find((line) => line.trim().length > 0)?.trim() || null;
1017
+ return firstLineModel(value);
1478
1018
  }
1479
1019
 
1480
1020
  function printBufferedOutput(output, prefix) {
@@ -1486,95 +1026,23 @@ function printBufferedOutput(output, prefix) {
1486
1026
  }
1487
1027
 
1488
1028
  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;
1029
+ return resolveRuntimeUrlModel(rawUrl, serviceName, targetConfig, workerId, context);
1497
1030
  }
1498
1031
 
1499
1032
  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;
1033
+ return finalizeStringModel(value, context);
1507
1034
  }
1508
1035
 
1509
1036
  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
- });
1037
+ return resolveTemplateStringModel(value, context);
1550
1038
  }
1551
1039
 
1552
1040
  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
- }
1041
+ return rewriteUrlPortModel(rawUrl, port);
1568
1042
  }
1569
1043
 
1570
1044
  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
- }
1045
+ return numericPortFromUrlModel(rawUrl);
1578
1046
  }
1579
1047
 
1580
1048
  async function assertLocalServicePortsAvailable(config) {
@@ -1599,22 +1067,11 @@ async function assertLocalServicePortsAvailable(config) {
1599
1067
  }
1600
1068
 
1601
1069
  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
- }
1070
+ return socketFromUrlModel(rawUrl);
1612
1071
  }
1613
1072
 
1614
1073
  function normalizeSocketHost(hostname) {
1615
- if (!hostname || hostname === "localhost") return "127.0.0.1";
1616
- if (hostname === "[::1]") return "::1";
1617
- return hostname;
1074
+ return normalizeSocketHostModel(hostname);
1618
1075
  }
1619
1076
 
1620
1077
  async function isPortInUse({ host, port }) {
@@ -1728,82 +1185,31 @@ function sleep(ms) {
1728
1185
  }
1729
1186
 
1730
1187
  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);
1188
+ return findRuntimeStateDirsModel(rootDir, isDatabaseStateDir);
1749
1189
  }
1750
1190
 
1751
1191
  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();
1192
+ return findGraphDirsForServiceModel(productDir, serviceName);
1767
1193
  }
1768
1194
 
1769
1195
  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
- );
1196
+ return writeGraphMetadataModel(graphDir, graph);
1780
1197
  }
1781
1198
 
1782
1199
  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
- }
1200
+ return readGraphMetadataModel(graphDir);
1791
1201
  }
1792
1202
 
1793
1203
  function isRuntimeSuperset(candidate, target) {
1794
- return target.every((name) => candidate.includes(name));
1204
+ return isRuntimeSupersetModel(candidate, target);
1795
1205
  }
1796
1206
 
1797
1207
  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);
1208
+ return compareGraphsForAssignmentModel(left, right);
1802
1209
  }
1803
1210
 
1804
1211
  function buildGraphDirName(runtimeNames) {
1805
- const slug = runtimeNames.map(slugSegment).join("__");
1806
- return slug.length > 0 ? slug : "graph";
1212
+ return buildGraphDirNameModel(runtimeNames);
1807
1213
  }
1808
1214
 
1809
1215
  function slugSegment(value) {
@@ -1811,5 +1217,5 @@ function slugSegment(value) {
1811
1217
  }
1812
1218
 
1813
1219
  function normalizePathSeparators(filePath) {
1814
- return filePath.split(path.sep).join("/");
1220
+ return normalizePathSeparatorsModel(filePath);
1815
1221
  }