@elench/testkit 0.1.41 → 0.1.43

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.
@@ -21,53 +21,56 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
21
21
  };
22
22
 
23
23
  return {
24
+ canAcquire(task) {
25
+ const pool = getPool(pools, graphByKey, task, productDir, lifecycle);
26
+ if (!locksAvailable(locks, task.locks || [])) {
27
+ return false;
28
+ }
29
+ return claimableRuntimeSlot(pool, task) !== null;
30
+ },
24
31
  async acquire(task) {
25
32
  const pool = getPool(pools, graphByKey, task, productDir, lifecycle);
33
+ if (lifecycle.isStopRequested()) {
34
+ throw lifecycle.signal.reason || new Error("testkit run interrupted");
35
+ }
26
36
 
27
- while (true) {
28
- if (lifecycle.isStopRequested()) {
29
- throw lifecycle.signal.reason || new Error("testkit run interrupted");
30
- }
31
-
32
- const leaseId = `lease-${task.id}-${nextLeaseCounter}`;
33
- nextLeaseCounter += 1;
37
+ const leaseId = `lease-${task.id}-${nextLeaseCounter}`;
38
+ nextLeaseCounter += 1;
34
39
 
35
- if (!tryAcquireLocks(locks, task.locks || [], leaseId)) {
36
- await runtimeHooks.sleep(10);
37
- continue;
38
- }
40
+ if (!tryAcquireLocks(locks, task.locks || [], leaseId)) {
41
+ throw new Error(`Task ${task.id} was claimed before its locks were available`);
42
+ }
39
43
 
40
- const slot = claimRuntimeSlot(pool);
41
- if (!slot) {
42
- releaseLocks(locks, task.locks || [], leaseId);
43
- await runtimeHooks.sleep(10);
44
- continue;
45
- }
44
+ const slot = claimRuntimeSlot(pool, task);
45
+ if (!slot) {
46
+ releaseLocks(locks, task.locks || [], leaseId);
47
+ throw new Error(`Task ${task.id} was claimed before runtime capacity was available`);
48
+ }
46
49
 
47
- try {
48
- const context = await getReadyContext(slot, productDir, task, lifecycle, runtimeHooks);
49
- const leaseDir = path.join(context.runtimeDir, "leases", leaseId);
50
- fs.mkdirSync(leaseDir, { recursive: true });
51
- return {
52
- leaseId,
53
- leaseDir,
54
- lockNames: task.locks || [],
55
- slot,
56
- context,
57
- };
58
- } catch (error) {
59
- releaseRuntimeSlot(slot);
60
- releaseLocks(locks, task.locks || [], leaseId);
61
- cleanupLeaseDir({ leaseDir: path.join(slot.context?.runtimeDir || productDir, "leases", leaseId) });
62
- await drainRuntimeSlot(slot, lifecycle, runtimeHooks);
63
- throw error;
64
- }
50
+ try {
51
+ const context = await getReadyContext(slot, productDir, task, lifecycle, runtimeHooks);
52
+ const leaseDir = path.join(context.runtimeDir, "leases", leaseId);
53
+ fs.mkdirSync(leaseDir, { recursive: true });
54
+ return {
55
+ leaseId,
56
+ leaseDir,
57
+ lockNames: task.locks || [],
58
+ resourceCost: task.resourceCost || 1,
59
+ slot,
60
+ context,
61
+ };
62
+ } catch (error) {
63
+ releaseRuntimeSlot(slot, task);
64
+ releaseLocks(locks, task.locks || [], leaseId);
65
+ cleanupLeaseDir({ leaseDir: path.join(slot.context?.runtimeDir || productDir, "leases", leaseId) });
66
+ await drainRuntimeSlot(slot, lifecycle, runtimeHooks);
67
+ throw error;
65
68
  }
66
69
  },
67
70
  async release(lease, options = {}) {
68
71
  if (!lease?.slot) return;
69
72
  releaseLocks(locks, lease.lockNames || [], lease.leaseId);
70
- releaseRuntimeSlot(lease.slot);
73
+ releaseRuntimeSlot(lease.slot, { resourceCost: lease.resourceCost || 1 });
71
74
  cleanupLeaseDir(lease);
72
75
  if (options.invalidate) {
73
76
  lease.slot.draining = true;
@@ -84,6 +87,23 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
84
87
  }
85
88
  }
86
89
  },
90
+ getStats() {
91
+ return [...pools.values()]
92
+ .map((pool) => ({
93
+ graphKey: pool.slots[0]?.graph.key || null,
94
+ targetNames: [...(pool.slots[0]?.graph.targetNames || [])],
95
+ maxConcurrentTasks: Number.isFinite(pool.slots[0]?.graph.maxConcurrentTasks)
96
+ ? pool.slots[0]?.graph.maxConcurrentTasks
97
+ : null,
98
+ runtimeCount: pool.slots.length,
99
+ runtimes: pool.slots.map((slot) => ({
100
+ runtimeId: slot.runtimeId,
101
+ peakLeaseCount: slot.peakLeaseCount,
102
+ peakResourceUnits: slot.peakResourceUnits,
103
+ })),
104
+ }))
105
+ .sort((left, right) => String(left.graphKey).localeCompare(String(right.graphKey)));
106
+ },
87
107
  };
