@elench/testkit 0.1.56 → 0.1.57

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.
@@ -31,14 +31,15 @@ export function parseDotenv(filePath) {
31
31
  return parseDotenvString(fs.readFileSync(filePath, "utf8"));
32
32
  }
33
33
 
34
- export async function loadConfigs(opts = {}) {
34
+ export async function loadConfigContext(opts = {}) {
35
35
  const productDir = resolveProductDir(process.cwd(), opts.dir);
36
- const { setup, setupFile } = await loadTestkitSetup(productDir);
36
+ const setupContext = opts.setupContext || (await loadTestkitSetup(productDir));
37
+ const { setup, setupFile } = setupContext;
37
38
  const execution = normalizeRepoExecution(setup.execution);
38
39
  const reporting = normalizeReportingConfig(setup.reporting);
39
40
  const toolchains = normalizeToolchainRegistry(setup.toolchains);
40
41
  const explicitServices = setup.services || {};
41
- const discovery = discoverProject(productDir, explicitServices);
42
+ const discovery = discoverProject(productDir, explicitServices, opts.discoveryOptions || {});
42
43
  const serviceNames = new Set([
43
44
  ...Object.keys(explicitServices),
44
45
  ...Object.keys(discovery.suitesByService),
@@ -63,6 +64,23 @@ export async function loadConfigs(opts = {}) {
63
64
 
64
65
  validateConfigCoverage(configs);
65
66
 
67
+ return {
68
+ productDir,
69
+ setup,
70
+ setupFile,
71
+ execution,
72
+ reporting,
73
+ toolchains,
74
+ explicitServices,
75
+ discovery,
76
+ configs,
77
+ };
78
+ }
79
+
80
+ export async function loadConfigs(opts = {}) {
81
+ const context = await loadConfigContext(opts);
82
+ const { configs } = context;
83
+
66
84
  const filtered = opts.service
67
85
  ? configs.filter((config) => config.name === opts.service)
68
86
  : configs;
@@ -0,0 +1,121 @@
1
+ export type DiscoverySelectionType = "int" | "e2e" | "scenario" | "dal" | "load" | "pw";
2
+ export type DiscoveryInternalType = "integration" | "e2e" | "scenario" | "dal" | "load";
3
+ export type DiscoveryFramework = "k6" | "playwright";
4
+
5
+ export interface DiscoveryDiagnostic {
6
+ code: string;
7
+ severity: "error" | "warning";
8
+ message: string;
9
+ path?: string;
10
+ serviceNames?: string[];
11
+ }
12
+
13
+ export interface DiscoveryService {
14
+ name: string;
15
+ discovered: boolean;
16
+ localCwd: string;
17
+ dependsOn: string[];
18
+ suiteCount: number;
19
+ fileCount: number;
20
+ activeFileCount: number;
21
+ skippedFileCount: number;
22
+ }
23
+
24
+ export interface DiscoverySuite {
25
+ id: string;
26
+ service: string;
27
+ name: string;
28
+ displayName: string;
29
+ selectionType: DiscoverySelectionType;
30
+ internalType: DiscoveryInternalType;
31
+ framework: DiscoveryFramework;
32
+ groupLabel: string;
33
+ fileCount: number;
34
+ activeFileCount: number;
35
+ skippedFileCount: number;
36
+ dependsOn: string[];
37
+ locks: string[];
38
+ filePaths: string[];
39
+ }
40
+
41
+ export interface DiscoveryHistorySummary {
42
+ firstSeenAt?: string | null;
43
+ lastSeenAt?: string | null;
44
+ lastRunAt?: string | null;
45
+ runCount?: number;
46
+ passCount?: number;
47
+ failCount?: number;
48
+ skipCount?: number;
49
+ avgDurationMs?: number;
50
+ lastStatus?: "passed" | "failed" | "skipped" | "not_run" | null;
51
+ }
52
+
53
+ export interface DiscoveryFile {
54
+ id: string;
55
+ path: string;
56
+ displayName: string;
57
+ service: string;
58
+ suiteName: string;
59
+ groupLabel: string;
60
+ selectionType: DiscoverySelectionType;
61
+ internalType: DiscoveryInternalType;
62
+ framework: DiscoveryFramework;
63
+ skipped: boolean;
64
+ skipReason: string | null;
65
+ locks: string[];
66
+ dependsOn: string[];
67
+ history?: DiscoveryHistorySummary;
68
+ }
69
+
70
+ export interface DiscoveryResult {
71
+ schemaVersion: number;
72
+ source: "testkit-discovery";
73
+ product: {
74
+ name: string;
75
+ directory: string;
76
+ };
77
+ setupFile: string | null;
78
+ filters: {
79
+ service: string | null;
80
+ types: DiscoverySelectionType[] | ["all"];
81
+ suiteSelectors: string[];
82
+ fileNames: string[];
83
+ runnableOnly: boolean;
84
+ diagnostics: "error" | "report";
85
+ };
86
+ services: DiscoveryService[];
87
+ suites: DiscoverySuite[];
88
+ files: DiscoveryFile[];
89
+ diagnostics: DiscoveryDiagnostic[];
90
+ summary: {
91
+ services: number;
92
+ suites: number;
93
+ files: number;
94
+ activeFiles: number;
95
+ skippedFiles: number;
96
+ diagnostics: {
97
+ errors: number;
98
+ warnings: number;
99
+ };
100
+ byService: Record<string, number>;
101
+ byType: Record<string, number>;
102
+ };
103
+ history: {
104
+ available: boolean;
105
+ path?: string;
106
+ };
107
+ }
108
+
109
+ export interface DiscoverTestsOptions {
110
+ dir?: string;
111
+ service?: string;
112
+ type?: string | string[];
113
+ suite?: string | string[];
114
+ file?: string | string[];
115
+ runnableOnly?: boolean;
116
+ diagnostics?: "error" | "report";
117
+ }
118
+
119
+ export declare function discoverTests(options?: DiscoverTestsOptions): Promise<DiscoveryResult>;
120
+ export declare function formatSelectionTypeLabel(type: DiscoverySelectionType): string;
121
+ export declare function formatDisplayName(value: string): string;
@@ -0,0 +1,540 @@
1
+ import path from "path";
2
+ import { loadConfigContext, resolveProductDir } from "../config/index.mjs";
3
+ import { discoverProject } from "../config/discovery.mjs";
4
+ import { loadTestkitSetup } from "../config/setup-loader.mjs";
5
+ import { historyFilePath, loadHistory, summarizeHistoryForFiles } from "../history/index.mjs";
6
+ import {
7
+ matchesSelectedTypes,
8
+ matchesSuiteSelectors,
9
+ normalizeTypeValues,
10
+ parseSuiteSelectors,
11
+ suiteSelectionType,
12
+ } from "../runner/suite-selection.mjs";
13
+
14
+ const DISCOVERY_SCHEMA_VERSION = 1;
15
+
16
+ export async function discoverTests(options = {}) {
17
+ const productDir = resolveProductDir(process.cwd(), options.dir);
18
+ const filters = normalizeDiscoveryFilters(options);
19
+ const setupContext = await loadSetupContext(productDir, filters.diagnosticsMode);
20
+ const baseResult = {
21
+ schemaVersion: DISCOVERY_SCHEMA_VERSION,
22
+ source: "testkit-discovery",
23
+ product: {
24
+ name: path.basename(productDir),
25
+ directory: productDir,
26
+ },
27
+ setupFile: setupContext.setupFile ? path.relative(productDir, setupContext.setupFile) || path.basename(setupContext.setupFile) : null,
28
+ filters: {
29
+ service: filters.serviceFilter,
30
+ types: filters.typeValues,
31
+ suiteSelectors: filters.suiteSelectors.map((selector) => selector.raw),
32
+ fileNames: filters.fileNames,
33
+ runnableOnly: filters.runnableOnly,
34
+ diagnostics: filters.diagnosticsMode,
35
+ },
36
+ services: [],
37
+ suites: [],
38
+ files: [],
39
+ diagnostics: [...setupContext.diagnostics],
40
+ summary: emptySummary(),
41
+ history: {
42
+ available: false,
43
+ path: normalizePath(path.relative(productDir, historyFilePath(productDir))),
44
+ },
45
+ };
46
+
47
+ if (!setupContext.setup) {
48
+ return finalizeDiscoveryResult(baseResult);
49
+ }
50
+
51
+ const rawDiscovery = discoverProject(productDir, setupContext.setup.services || {}, {
52
+ strict: filters.diagnosticsMode === "error",
53
+ });
54
+ baseResult.diagnostics.push(...rawDiscovery.diagnostics);
55
+ validateRequestedService(filters.serviceFilter, setupContext.setup.services || {}, rawDiscovery);
56
+
57
+ let configContext = null;
58
+ try {
59
+ configContext = await loadConfigContext({
60
+ dir: productDir,
61
+ setupContext: {
62
+ setup: setupContext.setup,
63
+ setupFile: setupContext.setupFile,
64
+ },
65
+ discoveryOptions: {
66
+ strict: filters.diagnosticsMode === "error",
67
+ },
68
+ });
69
+ } catch (error) {
70
+ if (filters.diagnosticsMode === "error") throw error;
71
+ baseResult.diagnostics.push({
72
+ code: "config_invalid",
73
+ severity: "error",
74
+ message: formatErrorMessage(error),
75
+ });
76
+ }
77
+
78
+ if (configContext) {
79
+ const resolved = buildResolvedDiscovery({
80
+ configs: configContext.configs,
81
+ filters,
82
+ });
83
+ return finalizeDiscoveryResult(
84
+ {
85
+ ...baseResult,
86
+ services: resolved.services,
87
+ suites: resolved.suites,
88
+ files: resolved.files,
89
+ },
90
+ productDir
91
+ );
92
+ }
93
+
94
+ const rawOnly = buildRawDiscovery({
95
+ rawDiscovery,
96
+ explicitServices: setupContext.setup.services || {},
97
+ filters,
98
+ });
99
+ return finalizeDiscoveryResult(
100
+ {
101
+ ...baseResult,
102
+ services: rawOnly.services,
103
+ suites: rawOnly.suites,
104
+ files: rawOnly.files,
105
+ },
106
+ productDir
107
+ );
108
+ }
109
+
110
+ export function formatSelectionTypeLabel(type) {
111
+ if (type === "int") return "Integration";
112
+ if (type === "e2e") return "E2E";
113
+ if (type === "scenario") return "Scenario";
114
+ if (type === "dal") return "DAL";
115
+ if (type === "load") return "Load";
116
+ if (type === "pw") return "Playwright";
117
+ return type;
118
+ }
119
+
120
+ export function formatDisplayName(value) {
121
+ return String(value || "")
122
+ .split(/[-_/]+/g)
123
+ .filter(Boolean)
124
+ .map((part) => part.replace(/^\w/, (char) => char.toUpperCase()))
125
+ .join(" ");
126
+ }
127
+
128
+ async function loadSetupContext(productDir, diagnosticsMode) {
129
+ try {
130
+ const { setup, setupFile } = await loadTestkitSetup(productDir);
131
+ return {
132
+ setup,
133
+ setupFile,
134
+ diagnostics: [],
135
+ };
136
+ } catch (error) {
137
+ if (diagnosticsMode === "error") throw error;
138
+ return {
139
+ setup: null,
140
+ setupFile: null,
141
+ diagnostics: [
142
+ {
143
+ code: "setup_invalid",
144
+ severity: "error",
145
+ message: formatErrorMessage(error),
146
+ },
147
+ ],
148
+ };
149
+ }
150
+ }
151
+
152
+ function buildResolvedDiscovery({ configs, filters }) {
153
+ const services = [];
154
+ const suites = [];
155
+ const files = [];
156
+
157
+ for (const config of configs) {
158
+ if (filters.serviceFilter && config.name !== filters.serviceFilter) continue;
159
+
160
+ const suiteEntries = [];
161
+ const fileEntries = [];
162
+ for (const [internalType, discoveredSuites] of Object.entries(config.suites || {})) {
163
+ for (const suite of discoveredSuites || []) {
164
+ const suiteEntriesForConfig = buildResolvedSuiteEntries(config, suite, internalType, filters);
165
+ if (!suiteEntriesForConfig) continue;
166
+ suiteEntries.push(suiteEntriesForConfig.suiteEntry);
167
+ fileEntries.push(...suiteEntriesForConfig.fileEntries);
168
+ }
169
+ }
170
+
171
+ services.push(buildServiceEntry(config, suiteEntries, fileEntries));
172
+ suites.push(...suiteEntries);
173
+ files.push(...fileEntries);
174
+ }
175
+
176
+ return {
177
+ services: services.sort((left, right) => left.name.localeCompare(right.name)),
178
+ suites: suites.sort(compareSuiteEntries),
179
+ files: files.sort(compareFileEntries),
180
+ };
181
+ }
182
+
183
+ function buildResolvedSuiteEntries(config, suite, internalType, filters) {
184
+ const framework = suite.framework || "k6";
185
+ const selectionType = suiteSelectionType(internalType, framework);
186
+ if (!matchesSelectedTypes(selectionType, filters.typeValues)) return null;
187
+ if (!matchesSuiteSelectors(selectionType, suite.name, filters.suiteSelectors)) return null;
188
+
189
+ const suiteSkipReason = findSuiteSkipReason(config, selectionType, suite.name);
190
+ const suiteLocks = findSuiteLocks(config, selectionType, suite.name);
191
+ const visibleFiles = [];
192
+
193
+ for (const filePath of suite.files || []) {
194
+ if (filters.fileNameSet.size > 0 && !filters.fileNameSet.has(filePath)) continue;
195
+ const fileLocks = config.testkit.requirements?.fileLocksByPath?.get(filePath) || [];
196
+ const skipReason = config.testkit.skip?.fileReasonByPath?.get(filePath) || suiteSkipReason || null;
197
+ const skipped = Boolean(skipReason);
198
+ if (filters.runnableOnly && skipped) continue;
199
+ visibleFiles.push({
200
+ id: buildFileId(config.name, selectionType, filePath),
201
+ path: filePath,
202
+ displayName: fileDisplayName(filePath),
203
+ service: config.name,
204
+ suiteName: suite.name,
205
+ groupLabel: formatDisplayName(suite.name),
206
+ selectionType,
207
+ internalType,
208
+ framework,
209
+ skipped,
210
+ skipReason,
211
+ locks: [...new Set([...suiteLocks, ...fileLocks])].sort(),
212
+ dependsOn: [...(config.testkit.dependsOn || [])],
213
+ });
214
+ }
215
+
216
+ if (visibleFiles.length === 0) return null;
217
+ const skippedFileCount = visibleFiles.filter((entry) => entry.skipped).length;
218
+ const suiteEntry = {
219
+ id: buildSuiteId(config.name, selectionType, suite.name, framework),
220
+ service: config.name,
221
+ name: suite.name,
222
+ displayName: formatDisplayName(suite.name),
223
+ selectionType,
224
+ internalType,
225
+ framework,
226
+ groupLabel: formatDisplayName(suite.name),
227
+ fileCount: visibleFiles.length,
228
+ activeFileCount: visibleFiles.length - skippedFileCount,
229
+ skippedFileCount,
230
+ dependsOn: [...(config.testkit.dependsOn || [])],
231
+ locks: [...new Set(visibleFiles.flatMap((entry) => entry.locks || []))].sort(),
232
+ filePaths: visibleFiles.map((entry) => entry.path),
233
+ };
234
+
235
+ return {
236
+ suiteEntry,
237
+ fileEntries: visibleFiles,
238
+ };
239
+ }
240
+
241
+ function buildRawDiscovery({ rawDiscovery, explicitServices, filters }) {
242
+ const servicesByName = new Map();
243
+ const suitesById = new Map();
244
+ const files = [];
245
+
246
+ for (const [name, config] of Object.entries(explicitServices || {})) {
247
+ if (filters.serviceFilter && name !== filters.serviceFilter) continue;
248
+ servicesByName.set(name, {
249
+ name,
250
+ discovered: Boolean(rawDiscovery.services?.[name]),
251
+ localCwd: config?.local?.cwd || rawDiscovery.services?.[name]?.inferredLocalCwd || ".",
252
+ dependsOn: [...(config?.dependsOn || [])],
253
+ suiteCount: 0,
254
+ fileCount: 0,
255
+ activeFileCount: 0,
256
+ skippedFileCount: 0,
257
+ });
258
+ }
259
+
260
+ for (const [name, service] of Object.entries(rawDiscovery.services || {})) {
261
+ if (filters.serviceFilter && name !== filters.serviceFilter) continue;
262
+ servicesByName.set(name, {
263
+ name,
264
+ discovered: true,
265
+ localCwd: servicesByName.get(name)?.localCwd || service.inferredLocalCwd || ".",
266
+ dependsOn: [...(explicitServices[name]?.dependsOn || [])],
267
+ suiteCount: servicesByName.get(name)?.suiteCount || 0,
268
+ fileCount: servicesByName.get(name)?.fileCount || 0,
269
+ activeFileCount: servicesByName.get(name)?.activeFileCount || 0,
270
+ skippedFileCount: servicesByName.get(name)?.skippedFileCount || 0,
271
+ });
272
+ }
273
+
274
+ for (const entry of rawDiscovery.files || []) {
275
+ const selectionType = suiteSelectionType(entry.type, entry.framework);
276
+ if (!matchesSelectedTypes(selectionType, filters.typeValues)) continue;
277
+ if (!matchesSuiteSelectors(selectionType, entry.suiteName, filters.suiteSelectors)) continue;
278
+ if (filters.serviceFilter && entry.serviceName !== filters.serviceFilter) continue;
279
+ if (filters.fileNameSet.size > 0 && !filters.fileNameSet.has(entry.filePath)) continue;
280
+
281
+ const fileEntry = {
282
+ id: buildFileId(entry.serviceName, selectionType, entry.filePath),
283
+ path: entry.filePath,
284
+ displayName: fileDisplayName(entry.filePath),
285
+ service: entry.serviceName,
286
+ suiteName: entry.suiteName,
287
+ groupLabel: formatDisplayName(entry.suiteName),
288
+ selectionType,
289
+ internalType: entry.type,
290
+ framework: entry.framework,
291
+ skipped: false,
292
+ skipReason: null,
293
+ locks: [],
294
+ dependsOn: [...(explicitServices[entry.serviceName]?.dependsOn || [])],
295
+ };
296
+ files.push(fileEntry);
297
+
298
+ const suiteId = buildSuiteId(entry.serviceName, selectionType, entry.suiteName, entry.framework);
299
+ const existingSuite = suitesById.get(suiteId);
300
+ if (existingSuite) {
301
+ existingSuite.filePaths.push(entry.filePath);
302
+ existingSuite.fileCount += 1;
303
+ existingSuite.activeFileCount += 1;
304
+ } else {
305
+ suitesById.set(suiteId, {
306
+ id: suiteId,
307
+ service: entry.serviceName,
308
+ name: entry.suiteName,
309
+ displayName: formatDisplayName(entry.suiteName),
310
+ selectionType,
311
+ internalType: entry.type,
312
+ framework: entry.framework,
313
+ groupLabel: formatDisplayName(entry.suiteName),
314
+ fileCount: 1,
315
+ activeFileCount: 1,
316
+ skippedFileCount: 0,
317
+ dependsOn: [...(explicitServices[entry.serviceName]?.dependsOn || [])],
318
+ locks: [],
319
+ filePaths: [entry.filePath],
320
+ });
321
+ }
322
+ }
323
+
324
+ for (const suite of suitesById.values()) {
325
+ const service = servicesByName.get(suite.service) || {
326
+ name: suite.service,
327
+ discovered: true,
328
+ localCwd: rawDiscovery.services?.[suite.service]?.inferredLocalCwd || ".",
329
+ dependsOn: [...(explicitServices[suite.service]?.dependsOn || [])],
330
+ suiteCount: 0,
331
+ fileCount: 0,
332
+ activeFileCount: 0,
333
+ skippedFileCount: 0,
334
+ };
335
+ service.suiteCount += 1;
336
+ service.fileCount += suite.fileCount;
337
+ service.activeFileCount += suite.activeFileCount;
338
+ service.skippedFileCount += suite.skippedFileCount;
339
+ servicesByName.set(suite.service, service);
340
+ }
341
+
342
+ return {
343
+ services: [...servicesByName.values()].sort((left, right) => left.name.localeCompare(right.name)),
344
+ suites: [...suitesById.values()].sort(compareSuiteEntries),
345
+ files: files.sort(compareFileEntries),
346
+ };
347
+ }
348
+
349
+ function buildServiceEntry(config, suiteEntries, fileEntries) {
350
+ return {
351
+ name: config.name,
352
+ discovered: true,
353
+ localCwd: config.testkit.local?.cwd || ".",
354
+ dependsOn: [...(config.testkit.dependsOn || [])],
355
+ suiteCount: suiteEntries.length,
356
+ fileCount: fileEntries.length,
357
+ activeFileCount: fileEntries.filter((entry) => !entry.skipped).length,
358
+ skippedFileCount: fileEntries.filter((entry) => entry.skipped).length,
359
+ };
360
+ }
361
+
362
+ function finalizeDiscoveryResult(result, productDir) {
363
+ const history = loadHistory(productDir);
364
+ const historyById = summarizeHistoryForFiles(history, result.files);
365
+ const files = result.files.map((entry) => {
366
+ const historyEntry = historyById.get(entry.id);
367
+ if (!historyEntry) return entry;
368
+ return {
369
+ ...entry,
370
+ history: historyEntry,
371
+ };
372
+ });
373
+
374
+ return {
375
+ ...result,
376
+ files,
377
+ diagnostics: result.diagnostics.sort(compareDiagnostics),
378
+ summary: buildSummary(result.services, result.suites, files, result.diagnostics),
379
+ history: {
380
+ ...result.history,
381
+ available: Object.keys(history.tests || {}).length > 0,
382
+ },
383
+ };
384
+ }
385
+
386
+ function buildSummary(services, suites, files, diagnostics) {
387
+ const byService = {};
388
+ const byType = {};
389
+
390
+ for (const file of files) {
391
+ byService[file.service] = (byService[file.service] || 0) + 1;
392
+ byType[file.selectionType] = (byType[file.selectionType] || 0) + 1;
393
+ }
394
+
395
+ return {
396
+ services: services.length,
397
+ suites: suites.length,
398
+ files: files.length,
399
+ activeFiles: files.filter((entry) => !entry.skipped).length,
400
+ skippedFiles: files.filter((entry) => entry.skipped).length,
401
+ diagnostics: {
402
+ errors: diagnostics.filter((entry) => entry.severity === "error").length,
403
+ warnings: diagnostics.filter((entry) => entry.severity === "warning").length,
404
+ },
405
+ byService,
406
+ byType,
407
+ };
408
+ }
409
+
410
+ function validateRequestedService(serviceFilter, explicitServices, rawDiscovery) {
411
+ if (!serviceFilter) return;
412
+ const available = new Set([
413
+ ...Object.keys(explicitServices || {}),
414
+ ...Object.keys(rawDiscovery.services || {}),
415
+ ...Object.keys(rawDiscovery.suitesByService || {}),
416
+ ]);
417
+ if (available.has(serviceFilter)) return;
418
+ const formatted = [...available].sort((left, right) => left.localeCompare(right)).join(", ");
419
+ throw new Error(
420
+ formatted.length > 0
421
+ ? `Service "${serviceFilter}" not found. Available: ${formatted}`
422
+ : `Service "${serviceFilter}" not found.`
423
+ );
424
+ }
425
+
426
+ function normalizeDiscoveryFilters(options) {
427
+ const typeValues = normalizeTypeValues(normalizeMultiValue(options.type));
428
+ const suiteSelectors = parseSuiteSelectors(normalizeMultiValue(options.suite));
429
+ const fileNames = normalizeMultiValue(options.file).map(normalizePath);
430
+ return {
431
+ typeValues,
432
+ suiteSelectors,
433
+ fileNames,
434
+ fileNameSet: new Set(fileNames),
435
+ serviceFilter: options.service || null,
436
+ runnableOnly: Boolean(options.runnableOnly),
437
+ diagnosticsMode: options.diagnostics === "error" ? "error" : "report",
438
+ };
439
+ }
440
+
441
+ function findSuiteSkipReason(config, selectionType, suiteName) {
442
+ for (const rule of config.testkit.skip?.suites || []) {
443
+ if (matchesSuiteSelectors(selectionType, suiteName, [rule.selector])) {
444
+ return rule.reason;
445
+ }
446
+ }
447
+ return null;
448
+ }
449
+
450
+ function findSuiteLocks(config, selectionType, suiteName) {
451
+ const locks = new Set();
452
+ for (const rule of config.testkit.requirements?.suites || []) {
453
+ if (matchesSuiteSelectors(selectionType, suiteName, [rule.selector])) {
454
+ for (const lockName of rule.locks || []) {
455
+ locks.add(lockName);
456
+ }
457
+ }
458
+ }
459
+ return [...locks].sort();
460
+ }
461
+
462
+ function normalizeMultiValue(value) {
463
+ if (Array.isArray(value)) {
464
+ return value.filter((entry) => entry != null).map((entry) => String(entry));
465
+ }
466
+ if (value == null || value === "") return [];
467
+ return [String(value)];
468
+ }
469
+
470
+ function normalizePath(filePath) {
471
+ return String(filePath).split(path.sep).join("/").replace(/^\.\/+/, "");
472
+ }
473
+
474
+ function fileDisplayName(filePath) {
475
+ const base = path.posix
476
+ .basename(filePath)
477
+ .replace(/(\.int|\.e2e|\.scenario|\.dal|\.load|\.pw)\.testkit\.ts$/, "");
478
+ return formatDisplayName(base);
479
+ }
480
+
481
+ function buildSuiteId(serviceName, selectionType, suiteName, framework) {
482
+ return [serviceName, selectionType, framework, suiteName].join("|");
483
+ }
484
+
485
+ function buildFileId(serviceName, selectionType, filePath) {
486
+ return [serviceName, selectionType, normalizePath(filePath)].join("|");
487
+ }
488
+
489
+ function compareSuiteEntries(left, right) {
490
+ return (
491
+ left.service.localeCompare(right.service) ||
492
+ left.selectionType.localeCompare(right.selectionType) ||
493
+ left.name.localeCompare(right.name) ||
494
+ left.framework.localeCompare(right.framework)
495
+ );
496
+ }
497
+
498
+ function compareFileEntries(left, right) {
499
+ return (
500
+ left.service.localeCompare(right.service) ||
501
+ left.selectionType.localeCompare(right.selectionType) ||
502
+ left.suiteName.localeCompare(right.suiteName) ||
503
+ left.path.localeCompare(right.path)
504
+ );
505
+ }
506
+
507
+ function compareDiagnostics(left, right) {
508
+ return (
509
+ severityRank(left.severity) - severityRank(right.severity) ||
510
+ String(left.code || "").localeCompare(String(right.code || "")) ||
511
+ String(left.path || "").localeCompare(String(right.path || ""))
512
+ );
513
+ }
514
+
515
+ function severityRank(value) {
516
+ if (value === "error") return 1;
517
+ if (value === "warning") return 2;
518
+ return 3;
519
+ }
520
+
521
+ function emptySummary() {
522
+ return {
523
+ services: 0,
524
+ suites: 0,
525
+ files: 0,
526
+ activeFiles: 0,
527
+ skippedFiles: 0,
528
+ diagnostics: {
529
+ errors: 0,
530
+ warnings: 0,
531
+ },
532
+ byService: {},
533
+ byType: {},
534
+ };
535
+ }
536
+
537
+ function formatErrorMessage(error) {
538
+ if (error instanceof Error) return error.message;
539
+ return String(error);
540
+ }