@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.
- package/README.md +13 -0
- package/bin/testkit.mjs +6 -1
- package/lib/cli/index.mjs +4 -2
- package/lib/config/index.mjs +34 -0
- package/lib/reporters/playwright.mjs +34 -5
- package/lib/reporters/playwright.test.mjs +11 -0
- package/lib/runner/default-runtime-runner.mjs +29 -6
- package/lib/runner/execution-config.mjs +17 -0
- package/lib/runner/execution-config.test.mjs +8 -0
- package/lib/runner/failure-details.mjs +91 -0
- package/lib/runner/failure-details.test.mjs +63 -0
- package/lib/runner/lifecycle.mjs +99 -1
- package/lib/runner/orchestrator.mjs +26 -9
- package/lib/runner/planning.mjs +28 -6
- package/lib/runner/planning.test.mjs +38 -0
- package/lib/runner/playwright-config.mjs +5 -0
- package/lib/runner/playwright-config.test.mjs +6 -1
- package/lib/runner/playwright-runner.mjs +21 -4
- package/lib/runner/reporting.mjs +10 -2
- package/lib/runner/reporting.test.mjs +2 -2
- package/lib/runner/results.mjs +8 -0
- package/lib/runner/runtime-manager.mjs +90 -43
- package/lib/runner/runtime-manager.test.mjs +36 -11
- package/lib/runner/services.mjs +4 -2
- package/lib/runner/triage.mjs +330 -0
- package/lib/runner/triage.test.mjs +156 -0
- package/lib/runner/worker-loop.mjs +8 -2
- package/lib/runtime/index.mjs +2 -1
- package/lib/runtime-src/k6/checks.js +130 -0
- package/lib/runtime-src/k6/dal-suite.js +12 -1
- package/lib/runtime-src/k6/suite.js +10 -1
- package/lib/setup/index.d.ts +4 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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]
|
|
120
|
-
(
|
|
121
|
-
|
|
122
|
-
left
|
|
123
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
});
|
package/lib/runner/services.mjs
CHANGED
|
@@ -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
|
+
}
|