88
108
  }
89
109
 
@@ -105,6 +125,9 @@ function getPool(pools, graphByKey, task, productDir, lifecycle) {
105
125
  context: null,
106
126
  contextPromise: null,
107
127
  activeLeaseCount: 0,
128
+ activeResourceUnits: 0,
129
+ peakLeaseCount: 0,
130
+ peakResourceUnits: 0,
108
131
  draining: false,
109
132
  })),
110
133
  };
@@ -112,21 +135,30 @@ function getPool(pools, graphByKey, task, productDir, lifecycle) {
112
135
  return pool;
113
136
  }
114
137
 
115
- function claimRuntimeSlot(pool) {
138
+ function claimRuntimeSlot(pool, task) {
139
+ const resourceCost = task.resourceCost || 1;
116
140
  const available = pool.slots.filter((slot) => !slot.draining);
117
141
  if (available.length === 0) return null;
118
142
 
119
- const slot = [...available].sort(
120
- (left, right) =>
121
- left.activeLeaseCount - right.activeLeaseCount ||
122
- left.runtimeId.localeCompare(right.runtimeId)
123
- )[0];
143
+ const slot = [...available]
144
+ .filter((candidate) => slotHasCapacity(candidate, resourceCost))
145
+ .sort(
146
+ (left, right) =>
147
+ left.activeResourceUnits - right.activeResourceUnits ||
148
+ left.activeLeaseCount - right.activeLeaseCount ||
149
+ left.runtimeId.localeCompare(right.runtimeId)
150
+ )[0];
151
+ if (!slot) return null;
124
152
  slot.activeLeaseCount += 1;
153
+ slot.activeResourceUnits += resourceCost;
154
+ slot.peakLeaseCount = Math.max(slot.peakLeaseCount, slot.activeLeaseCount);
155
+ slot.peakResourceUnits = Math.max(slot.peakResourceUnits, slot.activeResourceUnits);
125
156
  return slot;
126
157
  }
127
158
 
128
- function releaseRuntimeSlot(slot) {
159
+ function releaseRuntimeSlot(slot, task = {}) {
129
160
  slot.activeLeaseCount = Math.max(0, slot.activeLeaseCount - 1);
161
+ slot.activeResourceUnits = Math.max(0, slot.activeResourceUnits - (task.resourceCost || 1));
130
162
  }
131
163
 
132
164
  async function getReadyContext(slot, productDir, task, lifecycle, runtimeHooks) {
@@ -163,6 +195,21 @@ function tryAcquireLocks(lockMap, lockNames, leaseId) {
163
195
  return true;
164
196
  }
165
197
 
198
+ function locksAvailable(lockMap, lockNames) {
199
+ return [...new Set(lockNames)].every((lockName) => !lockMap.has(lockName));
200
+ }
201
+
202
+ function claimableRuntimeSlot(pool, task) {
203
+ const resourceCost = task.resourceCost || 1;
204
+ return (
205
+ pool.slots.find((slot) => !slot.draining && slotHasCapacity(slot, resourceCost)) || null
206
+ );
207
+ }
208
+
209
+ function slotHasCapacity(slot, resourceCost) {
210
+ return slot.activeResourceUnits + resourceCost <= slot.graph.maxConcurrentTasks;
211
+ }
212
+
166
213
  function releaseLocks(lockMap, lockNames, leaseId) {
167
214
  for (const lockName of [...new Set(lockNames)].sort()) {
168
215
  if (lockMap.get(lockName) === leaseId) {
@@ -76,6 +76,7 @@ describe("runtime-manager", () => {
76
76
  dirName: "api",
77
77
  targetNames: ["api"],
78
78
  instanceCount: 2,
79
+ maxConcurrentTasks: 1,
79
80
  },
80
81
  ],
81
82
  hooks: makeHooks(events),
@@ -93,7 +94,7 @@ describe("runtime-manager", () => {
93
94
  await manager.cleanupAll();
94
95
  });
95
96
 
96
- it("blocks conflicting locks until the first lease releases", async () => {
97
+ it("marks conflicting locks unavailable until the first lease releases", async () => {
97
98
  const productDir = makeTempDir("testkit-runtime-manager-");
98
99
  const manager = createRuntimeManager({
99
100
  productDir,
@@ -104,24 +105,17 @@ describe("runtime-manager", () => {
104
105
  dirName: "api",
105
106
  targetNames: ["api"],
106
107
  instanceCount: 1,
108
+ maxConcurrentTasks: 1,
107
109
  },
108
110
  ],
109
111
  hooks: makeHooks({ created: [], ready: [], cleaned: [] }),
110
112
  });
111
113
 
112
114
  const firstLease = await manager.acquire(makeTask(1, { locks: ["shared-lock"] }));
113
- let secondSettled = false;
114
- const secondPromise = manager.acquire(makeTask(2, { locks: ["shared-lock"] })).then((lease) => {
115
- secondSettled = true;
116
- return lease;
117
- });
118
-
119
- await new Promise((resolve) => setTimeout(resolve, 20));
120
- expect(secondSettled).toBe(false);
115
+ expect(manager.canAcquire(makeTask(2, { locks: ["shared-lock"] }))).toBe(false);
121
116
 
122
117
  await manager.release(firstLease);
123
- const secondLease = await secondPromise;
124
- expect(secondSettled).toBe(true);
118
+ const secondLease = await manager.acquire(makeTask(2, { locks: ["shared-lock"] }));
125
119
 
126
120
  await manager.release(secondLease);
127
121
  await manager.cleanupAll();
@@ -139,6 +133,7 @@ describe("runtime-manager", () => {
139
133
  dirName: "api",
140
134
  targetNames: ["api"],
141
135
  instanceCount: 1,
136
+ maxConcurrentTasks: 2,
142
137
  },
143
138
  ],
144
139
  hooks: makeHooks(events),
@@ -165,6 +160,7 @@ describe("runtime-manager", () => {
165
160
  dirName: "api",
166
161
  targetNames: ["api"],
167
162
  instanceCount: 1,
163
+ maxConcurrentTasks: 1,
168
164
  },
169
165
  ],
170
166
  hooks: makeHooks({ created: [], ready: [], cleaned: [] }),
@@ -178,4 +174,33 @@ describe("runtime-manager", () => {
178
174
 
179
175
  await manager.cleanupAll();
180
176
  });
177
+
178
+ it("exposes runtime capacity through canAcquire", async () => {
179
+ const productDir = makeTempDir("testkit-runtime-manager-");
180
+ const manager = createRuntimeManager({
181
+ productDir,
182
+ lifecycle: makeLifecycle(),
183
+ graphs: [
184
+ {
185
+ key: "api",
186
+ dirName: "api",
187
+ targetNames: ["api"],
188
+ instanceCount: 1,
189
+ maxConcurrentTasks: 2,
190
+ },
191
+ ],
192
+ hooks: makeHooks({ created: [], ready: [], cleaned: [] }),
193
+ });
194
+
195
+ expect(manager.canAcquire(makeTask(1))).toBe(true);
196
+ const leaseOne = await manager.acquire(makeTask(1));
197
+ expect(manager.canAcquire(makeTask(2))).toBe(true);
198
+ const leaseTwo = await manager.acquire(makeTask(2));
199
+ expect(manager.canAcquire(makeTask(3))).toBe(false);
200
+
201
+ await manager.release(leaseOne);
202
+ expect(manager.canAcquire(makeTask(3))).toBe(true);
203
+ await manager.release(leaseTwo);
204
+ await manager.cleanupAll();
205
+ });
181
206
  });
@@ -1,7 +1,7 @@
1
1
  import { resolveServiceCwd } from "../config/index.mjs";
2
2
  import { buildExecutionEnv, numericPortFromUrl } from "./template.mjs";
3
3
  import { DEFAULT_READY_TIMEOUT_MS, assertLocalServicePortsAvailable, isPortInUse, waitForReady } from "./readiness.mjs";
4
- import { pipeOutput, startDetachedCommand, stopChildProcess, sleep } from "./processes.mjs";
4
+ import { killChildProcess, pipeOutput, startDetachedCommand, stopChildProcess, sleep } from "./processes.mjs";
5
5
  import { readDatabaseUrl } from "./state-io.mjs";
6
6
 
7
7
  export async function startLocalServices(runtimeConfigs, lifecycle) {
@@ -43,7 +43,9 @@ export async function startLocalService(config, lifecycle) {
43
43
  pipeOutput(child.stdout, `[${config.runtimeLabel}:${config.name}]`),
44
44
  pipeOutput(child.stderr, `[${config.runtimeLabel}:${config.name}]`),
45
45
  ];
46
- lifecycle.registerService(config, child, cwd);
46
+ lifecycle.registerService(config, child, cwd, () => {
47
+ killChildProcess(child, "SIGTERM");
48
+ });
47
49
 
48
50
  const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
49
51
 
@@ -0,0 +1,330 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ const CLASSIFICATIONS = new Set([
5
+ "expected_failure",
6
+ "infra",
7
+ "product_bug",
8
+ "stale_test",
9
+ "test_bug",
10
+ ]);
11
+ const STATES = new Set(["closed", "open"]);
12
+
13
+ export function loadKnownFailuresConfig(productDir, config) {
14
+ const relativePath = config?.knownFailuresFile;
15
+ if (!relativePath) return null;
16
+
17
+ const absolutePath = path.resolve(productDir, relativePath);
18
+ if (!fs.existsSync(absolutePath)) {
19
+ throw new Error(`Known failures file not found: ${relativePath}`);
20
+ }
21
+
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(fs.readFileSync(absolutePath, "utf8"));
25
+ } catch (error) {
26
+ throw new Error(
27
+ `Could not parse known failures file ${relativePath}: ${formatErrorMessage(error)}`
28
+ );
29
+ }
30
+
31
+ return normalizeKnownFailuresDocument(parsed, relativePath);
32
+ }
33
+
34
+ export function applyKnownFailuresToArtifacts(runArtifact, statusArtifact, knownFailures) {
35
+ if (!knownFailures) return { runArtifact, statusArtifact };
36
+
37
+ const runEntries = extractRunFileEntries(runArtifact);
38
+ const statusEntries = extractStatusFileEntries(statusArtifact);
39
+ const fileSummaries = new Map();
40
+
41
+ for (const entry of [...runEntries, ...statusEntries]) {
42
+ const key = buildFileIdentity(entry.service, entry.type, entry.path);
43
+ if (!fileSummaries.has(key)) {
44
+ fileSummaries.set(key, {
45
+ service: entry.service,
46
+ type: entry.type,
47
+ path: entry.path,
48
+ status: entry.status,
49
+ error: entry.error || null,
50
+ failureDetails: Array.isArray(entry.failureDetails) ? entry.failureDetails : [],
51
+ });
52
+ }
53
+ }
54
+
55
+ const matchesByFileKey = new Map();
56
+ const matchedByFailedEntryIds = new Set();
57
+
58
+ for (const entry of knownFailures.entries) {
59
+ for (const fileSummary of fileSummaries.values()) {
60
+ if (!matchesKnownFailureEntry(entry, fileSummary)) continue;
61
+ const fileKey = buildFileIdentity(fileSummary.service, fileSummary.type, fileSummary.path);
62
+ if (!matchesByFileKey.has(fileKey)) {
63
+ matchesByFileKey.set(fileKey, []);
64
+ }
65
+ matchesByFileKey.get(fileKey).push(toArtifactTriageEntry(entry));
66
+ if (fileSummary.status === "failed") {
67
+ matchedByFailedEntryIds.add(entry.id);
68
+ }
69
+ }
70
+ }
71
+
72
+ for (const entry of [...runEntries, ...statusEntries]) {
73
+ const fileKey = buildFileIdentity(entry.service, entry.type, entry.path);
74
+ const matches = matchesByFileKey.get(fileKey) || [];
75
+ if (matches.length === 0) {
76
+ if (entry.status === "failed") {
77
+ setEntryTriage(entry, {
78
+ status: "untriaged",
79
+ entries: [],
80
+ });
81
+ }
82
+ continue;
83
+ }
84
+
85
+ setEntryTriage(entry, {
86
+ status: entry.status === "failed" ? "known_failure" : "known_issue_not_reproduced",
87
+ classifications: [...new Set(matches.map((match) => match.classification))].sort(),
88
+ entries: matches,
89
+ });
90
+ }
91
+
92
+ const summaryTests = statusArtifact?.tests || runEntries;
93
+ const triageSummary = buildTriageSummary(
94
+ summaryTests,
95
+ knownFailures.entries,
96
+ matchedByFailedEntryIds
97
+ );
98
+ runArtifact.triageSummary = triageSummary;
99
+ if (statusArtifact) {
100
+ statusArtifact.triageSummary = triageSummary;
101
+ }
102
+ return { runArtifact, statusArtifact };
103
+ }
104
+
105
+ export function normalizeKnownFailuresDocument(document, relativePath = "known failures file") {
106
+ if (!document || typeof document !== "object") {
107
+ throw new Error(`${relativePath} must contain a JSON object`);
108
+ }
109
+ if (document.schemaVersion !== 1) {
110
+ throw new Error(`${relativePath} schemaVersion must be 1`);
111
+ }
112
+ if (!Array.isArray(document.entries)) {
113
+ throw new Error(`${relativePath} entries must be an array`);
114
+ }
115
+
116
+ const ids = new Set();
117
+ const entries = document.entries.map((entry, index) => {
118
+ const normalized = normalizeKnownFailureEntry(entry, `${relativePath} entries[${index}]`);
119
+ if (ids.has(normalized.id)) {
120
+ throw new Error(`${relativePath} has duplicate entry id "${normalized.id}"`);
121
+ }
122
+ ids.add(normalized.id);
123
+ return normalized;
124
+ });
125
+
126
+ return {
127
+ schemaVersion: 1,
128
+ issueRepo: normalizeOptionalString(document.issueRepo),
129
+ entries,
130
+ };
131
+ }
132
+
133
+ function normalizeKnownFailureEntry(entry, label) {
134
+ if (!entry || typeof entry !== "object") {
135
+ throw new Error(`${label} must be an object`);
136
+ }
137
+
138
+ const id = requireNonEmptyString(entry.id, `${label}.id`);
139
+ const title = requireNonEmptyString(entry.title, `${label}.title`);
140
+ const classification = requireEnumValue(
141
+ entry.classification,
142
+ CLASSIFICATIONS,
143
+ `${label}.classification`
144
+ );
145
+ const state = requireEnumValue(entry.state, STATES, `${label}.state`);
146
+ const description = requireNonEmptyString(entry.description, `${label}.description`);
147
+ const whyFailing = requireNonEmptyString(entry.whyFailing, `${label}.whyFailing`);
148
+ const lastReviewedAt = requireNonEmptyString(entry.lastReviewedAt, `${label}.lastReviewedAt`);
149
+ if (!Array.isArray(entry.matches) || entry.matches.length === 0) {
150
+ throw new Error(`${label}.matches must be a non-empty array`);
151
+ }
152
+
153
+ return {
154
+ id,
155
+ title,
156
+ classification,
157
+ state,
158
+ issue: normalizeKnownFailureIssue(entry.issue, `${label}.issue`),
159
+ description,
160
+ whyFailing,
161
+ lastReviewedAt,
162
+ matches: entry.matches.map((match, index) =>
163
+ normalizeKnownFailureMatch(match, `${label}.matches[${index}]`)
164
+ ),
165
+ };
166
+ }
167
+
168
+ function normalizeKnownFailureIssue(issue, label) {
169
+ if (!issue || typeof issue !== "object") {
170
+ throw new Error(`${label} must be an object`);
171
+ }
172
+
173
+ const repo = requireNonEmptyString(issue.repo, `${label}.repo`);
174
+ const number = issue.number;
175
+ if (!Number.isInteger(number) || number <= 0) {
176
+ throw new Error(`${label}.number must be a positive integer`);
177
+ }
178
+ const url = requireNonEmptyString(issue.url, `${label}.url`);
179
+
180
+ return { repo, number, url };
181
+ }
182
+
183
+ function normalizeKnownFailureMatch(match, label) {
184
+ if (!match || typeof match !== "object") {
185
+ throw new Error(`${label} must be an object`);
186
+ }
187
+
188
+ const pathValue = requireNonEmptyString(match.path, `${label}.path`);
189
+ const normalized = {
190
+ path: pathValue,
191
+ };
192
+
193
+ const service = normalizeOptionalString(match.service);
194
+ if (service) normalized.service = service;
195
+
196
+ const type = normalizeOptionalString(match.type);
197
+ if (type) normalized.type = type;
198
+
199
+ const failureKey = normalizeOptionalString(match.failureKey);
200
+ if (failureKey) normalized.failureKey = failureKey;
201
+
202
+ const errorIncludes = normalizeOptionalString(match.errorIncludes);
203
+ if (errorIncludes) normalized.errorIncludes = errorIncludes;
204
+
205
+ return normalized;
206
+ }
207
+
208
+ function matchesKnownFailureEntry(entry, fileSummary) {
209
+ return entry.matches.some((match) => matchesKnownFailureMatch(match, fileSummary));
210
+ }
211
+
212
+ function matchesKnownFailureMatch(match, fileSummary) {
213
+ if (match.service && match.service !== fileSummary.service) return false;
214
+ if (match.type && match.type !== fileSummary.type) return false;
215
+ if (match.path !== fileSummary.path) return false;
216
+ if (match.failureKey) {
217
+ const failureKeys = Array.isArray(fileSummary.failureDetails)
218
+ ? fileSummary.failureDetails.map((detail) => detail.key)
219
+ : [];
220
+ if (!failureKeys.includes(match.failureKey)) return false;
221
+ }
222
+ if (match.errorIncludes && !String(fileSummary.error || "").includes(match.errorIncludes)) {
223
+ return false;
224
+ }
225
+ return true;
226
+ }
227
+
228
+ function toArtifactTriageEntry(entry) {
229
+ return {
230
+ id: entry.id,
231
+ title: entry.title,
232
+ classification: entry.classification,
233
+ state: entry.state,
234
+ issue: entry.issue,
235
+ description: entry.description,
236
+ whyFailing: entry.whyFailing,
237
+ lastReviewedAt: entry.lastReviewedAt,
238
+ };
239
+ }
240
+
241
+ function buildTriageSummary(tests, entries, matchedEntryIds) {
242
+ const failedTests = tests.filter((test) => test.status === "failed");
243
+ const knownFailedTests = failedTests.filter((test) => test.triage?.status === "known_failure");
244
+ const byClassification = {};
245
+
246
+ for (const test of knownFailedTests) {
247
+ for (const classification of test.triage?.classifications || []) {
248
+ byClassification[classification] = (byClassification[classification] || 0) + 1;
249
+ }
250
+ }
251
+
252
+ return {
253
+ failed: {
254
+ total: failedTests.length,
255
+ known: knownFailedTests.length,
256
+ untriaged: failedTests.length - knownFailedTests.length,
257
+ byClassification,
258
+ },
259
+ entries: {
260
+ total: entries.length,
261
+ matchedByFailedTests: matchedEntryIds.size,
262
+ unmatched: entries.length - matchedEntryIds.size,
263
+ },
264
+ };
265
+ }
266
+
267
+ function extractRunFileEntries(runArtifact) {
268
+ const entries = [];
269
+
270
+ for (const service of runArtifact.services || []) {
271
+ for (const suite of service.suites || []) {
272
+ for (const file of suite.files || []) {
273
+ entries.push({
274
+ target: file,
275
+ service: service.name,
276
+ type: suite.type,
277
+ path: file.path,
278
+ status: file.status,
279
+ error: file.error || null,
280
+ failureDetails: Array.isArray(file.failureDetails) ? file.failureDetails : [],
281
+ });
282
+ }
283
+ }
284
+ }
285
+
286
+ return entries;
287
+ }
288
+
289
+ function extractStatusFileEntries(statusArtifact) {
290
+ return statusArtifact?.tests || [];
291
+ }
292
+
293
+ function setEntryTriage(entry, triage) {
294
+ if (entry?.target) {
295
+ entry.target.triage = triage;
296
+ return;
297
+ }
298
+ entry.triage = triage;
299
+ }
300
+
301
+ function buildFileIdentity(service, type, filePath) {
302
+ return `${service}::${type}::${filePath}`;
303
+ }
304
+
305
+ function requireNonEmptyString(value, label) {
306
+ const normalized = normalizeOptionalString(value);
307
+ if (!normalized) {
308
+ throw new Error(`${label} must be a non-empty string`);
309
+ }
310
+ return normalized;
311
+ }
312
+
313
+ function requireEnumValue(value, allowed, label) {
314
+ const normalized = requireNonEmptyString(value, label);
315
+ if (!allowed.has(normalized)) {
316
+ throw new Error(`${label} must be one of: ${[...allowed].sort().join(", ")}`);
317
+ }
318
+ return normalized;
319
+ }
320
+
321
+ function normalizeOptionalString(value) {
322
+ if (typeof value !== "string") return null;
323
+ const normalized = value.trim();
324
+ return normalized.length > 0 ? normalized : null;
325
+ }
326
+
327
+ function formatErrorMessage(error) {
328
+ if (error instanceof Error) return error.message;
329
+ return String(error);
330
+ }