@elench/testkit 0.1.86 → 0.1.88
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 +19 -12
- package/lib/cli/agents/providers/claude.mjs +1 -1
- package/lib/cli/agents/providers/codex.mjs +1 -1
- package/lib/cli/assistant/prompt-builder.mjs +78 -0
- package/lib/cli/assistant/protocol.mjs +67 -0
- package/lib/cli/assistant/session.mjs +92 -0
- package/lib/cli/assistant/slash-commands.mjs +160 -0
- package/lib/cli/assistant/state.mjs +279 -0
- package/lib/cli/assistant/tool-registry.mjs +236 -0
- package/lib/cli/assistant/tool-run-reporter.mjs +80 -0
- package/lib/cli/command-helpers.mjs +40 -24
- package/lib/cli/commands/assistant.mjs +84 -0
- package/lib/cli/entrypoint.mjs +37 -11
- package/lib/cli/presentation/tree-reporter.mjs +34 -28
- package/lib/cli/tui/assistant-app.mjs +131 -0
- package/lib/cli/tui/detail-pane.mjs +161 -0
- package/lib/cli/tui/filter-bar.mjs +12 -0
- package/lib/cli/tui/fuzzy-match.mjs +106 -0
- package/lib/cli/tui/inspect-app.mjs +306 -0
- package/lib/cli/tui/inspect-artifact-adapter.mjs +3 -0
- package/lib/cli/tui/inspect-live-adapter.mjs +15 -0
- package/lib/cli/tui/inspect-model.mjs +817 -0
- package/lib/cli/tui/inspect-state.mjs +321 -0
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +6 -6
- package/lib/cli/commands/artifacts.mjs +0 -45
- package/lib/cli/commands/investigate.mjs +0 -87
- package/lib/cli/commands/logs.mjs +0 -47
- package/lib/cli/commands/show.mjs +0 -47
- package/lib/cli/commands/watch.mjs +0 -23
- package/lib/cli/tui/run-app.mjs +0 -1
- package/lib/cli/tui/run-session-app.mjs +0 -432
- package/lib/cli/tui/run-session-state.mjs +0 -505
- package/lib/cli/tui/run-tree-state.mjs +0 -1
- package/lib/cli/tui/watch-app.mjs +0 -220
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
import { fileDisplayName } from "../../discovery/index.mjs";
|
|
2
|
+
import { suiteSelectionType } from "../../runner/suite-selection.mjs";
|
|
3
|
+
import { formatDuration } from "../../runner/formatting.mjs";
|
|
4
|
+
import { matchInspectEntry } from "./fuzzy-match.mjs";
|
|
5
|
+
|
|
6
|
+
export const INSPECT_PANES = ["detail", "artifacts", "logs", "setup"];
|
|
7
|
+
|
|
8
|
+
export function buildSummaryRows({
|
|
9
|
+
result,
|
|
10
|
+
passed,
|
|
11
|
+
failed,
|
|
12
|
+
skipped,
|
|
13
|
+
notRun,
|
|
14
|
+
files,
|
|
15
|
+
duration,
|
|
16
|
+
serviceErrors = 0,
|
|
17
|
+
regressionSummary = null,
|
|
18
|
+
}) {
|
|
19
|
+
const rows = [
|
|
20
|
+
["Result", result],
|
|
21
|
+
["Passed", String(passed)],
|
|
22
|
+
["Failed", String(failed)],
|
|
23
|
+
["Skipped", String(skipped)],
|
|
24
|
+
["Not run", String(notRun)],
|
|
25
|
+
["Files", String(files)],
|
|
26
|
+
["Duration", duration],
|
|
27
|
+
];
|
|
28
|
+
if (serviceErrors > 0) rows.push(["Runtime errors", String(serviceErrors)]);
|
|
29
|
+
if ((regressionSummary?.newRegressions || 0) > 0) {
|
|
30
|
+
rows.push(["New regressions", String(regressionSummary.newRegressions)]);
|
|
31
|
+
}
|
|
32
|
+
if ((regressionSummary?.knownRegressions || 0) > 0) {
|
|
33
|
+
rows.push(["Known regressions", String(regressionSummary.knownRegressions)]);
|
|
34
|
+
}
|
|
35
|
+
if ((regressionSummary?.fixedKnownRegressions || 0) > 0) {
|
|
36
|
+
rows.push(["Fixed known", String(regressionSummary.fixedKnownRegressions)]);
|
|
37
|
+
}
|
|
38
|
+
if ((regressionSummary?.catalogStale || 0) > 0) {
|
|
39
|
+
rows.push(["Catalog stale", String(regressionSummary.catalogStale)]);
|
|
40
|
+
}
|
|
41
|
+
return rows;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createEmptyInspectModel(dataSource = "live") {
|
|
45
|
+
return {
|
|
46
|
+
dataSource,
|
|
47
|
+
services: new Map(),
|
|
48
|
+
summaryData: null,
|
|
49
|
+
regressionCatalog: null,
|
|
50
|
+
runArtifact: null,
|
|
51
|
+
finished: dataSource === "artifact",
|
|
52
|
+
totalCount: 0,
|
|
53
|
+
completedCount: 0,
|
|
54
|
+
phase: null,
|
|
55
|
+
filterActive: false,
|
|
56
|
+
filterQuery: "",
|
|
57
|
+
filterMatches: new Map(),
|
|
58
|
+
collapsedOverrides: new Map(),
|
|
59
|
+
selectedEntryId: null,
|
|
60
|
+
paneMode: "detail",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resetInspectModel(model, dataSource = model.dataSource) {
|
|
65
|
+
model.dataSource = dataSource;
|
|
66
|
+
model.services = new Map();
|
|
67
|
+
model.summaryData = null;
|
|
68
|
+
model.regressionCatalog = null;
|
|
69
|
+
model.runArtifact = null;
|
|
70
|
+
model.finished = dataSource === "artifact";
|
|
71
|
+
model.totalCount = 0;
|
|
72
|
+
model.completedCount = 0;
|
|
73
|
+
model.phase = null;
|
|
74
|
+
model.filterActive = false;
|
|
75
|
+
model.filterQuery = "";
|
|
76
|
+
model.filterMatches = new Map();
|
|
77
|
+
model.collapsedOverrides = new Map();
|
|
78
|
+
model.selectedEntryId = null;
|
|
79
|
+
model.paneMode = "detail";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function initModelFromPlans(model, servicePlans) {
|
|
83
|
+
for (const plan of servicePlans) {
|
|
84
|
+
const service = getOrCreateService(model, plan.config.name);
|
|
85
|
+
if (plan.skipped) {
|
|
86
|
+
service.skipped = true;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
for (const suite of plan.suites || []) {
|
|
90
|
+
const displayType = suite.displayType || suiteSelectionType(suite.type, suite.framework);
|
|
91
|
+
const typeNode = getOrCreateType(service, displayType);
|
|
92
|
+
const suiteKey = buildSuiteKey(displayType, suite.name);
|
|
93
|
+
const suiteNode = getOrCreateSuite(typeNode, suiteKey, {
|
|
94
|
+
name: suite.name,
|
|
95
|
+
groupLabel: fileDisplayName(suite.name),
|
|
96
|
+
framework: suite.framework,
|
|
97
|
+
});
|
|
98
|
+
for (const filePath of suite.files || []) {
|
|
99
|
+
if (suiteNode.files.has(filePath)) continue;
|
|
100
|
+
suiteNode.files.set(filePath, {
|
|
101
|
+
id: buildFileId(service.name, filePath),
|
|
102
|
+
kind: "file",
|
|
103
|
+
serviceName: service.name,
|
|
104
|
+
type: displayType,
|
|
105
|
+
suiteKey,
|
|
106
|
+
suiteName: suite.name,
|
|
107
|
+
framework: suite.framework,
|
|
108
|
+
path: filePath,
|
|
109
|
+
displayName: fileDisplayName(filePath),
|
|
110
|
+
status: "pending",
|
|
111
|
+
durationMs: null,
|
|
112
|
+
error: null,
|
|
113
|
+
failureDetails: [],
|
|
114
|
+
diagnosis: null,
|
|
115
|
+
skipReason: null,
|
|
116
|
+
artifacts: [],
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!model.selectedEntryId) {
|
|
122
|
+
model.selectedEntryId = findFirstNavigableEntryId(model);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function applyArtifactToModel(model, artifact) {
|
|
127
|
+
resetInspectModel(model, artifact?.run?.status === "running" ? "live" : "artifact");
|
|
128
|
+
model.runArtifact = artifact;
|
|
129
|
+
model.finished = artifact?.run?.status !== "running";
|
|
130
|
+
model.phase = model.finished ? "run complete" : "live artifact";
|
|
131
|
+
const fileSummary = artifact?.summary?.files || {};
|
|
132
|
+
model.totalCount = fileSummary.total || 0;
|
|
133
|
+
model.completedCount = (fileSummary.passed || 0) + (fileSummary.failed || 0) + (fileSummary.skipped || 0) + (fileSummary.notRun || 0);
|
|
134
|
+
model.summaryData = {
|
|
135
|
+
rows: buildSummaryRows({
|
|
136
|
+
result: String(artifact?.run?.status || "passed").toUpperCase(),
|
|
137
|
+
passed: fileSummary.passed || 0,
|
|
138
|
+
failed: fileSummary.failed || 0,
|
|
139
|
+
skipped: fileSummary.skipped || 0,
|
|
140
|
+
notRun: fileSummary.notRun || 0,
|
|
141
|
+
files: fileSummary.total || 0,
|
|
142
|
+
duration: formatDuration(artifact?.run?.durationMs || 0),
|
|
143
|
+
serviceErrors: countArtifactServiceErrors(artifact),
|
|
144
|
+
regressionSummary: artifact?.regressions?.summary || null,
|
|
145
|
+
}),
|
|
146
|
+
result: String(artifact?.run?.status || "passed").toUpperCase(),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
for (const serviceResult of artifact?.services || []) {
|
|
150
|
+
const service = getOrCreateService(model, serviceResult.name);
|
|
151
|
+
service.skipped = Boolean(serviceResult.skipped);
|
|
152
|
+
service.durationMs = serviceResult.durationMs || 0;
|
|
153
|
+
service.errors = Array.isArray(serviceResult.errors) ? serviceResult.errors : [];
|
|
154
|
+
for (const suiteResult of serviceResult.suites || []) {
|
|
155
|
+
const displayType = suiteResult.displayType || suiteResult.type || suiteSelectionType(suiteResult.type, suiteResult.framework);
|
|
156
|
+
const typeNode = getOrCreateType(service, displayType);
|
|
157
|
+
const suiteKey = buildSuiteKey(displayType, suiteResult.name);
|
|
158
|
+
const suiteNode = getOrCreateSuite(typeNode, suiteKey, {
|
|
159
|
+
name: suiteResult.name,
|
|
160
|
+
groupLabel: fileDisplayName(suiteResult.name),
|
|
161
|
+
framework: suiteResult.framework,
|
|
162
|
+
});
|
|
163
|
+
for (const fileResult of suiteResult.files || []) {
|
|
164
|
+
suiteNode.files.set(fileResult.path, {
|
|
165
|
+
id: buildFileId(service.name, fileResult.path),
|
|
166
|
+
kind: "file",
|
|
167
|
+
serviceName: service.name,
|
|
168
|
+
type: displayType,
|
|
169
|
+
suiteKey,
|
|
170
|
+
suiteName: suiteResult.name,
|
|
171
|
+
framework: suiteResult.framework,
|
|
172
|
+
path: fileResult.path,
|
|
173
|
+
displayName: fileDisplayName(fileResult.path),
|
|
174
|
+
status: fileResult.status,
|
|
175
|
+
durationMs: fileResult.durationMs || null,
|
|
176
|
+
error: fileResult.error || null,
|
|
177
|
+
failureDetails: Array.isArray(fileResult.failureDetails) ? fileResult.failureDetails : [],
|
|
178
|
+
diagnosis: fileResult.diagnosis || null,
|
|
179
|
+
skipReason: fileResult.reason || null,
|
|
180
|
+
artifacts: Array.isArray(fileResult.artifacts) ? fileResult.artifacts : [],
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
model.selectedEntryId = findFirstFailureEntryId(model) || findFirstNavigableEntryId(model);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function markFileRunning(model, serviceName, suiteKey, filePath) {
|
|
190
|
+
const file = findFile(model, serviceName, suiteKey, filePath) || findFileByServiceAndPath(model, serviceName, filePath);
|
|
191
|
+
if (!file) return;
|
|
192
|
+
file.status = "running";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function markFileFinished(model, task, outcome) {
|
|
196
|
+
const suiteKey = buildSuiteKey(task.displayType || suiteSelectionType(task.type, task.framework), task.suiteName);
|
|
197
|
+
const file = findFile(model, task.serviceName, suiteKey, task.file) || findFileByServiceAndPath(model, task.serviceName, task.file);
|
|
198
|
+
if (!file) return;
|
|
199
|
+
const selectedEntry = model.selectedEntryId ? getEntryById(model, model.selectedEntryId) : null;
|
|
200
|
+
if (outcome.status === "skipped") {
|
|
201
|
+
file.status = "skipped";
|
|
202
|
+
file.skipReason = outcome.reason || null;
|
|
203
|
+
} else if (outcome.status === "not_run") {
|
|
204
|
+
file.status = "not_run";
|
|
205
|
+
file.skipReason = outcome.reason || null;
|
|
206
|
+
} else if (outcome.failed) {
|
|
207
|
+
file.status = "failed";
|
|
208
|
+
file.error = outcome.error || null;
|
|
209
|
+
file.failureDetails = Array.isArray(outcome.failureDetails) ? outcome.failureDetails : [];
|
|
210
|
+
file.diagnosis = outcome.diagnosis || file.diagnosis || null;
|
|
211
|
+
file.artifacts = Array.isArray(outcome.artifacts) ? outcome.artifacts : file.artifacts;
|
|
212
|
+
} else {
|
|
213
|
+
file.status = "passed";
|
|
214
|
+
file.error = null;
|
|
215
|
+
file.failureDetails = [];
|
|
216
|
+
}
|
|
217
|
+
file.durationMs = outcome.durationMs || null;
|
|
218
|
+
model.completedCount += 1;
|
|
219
|
+
if (file.status === "failed" && (!selectedEntry || selectedEntry.kind !== "file" || selectedEntry.status !== "failed")) {
|
|
220
|
+
model.selectedEntryId = file.id;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function markServiceSkipped(model, serviceName, reason) {
|
|
225
|
+
const service = getOrCreateService(model, serviceName);
|
|
226
|
+
service.skipped = true;
|
|
227
|
+
service.skipReason = reason || null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function markPlannedSkip(model, entry) {
|
|
231
|
+
const file = findFileByServiceAndPath(model, entry.serviceName, entry.file);
|
|
232
|
+
if (!file) return;
|
|
233
|
+
file.status = "skipped";
|
|
234
|
+
file.skipReason = entry.reason || null;
|
|
235
|
+
model.completedCount += 1;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function markRuntimeError(model, task, message) {
|
|
239
|
+
const file = findFileByServiceAndPath(model, task.serviceName, task.file);
|
|
240
|
+
if (!file) return;
|
|
241
|
+
const selectedEntry = model.selectedEntryId ? getEntryById(model, model.selectedEntryId) : null;
|
|
242
|
+
file.status = "failed";
|
|
243
|
+
file.error = message || "runtime error";
|
|
244
|
+
model.completedCount += 1;
|
|
245
|
+
if (!selectedEntry || selectedEntry.kind !== "file" || selectedEntry.status !== "failed") {
|
|
246
|
+
model.selectedEntryId = file.id;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function setTotalFileCount(model, count) {
|
|
251
|
+
model.totalCount = count;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function setPhase(model, label) {
|
|
255
|
+
model.phase = label;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function setRegressionCatalog(model, document) {
|
|
259
|
+
model.regressionCatalog = document;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function finishModel(model, results, durationMs, regressionReport) {
|
|
263
|
+
model.finished = true;
|
|
264
|
+
model.phase = "run complete";
|
|
265
|
+
const totals = summarizeModelFiles(model);
|
|
266
|
+
model.summaryData = {
|
|
267
|
+
rows: buildSummaryRows({
|
|
268
|
+
result: totals.failed > 0 ? "FAILED" : "PASSED",
|
|
269
|
+
passed: totals.passed,
|
|
270
|
+
failed: totals.failed,
|
|
271
|
+
skipped: totals.skipped,
|
|
272
|
+
notRun: totals.notRun,
|
|
273
|
+
files: totals.total,
|
|
274
|
+
duration: formatDuration(durationMs),
|
|
275
|
+
serviceErrors: countServiceErrors(results),
|
|
276
|
+
regressionSummary: regressionReport?.summary || null,
|
|
277
|
+
}),
|
|
278
|
+
result: totals.failed > 0 ? "FAILED" : "PASSED",
|
|
279
|
+
};
|
|
280
|
+
model.selectedEntryId = findFirstFailureEntryId(model) || model.selectedEntryId || findFirstNavigableEntryId(model);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function updateFilter(model, query) {
|
|
284
|
+
model.filterActive = true;
|
|
285
|
+
model.filterQuery = String(query || "");
|
|
286
|
+
model.filterMatches = new Map();
|
|
287
|
+
const normalizedQuery = model.filterQuery.trim();
|
|
288
|
+
if (!normalizedQuery) return;
|
|
289
|
+
for (const entry of collectAllEntries(model)) {
|
|
290
|
+
const match = matchInspectEntry(normalizedQuery, entry);
|
|
291
|
+
if (match.matched) {
|
|
292
|
+
model.filterMatches.set(entry.id, match);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function cyclePane(model) {
|
|
298
|
+
const index = INSPECT_PANES.indexOf(model.paneMode);
|
|
299
|
+
model.paneMode = INSPECT_PANES[(index + 1) % INSPECT_PANES.length];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function setPane(model, paneMode) {
|
|
303
|
+
if (INSPECT_PANES.includes(paneMode)) {
|
|
304
|
+
model.paneMode = paneMode;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function toggleCollapsed(model, entryId) {
|
|
309
|
+
const entry = getEntryById(model, entryId);
|
|
310
|
+
if (!entry || (entry.kind !== "type" && entry.kind !== "suite")) return;
|
|
311
|
+
const current = isCollapsed(model, entry);
|
|
312
|
+
model.collapsedOverrides.set(entryId, !current);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function revealEntry(model, entryId) {
|
|
316
|
+
const ancestors = getAncestorIds(model, entryId);
|
|
317
|
+
for (const ancestorId of ancestors) {
|
|
318
|
+
const ancestor = getEntryById(model, ancestorId);
|
|
319
|
+
if (ancestor && (ancestor.kind === "type" || ancestor.kind === "suite")) {
|
|
320
|
+
model.collapsedOverrides.set(ancestor.id, false);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
model.selectedEntryId = entryId;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function buildSnapshot(model) {
|
|
327
|
+
const nestedServices = buildNestedServices(model);
|
|
328
|
+
const visibleEntries = buildVisibleEntries(model, nestedServices);
|
|
329
|
+
const selectedEntry = visibleEntries.find((entry) => entry.id === model.selectedEntryId)
|
|
330
|
+
|| visibleEntries[0]
|
|
331
|
+
|| null;
|
|
332
|
+
const selectedEntryId = selectedEntry?.id || null;
|
|
333
|
+
const selectedFailure = toSelectedFailure(selectedEntry);
|
|
334
|
+
const filterResults = [...model.filterMatches.entries()]
|
|
335
|
+
.map(([id, match]) => ({ id, ...match }))
|
|
336
|
+
.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id));
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
dataSource: model.dataSource,
|
|
340
|
+
services: nestedServices,
|
|
341
|
+
visibleEntries,
|
|
342
|
+
selectedEntryId,
|
|
343
|
+
selectedEntry,
|
|
344
|
+
selectedFailureKey: selectedFailure ? `${selectedFailure.serviceName}::${selectedFailure.filePath}` : null,
|
|
345
|
+
selectedFailure,
|
|
346
|
+
failures: collectFailedEntries(model).map(toSelectedFailure),
|
|
347
|
+
completedCount: model.completedCount,
|
|
348
|
+
totalCount: model.totalCount,
|
|
349
|
+
phase: model.phase,
|
|
350
|
+
finished: model.finished,
|
|
351
|
+
summaryData: model.summaryData,
|
|
352
|
+
regressionCatalog: model.regressionCatalog,
|
|
353
|
+
paneMode: model.paneMode,
|
|
354
|
+
filter: {
|
|
355
|
+
active: model.filterActive,
|
|
356
|
+
query: model.filterQuery,
|
|
357
|
+
results: filterResults,
|
|
358
|
+
count: filterResults.length,
|
|
359
|
+
},
|
|
360
|
+
runArtifact: model.runArtifact,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function getOrCreateService(model, serviceName) {
|
|
365
|
+
if (!model.services.has(serviceName)) {
|
|
366
|
+
model.services.set(serviceName, {
|
|
367
|
+
id: buildServiceId(serviceName),
|
|
368
|
+
kind: "service",
|
|
369
|
+
name: serviceName,
|
|
370
|
+
skipped: false,
|
|
371
|
+
skipReason: null,
|
|
372
|
+
durationMs: 0,
|
|
373
|
+
errors: [],
|
|
374
|
+
types: new Map(),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
return model.services.get(serviceName);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function getOrCreateType(service, displayType) {
|
|
381
|
+
if (!service.types.has(displayType)) {
|
|
382
|
+
service.types.set(displayType, {
|
|
383
|
+
id: buildTypeId(service.name, displayType),
|
|
384
|
+
kind: "type",
|
|
385
|
+
serviceName: service.name,
|
|
386
|
+
type: displayType,
|
|
387
|
+
suites: new Map(),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
return service.types.get(displayType);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function getOrCreateSuite(typeNode, suiteKey, meta) {
|
|
394
|
+
if (!typeNode.suites.has(suiteKey)) {
|
|
395
|
+
typeNode.suites.set(suiteKey, {
|
|
396
|
+
id: buildSuiteId(typeNode.serviceName, suiteKey),
|
|
397
|
+
kind: "suite",
|
|
398
|
+
serviceName: typeNode.serviceName,
|
|
399
|
+
type: typeNode.type,
|
|
400
|
+
key: suiteKey,
|
|
401
|
+
name: meta.name,
|
|
402
|
+
groupLabel: meta.groupLabel,
|
|
403
|
+
framework: meta.framework,
|
|
404
|
+
files: new Map(),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return typeNode.suites.get(suiteKey);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function buildNestedServices(model) {
|
|
411
|
+
return [...model.services.values()].map((service) => {
|
|
412
|
+
const types = [...service.types.values()].map((typeNode) => {
|
|
413
|
+
const suites = [...typeNode.suites.values()].map((suite) => {
|
|
414
|
+
const files = [...suite.files.values()];
|
|
415
|
+
const summary = summarizeFiles(files);
|
|
416
|
+
const autoCollapsed = summary.total > 0 && (summary.passed === summary.total || summary.skipped === summary.total);
|
|
417
|
+
return {
|
|
418
|
+
id: suite.id,
|
|
419
|
+
kind: "suite",
|
|
420
|
+
serviceName: service.name,
|
|
421
|
+
type: typeNode.type,
|
|
422
|
+
key: suite.key,
|
|
423
|
+
name: suite.name,
|
|
424
|
+
groupLabel: suite.groupLabel,
|
|
425
|
+
framework: suite.framework,
|
|
426
|
+
files,
|
|
427
|
+
summary,
|
|
428
|
+
autoCollapsed,
|
|
429
|
+
collapsed: isCollapsed(model, { ...suite, autoCollapsed }),
|
|
430
|
+
};
|
|
431
|
+
});
|
|
432
|
+
const summary = summarizeNestedEntries(suites.map((suite) => suite.summary));
|
|
433
|
+
const autoCollapsed = suites.length > 0 && suites.every((suite) => suite.collapsed);
|
|
434
|
+
return {
|
|
435
|
+
id: typeNode.id,
|
|
436
|
+
kind: "type",
|
|
437
|
+
serviceName: service.name,
|
|
438
|
+
type: typeNode.type,
|
|
439
|
+
suites,
|
|
440
|
+
summary,
|
|
441
|
+
autoCollapsed,
|
|
442
|
+
collapsed: isCollapsed(model, { ...typeNode, autoCollapsed }),
|
|
443
|
+
};
|
|
444
|
+
});
|
|
445
|
+
const summary = summarizeNestedEntries(types.map((typeNode) => typeNode.summary));
|
|
446
|
+
return {
|
|
447
|
+
id: service.id,
|
|
448
|
+
kind: "service",
|
|
449
|
+
name: service.name,
|
|
450
|
+
serviceName: service.name,
|
|
451
|
+
skipped: service.skipped,
|
|
452
|
+
skipReason: service.skipReason,
|
|
453
|
+
durationMs: service.durationMs,
|
|
454
|
+
errors: service.errors,
|
|
455
|
+
types,
|
|
456
|
+
summary,
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function buildVisibleEntries(model, services) {
|
|
462
|
+
const entries = [];
|
|
463
|
+
const filterActive = model.filterActive && model.filterQuery.trim().length > 0;
|
|
464
|
+
const includedIds = filterActive ? buildFilteredIdSet(model, services) : null;
|
|
465
|
+
|
|
466
|
+
for (const service of services) {
|
|
467
|
+
pushVisibleEntry(entries, model, service, 0, includedIds);
|
|
468
|
+
if (service.skipped) continue;
|
|
469
|
+
for (const typeNode of service.types) {
|
|
470
|
+
if (!pushVisibleEntry(entries, model, typeNode, 1, includedIds)) continue;
|
|
471
|
+
const typeExpanded = filterActive || !typeNode.collapsed;
|
|
472
|
+
if (!typeExpanded) continue;
|
|
473
|
+
for (const suite of typeNode.suites) {
|
|
474
|
+
if (!pushVisibleEntry(entries, model, suite, 2, includedIds)) continue;
|
|
475
|
+
const suiteExpanded = filterActive || !suite.collapsed;
|
|
476
|
+
if (!suiteExpanded) continue;
|
|
477
|
+
for (const file of suite.files) {
|
|
478
|
+
pushVisibleEntry(entries, model, file, 3, includedIds);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return entries;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function pushVisibleEntry(entries, model, entry, depth, includedIds) {
|
|
488
|
+
if (includedIds && !includedIds.has(entry.id)) return false;
|
|
489
|
+
const base = toPublicEntry(entry, depth);
|
|
490
|
+
const match = model.filterMatches.get(entry.id) || null;
|
|
491
|
+
entries.push({
|
|
492
|
+
...base,
|
|
493
|
+
match,
|
|
494
|
+
});
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function buildFilteredIdSet(model, services) {
|
|
499
|
+
const included = new Set();
|
|
500
|
+
const childrenById = new Map();
|
|
501
|
+
const parentsById = new Map();
|
|
502
|
+
|
|
503
|
+
for (const service of services) {
|
|
504
|
+
childrenById.set(service.id, service.types.map((entry) => entry.id));
|
|
505
|
+
for (const typeNode of service.types) {
|
|
506
|
+
parentsById.set(typeNode.id, service.id);
|
|
507
|
+
childrenById.set(typeNode.id, typeNode.suites.map((entry) => entry.id));
|
|
508
|
+
for (const suite of typeNode.suites) {
|
|
509
|
+
parentsById.set(suite.id, typeNode.id);
|
|
510
|
+
childrenById.set(suite.id, suite.files.map((entry) => entry.id));
|
|
511
|
+
for (const file of suite.files) {
|
|
512
|
+
parentsById.set(file.id, suite.id);
|
|
513
|
+
childrenById.set(file.id, []);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
for (const entryId of model.filterMatches.keys()) {
|
|
520
|
+
included.add(entryId);
|
|
521
|
+
let current = parentsById.get(entryId) || null;
|
|
522
|
+
while (current) {
|
|
523
|
+
included.add(current);
|
|
524
|
+
current = parentsById.get(current) || null;
|
|
525
|
+
}
|
|
526
|
+
const entry = getEntryById(model, entryId);
|
|
527
|
+
if (entry && entry.kind !== "file") {
|
|
528
|
+
includeDescendants(entryId, childrenById, included);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return included;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function includeDescendants(entryId, childrenById, included) {
|
|
536
|
+
for (const childId of childrenById.get(entryId) || []) {
|
|
537
|
+
included.add(childId);
|
|
538
|
+
includeDescendants(childId, childrenById, included);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function toPublicEntry(entry, depth) {
|
|
543
|
+
if (entry.kind === "service") {
|
|
544
|
+
return {
|
|
545
|
+
id: entry.id,
|
|
546
|
+
kind: "service",
|
|
547
|
+
depth,
|
|
548
|
+
label: entry.name,
|
|
549
|
+
serviceName: entry.name,
|
|
550
|
+
skipped: entry.skipped,
|
|
551
|
+
skipReason: entry.skipReason,
|
|
552
|
+
status: entry.skipped ? "skipped" : summarizeStatus(entry.summary),
|
|
553
|
+
summary: entry.summary,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
if (entry.kind === "type") {
|
|
557
|
+
return {
|
|
558
|
+
id: entry.id,
|
|
559
|
+
kind: "type",
|
|
560
|
+
depth,
|
|
561
|
+
label: entry.type,
|
|
562
|
+
serviceName: entry.serviceName,
|
|
563
|
+
type: entry.type,
|
|
564
|
+
status: summarizeStatus(entry.summary),
|
|
565
|
+
summary: entry.summary,
|
|
566
|
+
collapsed: entry.collapsed,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
if (entry.kind === "suite") {
|
|
570
|
+
return {
|
|
571
|
+
id: entry.id,
|
|
572
|
+
kind: "suite",
|
|
573
|
+
depth,
|
|
574
|
+
label: entry.groupLabel,
|
|
575
|
+
serviceName: entry.serviceName,
|
|
576
|
+
type: entry.type,
|
|
577
|
+
suiteKey: entry.key,
|
|
578
|
+
suiteName: entry.name,
|
|
579
|
+
status: summarizeStatus(entry.summary),
|
|
580
|
+
summary: entry.summary,
|
|
581
|
+
collapsed: entry.collapsed,
|
|
582
|
+
framework: entry.framework,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
id: entry.id,
|
|
587
|
+
kind: "file",
|
|
588
|
+
depth,
|
|
589
|
+
label: entry.displayName,
|
|
590
|
+
displayName: entry.displayName,
|
|
591
|
+
serviceName: entry.serviceName,
|
|
592
|
+
type: entry.type,
|
|
593
|
+
suiteKey: entry.suiteKey,
|
|
594
|
+
suiteName: entry.suiteName,
|
|
595
|
+
framework: entry.framework,
|
|
596
|
+
filePath: entry.path,
|
|
597
|
+
status: entry.status,
|
|
598
|
+
durationMs: entry.durationMs,
|
|
599
|
+
error: entry.error,
|
|
600
|
+
failureDetails: entry.failureDetails,
|
|
601
|
+
diagnosis: entry.diagnosis,
|
|
602
|
+
skipReason: entry.skipReason,
|
|
603
|
+
artifacts: entry.artifacts,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function getEntryById(model, entryId) {
|
|
608
|
+
for (const service of model.services.values()) {
|
|
609
|
+
if (service.id === entryId) return service;
|
|
610
|
+
for (const typeNode of service.types.values()) {
|
|
611
|
+
if (typeNode.id === entryId) return typeNode;
|
|
612
|
+
for (const suite of typeNode.suites.values()) {
|
|
613
|
+
if (suite.id === entryId) return suite;
|
|
614
|
+
for (const file of suite.files.values()) {
|
|
615
|
+
if (file.id === entryId) return file;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function findEntryIdForFile(model, serviceName, filePath) {
|
|
624
|
+
const service = model.services.get(serviceName);
|
|
625
|
+
if (!service) return null;
|
|
626
|
+
for (const typeNode of service.types.values()) {
|
|
627
|
+
for (const suite of typeNode.suites.values()) {
|
|
628
|
+
const file = suite.files.get(filePath);
|
|
629
|
+
if (file) return file.id;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export function findEntryIdForService(model, serviceName) {
|
|
636
|
+
return model.services.get(serviceName)?.id || null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function isCollapsed(model, entry) {
|
|
640
|
+
if (model.collapsedOverrides.has(entry.id)) {
|
|
641
|
+
return Boolean(model.collapsedOverrides.get(entry.id));
|
|
642
|
+
}
|
|
643
|
+
return Boolean(entry.autoCollapsed);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function summarizeFiles(files) {
|
|
647
|
+
return files.reduce(
|
|
648
|
+
(summary, file) => {
|
|
649
|
+
summary.total += 1;
|
|
650
|
+
summary.durationMs += file.durationMs || 0;
|
|
651
|
+
if (file.status === "passed") summary.passed += 1;
|
|
652
|
+
else if (file.status === "failed") summary.failed += 1;
|
|
653
|
+
else if (file.status === "skipped") summary.skipped += 1;
|
|
654
|
+
else if (file.status === "not_run") summary.notRun += 1;
|
|
655
|
+
else if (file.status === "running") summary.running += 1;
|
|
656
|
+
else summary.pending += 1;
|
|
657
|
+
return summary;
|
|
658
|
+
},
|
|
659
|
+
{ total: 0, passed: 0, failed: 0, skipped: 0, notRun: 0, running: 0, pending: 0, durationMs: 0 }
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function summarizeNestedEntries(summaries) {
|
|
664
|
+
return summaries.reduce(
|
|
665
|
+
(aggregate, summary) => {
|
|
666
|
+
aggregate.total += summary.total;
|
|
667
|
+
aggregate.passed += summary.passed;
|
|
668
|
+
aggregate.failed += summary.failed;
|
|
669
|
+
aggregate.skipped += summary.skipped;
|
|
670
|
+
aggregate.notRun += summary.notRun;
|
|
671
|
+
aggregate.running += summary.running;
|
|
672
|
+
aggregate.pending += summary.pending;
|
|
673
|
+
aggregate.durationMs += summary.durationMs;
|
|
674
|
+
return aggregate;
|
|
675
|
+
},
|
|
676
|
+
{ total: 0, passed: 0, failed: 0, skipped: 0, notRun: 0, running: 0, pending: 0, durationMs: 0 }
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function summarizeStatus(summary) {
|
|
681
|
+
if (!summary || summary.total === 0) return "pending";
|
|
682
|
+
if (summary.failed > 0) return "failed";
|
|
683
|
+
if (summary.running > 0) return "running";
|
|
684
|
+
if (summary.pending > 0) return "pending";
|
|
685
|
+
if (summary.notRun > 0) return "not_run";
|
|
686
|
+
if (summary.skipped === summary.total) return "skipped";
|
|
687
|
+
return "passed";
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function collectAllEntries(model) {
|
|
691
|
+
const entries = [];
|
|
692
|
+
for (const service of buildNestedServices(model)) {
|
|
693
|
+
entries.push(toPublicEntry(service, 0));
|
|
694
|
+
for (const typeNode of service.types) {
|
|
695
|
+
entries.push(toPublicEntry(typeNode, 1));
|
|
696
|
+
for (const suite of typeNode.suites) {
|
|
697
|
+
entries.push(toPublicEntry(suite, 2));
|
|
698
|
+
for (const file of suite.files) {
|
|
699
|
+
entries.push(toPublicEntry(file, 3));
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return entries;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function summarizeModelFiles(model) {
|
|
708
|
+
return collectAllEntries(model)
|
|
709
|
+
.filter((entry) => entry.kind === "file")
|
|
710
|
+
.reduce(
|
|
711
|
+
(summary, entry) => {
|
|
712
|
+
summary.total += 1;
|
|
713
|
+
if (entry.status === "passed") summary.passed += 1;
|
|
714
|
+
else if (entry.status === "failed") summary.failed += 1;
|
|
715
|
+
else if (entry.status === "skipped") summary.skipped += 1;
|
|
716
|
+
else if (entry.status === "not_run") summary.notRun += 1;
|
|
717
|
+
return summary;
|
|
718
|
+
},
|
|
719
|
+
{ total: 0, passed: 0, failed: 0, skipped: 0, notRun: 0 }
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function countServiceErrors(results) {
|
|
724
|
+
return (results || []).filter((result) => Array.isArray(result.errors) && result.errors.length > 0).length;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function countArtifactServiceErrors(artifact) {
|
|
728
|
+
return (artifact?.services || []).filter((service) => Array.isArray(service.errors) && service.errors.length > 0).length;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function findFile(model, serviceName, suiteKey, filePath) {
|
|
732
|
+
const service = model.services.get(serviceName);
|
|
733
|
+
if (!service) return null;
|
|
734
|
+
for (const typeNode of service.types.values()) {
|
|
735
|
+
const suite = typeNode.suites.get(suiteKey);
|
|
736
|
+
if (!suite) continue;
|
|
737
|
+
const file = suite.files.get(filePath);
|
|
738
|
+
if (file) return file;
|
|
739
|
+
}
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function findFileByServiceAndPath(model, serviceName, filePath) {
|
|
744
|
+
const service = model.services.get(serviceName);
|
|
745
|
+
if (!service) return null;
|
|
746
|
+
for (const typeNode of service.types.values()) {
|
|
747
|
+
for (const suite of typeNode.suites.values()) {
|
|
748
|
+
const file = suite.files.get(filePath);
|
|
749
|
+
if (file) return file;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function findFirstFailureEntryId(model) {
|
|
756
|
+
const failed = collectFailedEntries(model)[0];
|
|
757
|
+
return failed?.id || null;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function collectFailedEntries(model) {
|
|
761
|
+
return collectAllEntries(model)
|
|
762
|
+
.filter((entry) => entry.kind === "file" && entry.status === "failed")
|
|
763
|
+
.sort((left, right) => left.serviceName.localeCompare(right.serviceName) || left.filePath.localeCompare(right.filePath));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function findFirstNavigableEntryId(model) {
|
|
767
|
+
return collectAllEntries(model)[0]?.id || null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function toSelectedFailure(entry) {
|
|
771
|
+
if (!entry || entry.kind !== "file" || entry.status !== "failed") return null;
|
|
772
|
+
return {
|
|
773
|
+
key: `${entry.serviceName}::${entry.filePath}`,
|
|
774
|
+
serviceName: entry.serviceName,
|
|
775
|
+
suiteKey: entry.suiteKey,
|
|
776
|
+
suiteName: entry.suiteName,
|
|
777
|
+
displayType: entry.type,
|
|
778
|
+
framework: entry.framework,
|
|
779
|
+
filePath: entry.filePath,
|
|
780
|
+
displayName: entry.displayName,
|
|
781
|
+
error: entry.error,
|
|
782
|
+
failureDetails: entry.failureDetails,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function getAncestorIds(model, entryId) {
|
|
787
|
+
const entry = getEntryById(model, entryId);
|
|
788
|
+
if (!entry) return [];
|
|
789
|
+
if (entry.kind === "service") return [];
|
|
790
|
+
if (entry.kind === "type") return [buildServiceId(entry.serviceName)];
|
|
791
|
+
if (entry.kind === "suite") return [buildServiceId(entry.serviceName), buildTypeId(entry.serviceName, entry.type)];
|
|
792
|
+
return [
|
|
793
|
+
buildServiceId(entry.serviceName),
|
|
794
|
+
buildTypeId(entry.serviceName, entry.type),
|
|
795
|
+
buildSuiteId(entry.serviceName, entry.suiteKey),
|
|
796
|
+
];
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function buildSuiteKey(displayType, suiteName) {
|
|
800
|
+
return `${displayType}:${suiteName}`;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function buildServiceId(serviceName) {
|
|
804
|
+
return `service:${serviceName}`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function buildTypeId(serviceName, displayType) {
|
|
808
|
+
return `type:${serviceName}:${displayType}`;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function buildSuiteId(serviceName, suiteKey) {
|
|
812
|
+
return `suite:${serviceName}:${suiteKey}`;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function buildFileId(serviceName, filePath) {
|
|
816
|
+
return `file:${serviceName}:${filePath}`;
|
|
817
|
+
}
|