@elench/testkit 0.1.112 → 0.1.113
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/lib/bundler/index.mjs +95 -1
- package/lib/cli/components/blocks/run-tree.mjs +28 -63
- package/lib/cli/renderers/run/inline-detail.mjs +64 -0
- package/lib/cli/state/run/model.mjs +24 -95
- package/lib/cli/state/run/state.mjs +0 -22
- package/lib/runner/default-runtime-runner.mjs +3 -1
- package/lib/runner/failure-details.mjs +22 -0
- package/lib/runner/results.mjs +31 -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 +5 -5
- package/lib/cli/components/primitives/filter-bar.mjs +0 -12
- package/lib/cli/state/tree/fuzzy-match.mjs +0 -106
package/lib/bundler/index.mjs
CHANGED
|
@@ -11,6 +11,7 @@ const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
|
|
|
11
11
|
const CONFIG_ENTRY = path.join(PACKAGE_ROOT, "lib", "config-api", "index.mjs");
|
|
12
12
|
const RUNTIME_ENTRY = path.join(PACKAGE_ROOT, "lib", "runtime", "index.mjs");
|
|
13
13
|
const MANIFEST_FILE = "manifest.json";
|
|
14
|
+
const BUNDLE_WRAPPER_VERSION = "summary-check-artifacts-v2";
|
|
14
15
|
const bundleCache = new Map();
|
|
15
16
|
|
|
16
17
|
export async function bundleK6File({
|
|
@@ -105,7 +106,9 @@ async function buildCacheMetadata(sourceFile, configFile = null) {
|
|
|
105
106
|
.update("\0")
|
|
106
107
|
.update(source)
|
|
107
108
|
.update("\0")
|
|
108
|
-
.update(packageJsonText)
|
|
109
|
+
.update(packageJsonText)
|
|
110
|
+
.update("\0")
|
|
111
|
+
.update(BUNDLE_WRAPPER_VERSION);
|
|
109
112
|
let configHash = null;
|
|
110
113
|
|
|
111
114
|
if (configFile && fs.existsSync(configFile)) {
|
|
@@ -119,6 +122,7 @@ async function buildCacheMetadata(sourceFile, configFile = null) {
|
|
|
119
122
|
cacheKey: hash.digest("hex"),
|
|
120
123
|
sourceHash,
|
|
121
124
|
configHash,
|
|
125
|
+
wrapperVersion: BUNDLE_WRAPPER_VERSION,
|
|
122
126
|
testkitVersion: packageJson.version || null,
|
|
123
127
|
};
|
|
124
128
|
}
|
|
@@ -169,6 +173,96 @@ export function setup(...args) {
|
|
|
169
173
|
export default function exec(...args) {
|
|
170
174
|
return suite.exec(...args);
|
|
171
175
|
}
|
|
176
|
+
export function handleSummary(data) {
|
|
177
|
+
const checksData = extractChecksFromSummary(data);
|
|
178
|
+
emitTestkitArtifact("checks", checksData, {
|
|
179
|
+
kind: "testkit.checks",
|
|
180
|
+
summary: checksData.summary
|
|
181
|
+
? \`\${checksData.summary.passed}/\${checksData.summary.total} checks passed\`
|
|
182
|
+
: "no checks",
|
|
183
|
+
});
|
|
184
|
+
return {};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function extractChecksFromSummary(data) {
|
|
188
|
+
const checks = [];
|
|
189
|
+
collectChecksRecursive(data && data.root_group, [], checks);
|
|
190
|
+
const total = checks.length;
|
|
191
|
+
const passed = checks.filter((c) => c.passes > 0 && c.fails === 0).length;
|
|
192
|
+
|
|
193
|
+
const thresholds = {};
|
|
194
|
+
for (const [name, metric] of Object.entries((data && data.metrics) || {})) {
|
|
195
|
+
if (metric && metric.thresholds) {
|
|
196
|
+
thresholds[name] = Object.entries(metric.thresholds).map(([expr, result]) => ({
|
|
197
|
+
expression: expr,
|
|
198
|
+
ok: Boolean(result && result.ok),
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const http = {};
|
|
204
|
+
for (const key of ["http_reqs", "http_req_duration", "http_req_failed"]) {
|
|
205
|
+
if (data && data.metrics && data.metrics[key]) {
|
|
206
|
+
http[key] = data.metrics[key].values || {};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { checks, summary: { total, passed, failed: total - passed }, thresholds, http };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function collectChecksRecursive(group, path, out) {
|
|
214
|
+
if (!group) return;
|
|
215
|
+
const currentPath = normalizeNonEmptyString(group.name) ? [...path, normalizeNonEmptyString(group.name)] : path;
|
|
216
|
+
for (const check of Object.values(group.checks || {})) {
|
|
217
|
+
if (!check) continue;
|
|
218
|
+
out.push({
|
|
219
|
+
name: normalizeNonEmptyString(check.name) || "unnamed",
|
|
220
|
+
path: currentPath,
|
|
221
|
+
passes: normalizeCount(check.passes),
|
|
222
|
+
fails: normalizeCount(check.fails),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
for (const subGroup of Object.values(group.groups || {})) {
|
|
226
|
+
collectChecksRecursive(subGroup, currentPath, out);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const TESTKIT_ARTIFACT_MARKER = "TESTKIT_ARTIFACT:";
|
|
231
|
+
|
|
232
|
+
function emitTestkitArtifact(name, data, options = {}) {
|
|
233
|
+
const payload = encodeURIComponent(JSON.stringify({
|
|
234
|
+
name: normalizeArtifactName(name),
|
|
235
|
+
kind: normalizeOptionalString(options.kind),
|
|
236
|
+
summary: normalizeOptionalString(options.summary),
|
|
237
|
+
contentType: normalizeOptionalString(options.contentType) || "application/json",
|
|
238
|
+
data,
|
|
239
|
+
emittedAt: new Date().toISOString(),
|
|
240
|
+
}));
|
|
241
|
+
console.log(\`\${TESTKIT_ARTIFACT_MARKER}\${payload}\`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function normalizeArtifactName(name) {
|
|
245
|
+
const normalized = normalizeNonEmptyString(name);
|
|
246
|
+
if (!normalized) {
|
|
247
|
+
throw new Error("emitArtifact(name, data) requires a non-empty artifact name");
|
|
248
|
+
}
|
|
249
|
+
return normalized;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function normalizeOptionalString(value) {
|
|
253
|
+
if (value === undefined || value === null) return null;
|
|
254
|
+
const normalized = String(value).trim();
|
|
255
|
+
return normalized.length > 0 ? normalized : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function normalizeNonEmptyString(value) {
|
|
259
|
+
return normalizeOptionalString(value);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizeCount(value) {
|
|
263
|
+
const count = Number(value);
|
|
264
|
+
return Number.isFinite(count) && count > 0 ? count : 0;
|
|
265
|
+
}
|
|
172
266
|
|
|
173
267
|
function normalizeTestkitSuite(module) {
|
|
174
268
|
const candidate = module?.default;
|
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
yellow,
|
|
13
13
|
} from "../../terminal/colors.mjs";
|
|
14
14
|
import { renderSummaryBox } from "../primitives/summary-box.mjs";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { getTerminalWidth } from "../../terminal/layout.mjs";
|
|
16
|
+
import { renderFailureDetail, renderPassedDetail } from "../../renderers/run/inline-detail.mjs";
|
|
17
17
|
|
|
18
18
|
const SPINNER_FRAMES = ["|", "/", "-", "\\"];
|
|
19
19
|
|
|
@@ -50,38 +50,10 @@ export function RunTreeView({
|
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
if (snapshot.filter.active) {
|
|
54
|
-
if (key.escape) {
|
|
55
|
-
runState.deactivateFilter();
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
if (key.return) return;
|
|
59
|
-
if (key.downArrow || input === "j") {
|
|
60
|
-
runState.moveCursorDown();
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
if (key.upArrow || input === "k") {
|
|
64
|
-
runState.moveCursorUp();
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
if (key.backspace || key.delete) {
|
|
68
|
-
runState.updateFilterQuery(snapshot.filter.query.slice(0, -1));
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
if (isPrintableInput(input, key)) {
|
|
72
|
-
runState.updateFilterQuery(`${snapshot.filter.query}${input}`);
|
|
73
|
-
}
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
53
|
if (input === "q") {
|
|
78
54
|
(onRequestClose || exit)();
|
|
79
55
|
return;
|
|
80
56
|
}
|
|
81
|
-
if (input === "/") {
|
|
82
|
-
runState.activateFilter();
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
57
|
if (key.downArrow || input === "j") {
|
|
86
58
|
runState.moveCursorDown();
|
|
87
59
|
return;
|
|
@@ -97,6 +69,7 @@ export function RunTreeView({
|
|
|
97
69
|
}, { isActive: interactive });
|
|
98
70
|
|
|
99
71
|
const visibleTreeEntries = useMemo(() => snapshot.visibleEntries || [], [snapshot.visibleEntries]);
|
|
72
|
+
const terminalWidth = getTerminalWidth(stdout, 100);
|
|
100
73
|
const summaryLines = snapshot.finished && snapshot.summaryData
|
|
101
74
|
? renderSummaryBox(snapshot.summaryData.rows, { stdout })
|
|
102
75
|
: [];
|
|
@@ -109,10 +82,8 @@ export function RunTreeView({
|
|
|
109
82
|
createElement(
|
|
110
83
|
Box,
|
|
111
84
|
{ key: "main", marginTop: 1, flexDirection: "column" },
|
|
112
|
-
...visibleTreeEntries.
|
|
85
|
+
...visibleTreeEntries.flatMap(renderTreeLine.bind(null, snapshot, spinnerFrame, terminalWidth))
|
|
113
86
|
),
|
|
114
|
-
snapshot.filter.active ? createElement(Text, { key: "filter-gap" }, "") : null,
|
|
115
|
-
snapshot.filter.active ? createElement(FilterBar, { key: "filter-bar", filter: snapshot.filter }) : null,
|
|
116
87
|
summaryLines.length > 0 ? createElement(Text, { key: "summary-gap" }, "") : null,
|
|
117
88
|
...summaryLines.map((line, index) => createElement(Text, { key: `summary-${index}` }, line)),
|
|
118
89
|
createElement(Text, { key: "footer-gap" }, ""),
|
|
@@ -124,31 +95,36 @@ export function buildHeaderText(snapshot) {
|
|
|
124
95
|
const progressText = snapshot.totalCount > 0 ? `[${snapshot.completedCount}/${snapshot.totalCount}]` : "[0/0]";
|
|
125
96
|
const phaseText = snapshot.phase || (snapshot.finished ? "run complete" : "preparing");
|
|
126
97
|
const sourceText = snapshot.dataSource === "artifact" ? "artifact run" : snapshot.finished ? "live summary" : "live run";
|
|
127
|
-
|
|
128
|
-
return [progressText, phaseText, sourceText, filterText].filter(Boolean).join(" · ");
|
|
98
|
+
return [progressText, phaseText, sourceText].filter(Boolean).join(" · ");
|
|
129
99
|
}
|
|
130
100
|
|
|
131
101
|
export function buildFooterText(snapshot, { interactive = true } = {}) {
|
|
132
102
|
if (!snapshot.finished) return "Run in progress";
|
|
133
|
-
|
|
134
|
-
return "type to filter · ↑/↓ move · Esc clear filter · q quit";
|
|
135
|
-
}
|
|
136
|
-
return interactive ? "↑/↓ move · Enter collapse/expand · / filter · q quit" : "Run complete";
|
|
103
|
+
return interactive ? "↑/↓ move · Enter toggle detail · q quit" : "Run complete";
|
|
137
104
|
}
|
|
138
105
|
|
|
139
|
-
function renderTreeLine(snapshot, spinnerFrame, entry) {
|
|
106
|
+
function renderTreeLine(snapshot, spinnerFrame, terminalWidth, entry) {
|
|
140
107
|
const selected = entry.id === snapshot.selectedEntryId;
|
|
141
108
|
const pointer = selected ? `${bold(">")} ` : " ";
|
|
142
109
|
const indent = " ".repeat(entry.depth);
|
|
143
|
-
const
|
|
144
|
-
const match = entry.match;
|
|
145
|
-
const highlightedLabel = match?.field === "label"
|
|
146
|
-
? applyHighlight(rawLabel, match.positions, bold)
|
|
147
|
-
: rawLabel;
|
|
148
|
-
const renderedLabel = decorateEntryLabel(entry, highlightedLabel, match);
|
|
110
|
+
const renderedLabel = decorateEntryLabel(entry, entry.label);
|
|
149
111
|
const icon = entryIcon(entry, spinnerFrame);
|
|
150
112
|
const line = `${pointer}${indent}${icon ? `${icon} ` : ""}${renderedLabel}${entrySuffix(entry)}`;
|
|
151
|
-
|
|
113
|
+
|
|
114
|
+
const elements = [createElement(Text, { key: entry.id }, line)];
|
|
115
|
+
|
|
116
|
+
if (entry.kind === "file" && !entry.collapsed && snapshot.finished) {
|
|
117
|
+
const detailLines = entry.status === "failed"
|
|
118
|
+
? renderFailureDetail(entry, { width: terminalWidth, regressionCatalog: snapshot.regressionCatalog })
|
|
119
|
+
: entry.status === "passed"
|
|
120
|
+
? renderPassedDetail(entry, { width: terminalWidth })
|
|
121
|
+
: [];
|
|
122
|
+
for (let i = 0; i < detailLines.length; i++) {
|
|
123
|
+
elements.push(createElement(Text, { key: `${entry.id}-detail-${i}` }, detailLines[i]));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return elements;
|
|
152
128
|
}
|
|
153
129
|
|
|
154
130
|
function entryIcon(entry, spinnerFrame) {
|
|
@@ -162,16 +138,11 @@ function entryIcon(entry, spinnerFrame) {
|
|
|
162
138
|
return dim("·");
|
|
163
139
|
}
|
|
164
140
|
|
|
165
|
-
function decorateEntryLabel(entry, label
|
|
166
|
-
|
|
167
|
-
if (entry.kind === "
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (match?.field === "path" && entry.filePath) {
|
|
172
|
-
rendered += ` ${dim(`(${applyHighlight(entry.filePath, match.positions, bold)})`)}`;
|
|
173
|
-
}
|
|
174
|
-
return rendered;
|
|
141
|
+
function decorateEntryLabel(entry, label) {
|
|
142
|
+
if (entry.kind === "service") return colorService(label);
|
|
143
|
+
if (entry.kind === "type") return colorTypeBadge(label.toUpperCase());
|
|
144
|
+
if (entry.kind === "suite") return bold(label);
|
|
145
|
+
return label;
|
|
175
146
|
}
|
|
176
147
|
|
|
177
148
|
function entrySuffix(entry) {
|
|
@@ -187,9 +158,3 @@ function entrySuffix(entry) {
|
|
|
187
158
|
}
|
|
188
159
|
return "";
|
|
189
160
|
}
|
|
190
|
-
|
|
191
|
-
function isPrintableInput(input, key) {
|
|
192
|
-
if (!input) return false;
|
|
193
|
-
if (key.ctrl || key.meta || key.escape || key.return || key.tab) return false;
|
|
194
|
-
return input >= " ";
|
|
195
|
-
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { buildFailurePresentation } from "../../../runner/formatting.mjs";
|
|
2
|
+
import { renderIndentedBlock } from "../../terminal/layout.mjs";
|
|
3
|
+
import { dim, green, red } from "../../terminal/colors.mjs";
|
|
4
|
+
import figures from "figures";
|
|
5
|
+
|
|
6
|
+
export function renderFailureDetail(entry, { width, regressionCatalog } = {}) {
|
|
7
|
+
const fileSummary = {
|
|
8
|
+
service: entry.serviceName,
|
|
9
|
+
type: normalizeType(entry),
|
|
10
|
+
path: entry.filePath,
|
|
11
|
+
error: entry.error || null,
|
|
12
|
+
failureDetails: Array.isArray(entry.failureDetails) ? entry.failureDetails : [],
|
|
13
|
+
suiteError: null,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const failureView = buildFailurePresentation(fileSummary, regressionCatalog);
|
|
17
|
+
const lines = [];
|
|
18
|
+
const indent = " ".repeat(2 + (entry.depth || 0) * 2 + 2);
|
|
19
|
+
|
|
20
|
+
if (failureView.primary) {
|
|
21
|
+
lines.push(...renderIndentedBlock(failureView.primary, { width, indent }));
|
|
22
|
+
}
|
|
23
|
+
for (const detail of failureView.details) {
|
|
24
|
+
lines.push(...renderIndentedBlock(detail, { width, indent }));
|
|
25
|
+
}
|
|
26
|
+
return lines;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function renderPassedDetail(entry, { width } = {}) {
|
|
30
|
+
const lines = [];
|
|
31
|
+
const indent = " ".repeat(2 + (entry.depth || 0) * 2 + 2);
|
|
32
|
+
|
|
33
|
+
const checks = Array.isArray(entry.checkDetails) ? entry.checkDetails : [];
|
|
34
|
+
if (checks.length > 0) {
|
|
35
|
+
const passed = checks.filter((c) => c.passed).length;
|
|
36
|
+
lines.push(...renderIndentedBlock(dim(`${passed}/${checks.length} checks passed`), { width, indent }));
|
|
37
|
+
const maxDisplay = 8;
|
|
38
|
+
const displayed = checks.slice(0, maxDisplay);
|
|
39
|
+
for (const check of displayed) {
|
|
40
|
+
const icon = check.passed ? green(figures.tick) : red(figures.cross);
|
|
41
|
+
lines.push(...renderIndentedBlock(`${icon} ${dim(check.name)}`, { width, indent: `${indent} ` }));
|
|
42
|
+
}
|
|
43
|
+
if (checks.length > maxDisplay) {
|
|
44
|
+
lines.push(...renderIndentedBlock(dim(`+${checks.length - maxDisplay} more`), { width, indent: `${indent} ` }));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const artifacts = Array.isArray(entry.artifacts) ? entry.artifacts : [];
|
|
49
|
+
for (const artifact of artifacts) {
|
|
50
|
+
if (artifact.kind === "testkit.checks") continue;
|
|
51
|
+
if (artifact.kind === "runtime.output") continue;
|
|
52
|
+
if (artifact.summary) {
|
|
53
|
+
lines.push(...renderIndentedBlock(dim(artifact.summary), { width, indent }));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeType(entry) {
|
|
61
|
+
if (entry.framework === "playwright" || entry.type === "ui") return "ui";
|
|
62
|
+
if (entry.type === "integration") return "int";
|
|
63
|
+
return entry.type;
|
|
64
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { fileDisplayName } from "../../../discovery/index.mjs";
|
|
2
2
|
import { suiteSelectionType } from "../../../runner/suite-selection.mjs";
|
|
3
3
|
import { formatDuration } from "../../../runner/formatting.mjs";
|
|
4
|
-
import { matchRunTreeEntry } from "../tree/fuzzy-match.mjs";
|
|
5
4
|
|
|
6
5
|
export function buildSummaryRows({
|
|
7
6
|
result,
|
|
@@ -51,9 +50,6 @@ export function createEmptyRunModel(dataSource = "live", options = {}) {
|
|
|
51
50
|
totalCount: 0,
|
|
52
51
|
completedCount: 0,
|
|
53
52
|
phase: null,
|
|
54
|
-
filterActive: false,
|
|
55
|
-
filterQuery: "",
|
|
56
|
-
filterMatches: new Map(),
|
|
57
53
|
collapsedOverrides: new Map(),
|
|
58
54
|
selectedEntryId: null,
|
|
59
55
|
};
|
|
@@ -69,9 +65,6 @@ export function resetRunModel(model, dataSource = model.dataSource) {
|
|
|
69
65
|
model.totalCount = 0;
|
|
70
66
|
model.completedCount = 0;
|
|
71
67
|
model.phase = null;
|
|
72
|
-
model.filterActive = false;
|
|
73
|
-
model.filterQuery = "";
|
|
74
|
-
model.filterMatches = new Map();
|
|
75
68
|
model.collapsedOverrides = new Map();
|
|
76
69
|
model.selectedEntryId = null;
|
|
77
70
|
}
|
|
@@ -111,6 +104,7 @@ export function initModelFromPlans(model, servicePlans) {
|
|
|
111
104
|
diagnosis: null,
|
|
112
105
|
skipReason: null,
|
|
113
106
|
artifacts: [],
|
|
107
|
+
checkDetails: [],
|
|
114
108
|
});
|
|
115
109
|
}
|
|
116
110
|
}
|
|
@@ -175,6 +169,7 @@ export function applyArtifactToModel(model, artifact) {
|
|
|
175
169
|
diagnosis: fileResult.diagnosis || null,
|
|
176
170
|
skipReason: fileResult.reason || null,
|
|
177
171
|
artifacts: Array.isArray(fileResult.artifacts) ? fileResult.artifacts : [],
|
|
172
|
+
checkDetails: Array.isArray(fileResult.checkDetails) ? fileResult.checkDetails : [],
|
|
178
173
|
});
|
|
179
174
|
}
|
|
180
175
|
}
|
|
@@ -210,7 +205,10 @@ export function markFileFinished(model, task, outcome) {
|
|
|
210
205
|
file.status = "passed";
|
|
211
206
|
file.error = null;
|
|
212
207
|
file.failureDetails = [];
|
|
208
|
+
file.artifacts = Array.isArray(outcome.artifacts) ? outcome.artifacts : file.artifacts;
|
|
209
|
+
file.diagnosis = outcome.diagnosis || null;
|
|
213
210
|
}
|
|
211
|
+
file.checkDetails = Array.isArray(outcome.checkDetails) ? outcome.checkDetails : file.checkDetails;
|
|
214
212
|
file.durationMs = outcome.durationMs || null;
|
|
215
213
|
model.completedCount += 1;
|
|
216
214
|
if (file.status === "failed" && (!selectedEntry || selectedEntry.kind !== "file" || selectedEntry.status !== "failed")) {
|
|
@@ -277,23 +275,9 @@ export function finishModel(model, results, durationMs, regressionReport) {
|
|
|
277
275
|
model.selectedEntryId = findFirstFailureEntryId(model) || model.selectedEntryId || findFirstNavigableEntryId(model);
|
|
278
276
|
}
|
|
279
277
|
|
|
280
|
-
export function updateFilter(model, query) {
|
|
281
|
-
model.filterActive = true;
|
|
282
|
-
model.filterQuery = String(query || "");
|
|
283
|
-
model.filterMatches = new Map();
|
|
284
|
-
const normalizedQuery = model.filterQuery.trim();
|
|
285
|
-
if (!normalizedQuery) return;
|
|
286
|
-
for (const entry of collectAllEntries(model)) {
|
|
287
|
-
const match = matchRunTreeEntry(normalizedQuery, entry);
|
|
288
|
-
if (match.matched) {
|
|
289
|
-
model.filterMatches.set(entry.id, match);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
278
|
export function toggleCollapsed(model, entryId) {
|
|
295
279
|
const entry = getEntryById(model, entryId);
|
|
296
|
-
if (!entry || (entry.kind !== "type" && entry.kind !== "suite")) return;
|
|
280
|
+
if (!entry || (entry.kind !== "type" && entry.kind !== "suite" && entry.kind !== "file")) return;
|
|
297
281
|
const current = isCollapsed(model, entry);
|
|
298
282
|
model.collapsedOverrides.set(entryId, !current);
|
|
299
283
|
}
|
|
@@ -317,9 +301,6 @@ export function buildSnapshot(model) {
|
|
|
317
301
|
|| null;
|
|
318
302
|
const selectedEntryId = selectedEntry?.id || null;
|
|
319
303
|
const selectedFailure = toSelectedFailure(selectedEntry);
|
|
320
|
-
const filterResults = [...model.filterMatches.entries()]
|
|
321
|
-
.map(([id, match]) => ({ id, ...match }))
|
|
322
|
-
.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id));
|
|
323
304
|
|
|
324
305
|
return {
|
|
325
306
|
dataSource: model.dataSource,
|
|
@@ -336,12 +317,6 @@ export function buildSnapshot(model) {
|
|
|
336
317
|
finished: model.finished,
|
|
337
318
|
summaryData: model.summaryData,
|
|
338
319
|
regressionCatalog: model.regressionCatalog,
|
|
339
|
-
filter: {
|
|
340
|
-
active: model.filterActive,
|
|
341
|
-
query: model.filterQuery,
|
|
342
|
-
results: filterResults,
|
|
343
|
-
count: filterResults.length,
|
|
344
|
-
},
|
|
345
320
|
runArtifact: model.runArtifact,
|
|
346
321
|
};
|
|
347
322
|
}
|
|
@@ -445,22 +420,18 @@ function buildNestedServices(model) {
|
|
|
445
420
|
|
|
446
421
|
function buildVisibleEntries(model, services) {
|
|
447
422
|
const entries = [];
|
|
448
|
-
const filterActive = model.filterActive && model.filterQuery.trim().length > 0;
|
|
449
|
-
const includedIds = filterActive ? buildFilteredIdSet(model, services) : null;
|
|
450
423
|
|
|
451
424
|
for (const service of services) {
|
|
452
|
-
pushVisibleEntry(entries, model, service, 0
|
|
425
|
+
pushVisibleEntry(entries, model, service, 0);
|
|
453
426
|
if (service.skipped) continue;
|
|
454
427
|
for (const typeNode of service.types) {
|
|
455
|
-
if (!pushVisibleEntry(entries, model, typeNode, 1
|
|
456
|
-
|
|
457
|
-
if (!typeExpanded) continue;
|
|
428
|
+
if (!pushVisibleEntry(entries, model, typeNode, 1)) continue;
|
|
429
|
+
if (typeNode.collapsed) continue;
|
|
458
430
|
for (const suite of typeNode.suites) {
|
|
459
|
-
if (!pushVisibleEntry(entries, model, suite, 2
|
|
460
|
-
|
|
461
|
-
if (!suiteExpanded) continue;
|
|
431
|
+
if (!pushVisibleEntry(entries, model, suite, 2)) continue;
|
|
432
|
+
if (suite.collapsed) continue;
|
|
462
433
|
for (const file of suite.files) {
|
|
463
|
-
pushVisibleEntry(entries, model, file, 3
|
|
434
|
+
pushVisibleEntry(entries, model, file, 3);
|
|
464
435
|
}
|
|
465
436
|
}
|
|
466
437
|
}
|
|
@@ -469,62 +440,12 @@ function buildVisibleEntries(model, services) {
|
|
|
469
440
|
return entries;
|
|
470
441
|
}
|
|
471
442
|
|
|
472
|
-
function pushVisibleEntry(entries, model, entry, depth
|
|
473
|
-
|
|
474
|
-
const base = toPublicEntry(entry, depth);
|
|
475
|
-
const match = model.filterMatches.get(entry.id) || null;
|
|
476
|
-
entries.push({
|
|
477
|
-
...base,
|
|
478
|
-
match,
|
|
479
|
-
});
|
|
443
|
+
function pushVisibleEntry(entries, model, entry, depth) {
|
|
444
|
+
entries.push(toPublicEntry(entry, depth, model));
|
|
480
445
|
return true;
|
|
481
446
|
}
|
|
482
447
|
|
|
483
|
-
function
|
|
484
|
-
const included = new Set();
|
|
485
|
-
const childrenById = new Map();
|
|
486
|
-
const parentsById = new Map();
|
|
487
|
-
|
|
488
|
-
for (const service of services) {
|
|
489
|
-
childrenById.set(service.id, service.types.map((entry) => entry.id));
|
|
490
|
-
for (const typeNode of service.types) {
|
|
491
|
-
parentsById.set(typeNode.id, service.id);
|
|
492
|
-
childrenById.set(typeNode.id, typeNode.suites.map((entry) => entry.id));
|
|
493
|
-
for (const suite of typeNode.suites) {
|
|
494
|
-
parentsById.set(suite.id, typeNode.id);
|
|
495
|
-
childrenById.set(suite.id, suite.files.map((entry) => entry.id));
|
|
496
|
-
for (const file of suite.files) {
|
|
497
|
-
parentsById.set(file.id, suite.id);
|
|
498
|
-
childrenById.set(file.id, []);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
for (const entryId of model.filterMatches.keys()) {
|
|
505
|
-
included.add(entryId);
|
|
506
|
-
let current = parentsById.get(entryId) || null;
|
|
507
|
-
while (current) {
|
|
508
|
-
included.add(current);
|
|
509
|
-
current = parentsById.get(current) || null;
|
|
510
|
-
}
|
|
511
|
-
const entry = getEntryById(model, entryId);
|
|
512
|
-
if (entry && entry.kind !== "file") {
|
|
513
|
-
includeDescendants(entryId, childrenById, included);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
return included;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function includeDescendants(entryId, childrenById, included) {
|
|
521
|
-
for (const childId of childrenById.get(entryId) || []) {
|
|
522
|
-
included.add(childId);
|
|
523
|
-
includeDescendants(childId, childrenById, included);
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function toPublicEntry(entry, depth) {
|
|
448
|
+
function toPublicEntry(entry, depth, model = null) {
|
|
528
449
|
if (entry.kind === "service") {
|
|
529
450
|
return {
|
|
530
451
|
id: entry.id,
|
|
@@ -567,6 +488,7 @@ function toPublicEntry(entry, depth) {
|
|
|
567
488
|
framework: entry.framework,
|
|
568
489
|
};
|
|
569
490
|
}
|
|
491
|
+
const autoCollapsed = defaultAutoCollapsed(entry);
|
|
570
492
|
return {
|
|
571
493
|
id: entry.id,
|
|
572
494
|
kind: "file",
|
|
@@ -586,6 +508,8 @@ function toPublicEntry(entry, depth) {
|
|
|
586
508
|
diagnosis: entry.diagnosis,
|
|
587
509
|
skipReason: entry.skipReason,
|
|
588
510
|
artifacts: entry.artifacts,
|
|
511
|
+
checkDetails: entry.checkDetails,
|
|
512
|
+
collapsed: model ? isCollapsed(model, { id: entry.id, autoCollapsed }) : autoCollapsed,
|
|
589
513
|
};
|
|
590
514
|
}
|
|
591
515
|
|
|
@@ -625,7 +549,12 @@ function isCollapsed(model, entry) {
|
|
|
625
549
|
if (model.collapsedOverrides.has(entry.id)) {
|
|
626
550
|
return Boolean(model.collapsedOverrides.get(entry.id));
|
|
627
551
|
}
|
|
628
|
-
return Boolean(entry.autoCollapsed);
|
|
552
|
+
return Boolean(entry.autoCollapsed ?? defaultAutoCollapsed(entry));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function defaultAutoCollapsed(entry) {
|
|
556
|
+
if (entry?.kind !== "file") return false;
|
|
557
|
+
return entry.status === "passed" || entry.status === "skipped" || entry.status === "pending";
|
|
629
558
|
}
|
|
630
559
|
|
|
631
560
|
function summarizeFiles(files) {
|
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
setRegressionCatalog,
|
|
18
18
|
setTotalFileCount,
|
|
19
19
|
toggleCollapsed,
|
|
20
|
-
updateFilter,
|
|
21
20
|
} from "./model.mjs";
|
|
22
21
|
|
|
23
22
|
export function createRunState({ dataSource = "live", autoCollapsePassedTreeBranches = true } = {}) {
|
|
@@ -143,27 +142,6 @@ export function createRunState({ dataSource = "live", autoCollapsePassedTreeBran
|
|
|
143
142
|
notify();
|
|
144
143
|
},
|
|
145
144
|
|
|
146
|
-
activateFilter() {
|
|
147
|
-
model.filterActive = true;
|
|
148
|
-
notify();
|
|
149
|
-
},
|
|
150
|
-
|
|
151
|
-
updateFilterQuery(query) {
|
|
152
|
-
updateFilter(model, query);
|
|
153
|
-
const snapshot = buildSnapshot(model);
|
|
154
|
-
if (snapshot.filter.results.length > 0) {
|
|
155
|
-
model.selectedEntryId = snapshot.filter.results[0].id;
|
|
156
|
-
}
|
|
157
|
-
notify();
|
|
158
|
-
},
|
|
159
|
-
|
|
160
|
-
deactivateFilter() {
|
|
161
|
-
model.filterActive = false;
|
|
162
|
-
model.filterQuery = "";
|
|
163
|
-
model.filterMatches = new Map();
|
|
164
|
-
notify();
|
|
165
|
-
},
|
|
166
|
-
|
|
167
145
|
revealFile(serviceName, filePath) {
|
|
168
146
|
const entryId = findEntryIdForFile(model, serviceName, filePath);
|
|
169
147
|
if (!entryId) return false;
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
determineDefaultRuntimeFailure,
|
|
13
13
|
extractDefaultRuntimeFatalDetail,
|
|
14
14
|
} from "./default-runtime-errors.mjs";
|
|
15
|
-
import { collectFailureDetailsFromRuntimeArtifacts } from "./failure-details.mjs";
|
|
15
|
+
import { collectFailureDetailsFromRuntimeArtifacts, collectCheckDetailsFromRuntimeArtifacts } from "./failure-details.mjs";
|
|
16
16
|
import { RUNTIME_ARTIFACT_MARKER } from "../runtime-src/k6/artifacts.js";
|
|
17
17
|
import { readDatabaseUrl } from "./state-io.mjs";
|
|
18
18
|
import { buildTaskExecutionEnv } from "./template.mjs";
|
|
@@ -147,6 +147,7 @@ export async function runDefaultRuntimeTask(
|
|
|
147
147
|
: null,
|
|
148
148
|
]);
|
|
149
149
|
const failureDetails = collectFailureDetailsFromRuntimeArtifacts(rawRuntimeArtifacts);
|
|
150
|
+
const checkDetails = collectCheckDetailsFromRuntimeArtifacts(rawRuntimeArtifacts);
|
|
150
151
|
const fatalRuntimeDetail = extractDefaultRuntimeFatalDetail(result.stderr || "", getFirstLine);
|
|
151
152
|
if (fatalRuntimeDetail && !failureDetails.some((detail) => detail?.kind === "runtime-exception")) {
|
|
152
153
|
failureDetails.unshift(fatalRuntimeDetail);
|
|
@@ -165,6 +166,7 @@ export async function runDefaultRuntimeTask(
|
|
|
165
166
|
finishedAt,
|
|
166
167
|
artifacts: [...runtimeArtifacts, ...outputArtifacts],
|
|
167
168
|
failureDetails,
|
|
169
|
+
checkDetails,
|
|
168
170
|
};
|
|
169
171
|
}
|
|
170
172
|
|
|
@@ -90,6 +90,28 @@ export function collectFailureDetailsFromRuntimeArtifacts(artifacts) {
|
|
|
90
90
|
return mergeFailureDetails(details);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
export function collectCheckDetailsFromRuntimeArtifacts(artifacts) {
|
|
94
|
+
const checks = [];
|
|
95
|
+
|
|
96
|
+
for (const artifact of Array.isArray(artifacts) ? artifacts : []) {
|
|
97
|
+
if (artifact?.kind !== "testkit.checks") continue;
|
|
98
|
+
for (const check of Array.isArray(artifact?.data?.checks) ? artifact.data.checks : []) {
|
|
99
|
+
const name = normalizeNonEmptyString(check?.name) || "unnamed";
|
|
100
|
+
const passes = Math.max(0, Number(check?.passes) || 0);
|
|
101
|
+
const fails = Math.max(0, Number(check?.fails) || 0);
|
|
102
|
+
checks.push({
|
|
103
|
+
name,
|
|
104
|
+
path: normalizeStringArray(check?.path),
|
|
105
|
+
passes,
|
|
106
|
+
fails,
|
|
107
|
+
passed: passes > 0 && fails === 0,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return checks;
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
function normalizeStringArray(value) {
|
|
94
116
|
if (!Array.isArray(value)) return [];
|
|
95
117
|
return value
|
package/lib/runner/results.mjs
CHANGED
|
@@ -48,6 +48,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
|
|
|
48
48
|
status: "not_run",
|
|
49
49
|
artifacts: [],
|
|
50
50
|
failureDetails: [],
|
|
51
|
+
checkDetails: [],
|
|
51
52
|
},
|
|
52
53
|
];
|
|
53
54
|
}),
|
|
@@ -62,6 +63,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
|
|
|
62
63
|
status: "skipped",
|
|
63
64
|
artifacts: [],
|
|
64
65
|
failureDetails: [],
|
|
66
|
+
checkDetails: [],
|
|
65
67
|
},
|
|
66
68
|
]),
|
|
67
69
|
]),
|
|
@@ -125,6 +127,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
|
|
|
125
127
|
existingFileResult.status = status;
|
|
126
128
|
existingFileResult.artifacts = Array.isArray(outcome.artifacts) ? outcome.artifacts : [];
|
|
127
129
|
existingFileResult.failureDetails = mergeFailureDetails(outcome.failureDetails);
|
|
130
|
+
existingFileResult.checkDetails = normalizeCheckDetails(outcome.checkDetails);
|
|
128
131
|
} else {
|
|
129
132
|
suite.fileResultsByPath.set(normalizedPath, {
|
|
130
133
|
path: normalizedPath,
|
|
@@ -135,6 +138,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
|
|
|
135
138
|
status,
|
|
136
139
|
artifacts: Array.isArray(outcome.artifacts) ? outcome.artifacts : [],
|
|
137
140
|
failureDetails: mergeFailureDetails(outcome.failureDetails),
|
|
141
|
+
checkDetails: normalizeCheckDetails(outcome.checkDetails),
|
|
138
142
|
});
|
|
139
143
|
}
|
|
140
144
|
if (status === "failed" && !suite.failedFileSet.has(task.file)) {
|
|
@@ -258,6 +262,9 @@ function finalizeSuite(suite) {
|
|
|
258
262
|
...(Array.isArray(file.artifacts) && file.artifacts.length > 0
|
|
259
263
|
? { artifacts: file.artifacts }
|
|
260
264
|
: {}),
|
|
265
|
+
...(Array.isArray(file.checkDetails) && file.checkDetails.length > 0
|
|
266
|
+
? { checkDetails: file.checkDetails }
|
|
267
|
+
: {}),
|
|
261
268
|
}));
|
|
262
269
|
|
|
263
270
|
return {
|
|
@@ -289,3 +296,27 @@ function normalizeOutcomeStatus(outcome) {
|
|
|
289
296
|
if (outcome?.status === "skipped") return "skipped";
|
|
290
297
|
return outcome?.failed ? "failed" : "passed";
|
|
291
298
|
}
|
|
299
|
+
|
|
300
|
+
function normalizeCheckDetails(value) {
|
|
301
|
+
if (!Array.isArray(value)) return [];
|
|
302
|
+
return value.map((check) => {
|
|
303
|
+
const passes = normalizeCount(check?.passes);
|
|
304
|
+
const fails = normalizeCount(check?.fails);
|
|
305
|
+
return {
|
|
306
|
+
name: typeof check?.name === "string" && check.name.trim().length > 0 ? check.name.trim() : "unnamed",
|
|
307
|
+
path: Array.isArray(check?.path)
|
|
308
|
+
? check.path
|
|
309
|
+
.map((entry) => String(entry || "").trim())
|
|
310
|
+
.filter(Boolean)
|
|
311
|
+
: [],
|
|
312
|
+
passes,
|
|
313
|
+
fails,
|
|
314
|
+
passed: passes > 0 && fails === 0,
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function normalizeCount(value) {
|
|
320
|
+
const count = Number(value);
|
|
321
|
+
return Number.isFinite(count) && count > 0 ? count : 0;
|
|
322
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.113",
|
|
4
4
|
"description": "Browser bridge helpers for testkit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@elench/testkit-protocol": "0.1.
|
|
25
|
+
"@elench/testkit-protocol": "0.1.113"
|
|
26
26
|
},
|
|
27
27
|
"private": false
|
|
28
28
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.113",
|
|
4
4
|
"description": "Assistant-first CLI for running, inspecting, and debugging local testkit suites",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -90,10 +90,10 @@
|
|
|
90
90
|
},
|
|
91
91
|
"dependencies": {
|
|
92
92
|
"@babel/code-frame": "^7.29.0",
|
|
93
|
-
"@elench/next-analysis": "0.1.
|
|
94
|
-
"@elench/testkit-bridge": "0.1.
|
|
95
|
-
"@elench/testkit-protocol": "0.1.
|
|
96
|
-
"@elench/ts-analysis": "0.1.
|
|
93
|
+
"@elench/next-analysis": "0.1.113",
|
|
94
|
+
"@elench/testkit-bridge": "0.1.113",
|
|
95
|
+
"@elench/testkit-protocol": "0.1.113",
|
|
96
|
+
"@elench/ts-analysis": "0.1.113",
|
|
97
97
|
"@oclif/core": "^4.10.6",
|
|
98
98
|
"@playwright/test": "^1.52.0",
|
|
99
99
|
"esbuild": "^0.25.11",
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import React, { createElement } from "react";
|
|
2
|
-
import { Text } from "ink";
|
|
3
|
-
import { bold, dim } from "../../terminal/colors.mjs";
|
|
4
|
-
|
|
5
|
-
export function FilterBar({ filter } = {}) {
|
|
6
|
-
if (!filter?.active) return null;
|
|
7
|
-
return createElement(
|
|
8
|
-
Text,
|
|
9
|
-
null,
|
|
10
|
-
`${bold("/")}${filter.query}${dim(` ${filter.count} ${filter.count === 1 ? "match" : "matches"}`)}`
|
|
11
|
-
);
|
|
12
|
-
}
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
const FIELD_WEIGHTS = {
|
|
2
|
-
path: 140,
|
|
3
|
-
label: 120,
|
|
4
|
-
suite: 90,
|
|
5
|
-
service: 70,
|
|
6
|
-
type: 60,
|
|
7
|
-
status: 30,
|
|
8
|
-
error: 20,
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export function fuzzyMatch(query, candidate) {
|
|
12
|
-
const normalizedQuery = normalizeValue(query);
|
|
13
|
-
const normalizedCandidate = normalizeValue(candidate);
|
|
14
|
-
if (!normalizedQuery) {
|
|
15
|
-
return { matched: true, score: 0, positions: [] };
|
|
16
|
-
}
|
|
17
|
-
if (!normalizedCandidate) {
|
|
18
|
-
return { matched: false, score: Number.NEGATIVE_INFINITY, positions: [] };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const positions = [];
|
|
22
|
-
let queryIndex = 0;
|
|
23
|
-
let lastPosition = -1;
|
|
24
|
-
let score = 0;
|
|
25
|
-
|
|
26
|
-
for (let candidateIndex = 0; candidateIndex < normalizedCandidate.length; candidateIndex += 1) {
|
|
27
|
-
if (normalizedCandidate[candidateIndex] !== normalizedQuery[queryIndex]) continue;
|
|
28
|
-
positions.push(candidateIndex);
|
|
29
|
-
score += 1;
|
|
30
|
-
if (candidateIndex === 0) score += 3;
|
|
31
|
-
if (isBoundary(candidate, candidateIndex)) score += 10;
|
|
32
|
-
if (lastPosition >= 0) {
|
|
33
|
-
const gap = candidateIndex - lastPosition - 1;
|
|
34
|
-
score -= gap;
|
|
35
|
-
if (gap === 0) score += 5;
|
|
36
|
-
}
|
|
37
|
-
lastPosition = candidateIndex;
|
|
38
|
-
queryIndex += 1;
|
|
39
|
-
if (queryIndex >= normalizedQuery.length) {
|
|
40
|
-
return { matched: true, score, positions };
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return { matched: false, score: Number.NEGATIVE_INFINITY, positions: [] };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function matchRunTreeEntry(query, entry) {
|
|
48
|
-
const fields = buildEntryMatchFields(entry);
|
|
49
|
-
let best = null;
|
|
50
|
-
|
|
51
|
-
for (const field of fields) {
|
|
52
|
-
if (!field.value) continue;
|
|
53
|
-
const result = fuzzyMatch(query, field.value);
|
|
54
|
-
if (!result.matched) continue;
|
|
55
|
-
const weightedScore = result.score + (FIELD_WEIGHTS[field.name] || 0);
|
|
56
|
-
if (!best || weightedScore > best.score) {
|
|
57
|
-
best = {
|
|
58
|
-
matched: true,
|
|
59
|
-
score: weightedScore,
|
|
60
|
-
field: field.name,
|
|
61
|
-
positions: result.positions,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (!best) {
|
|
67
|
-
return { matched: false, score: Number.NEGATIVE_INFINITY, field: null, positions: [] };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return best;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function applyHighlight(text, positions, highlightFn) {
|
|
74
|
-
const source = String(text || "");
|
|
75
|
-
if (!Array.isArray(positions) || positions.length === 0) return source;
|
|
76
|
-
const positionSet = new Set(positions);
|
|
77
|
-
let output = "";
|
|
78
|
-
for (let index = 0; index < source.length; index += 1) {
|
|
79
|
-
output += positionSet.has(index) ? highlightFn(source[index]) : source[index];
|
|
80
|
-
}
|
|
81
|
-
return output;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function buildEntryMatchFields(entry) {
|
|
85
|
-
return [
|
|
86
|
-
{ name: "label", value: entry.label || "" },
|
|
87
|
-
{ name: "path", value: entry.filePath || "" },
|
|
88
|
-
{ name: "suite", value: entry.suiteName || "" },
|
|
89
|
-
{ name: "service", value: entry.serviceName || "" },
|
|
90
|
-
{ name: "type", value: entry.type || "" },
|
|
91
|
-
{ name: "status", value: entry.status || "" },
|
|
92
|
-
{ name: "error", value: entry.error || "" },
|
|
93
|
-
];
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function normalizeValue(value) {
|
|
97
|
-
return String(value || "").toLowerCase();
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function isBoundary(source, index) {
|
|
101
|
-
if (index <= 0) return true;
|
|
102
|
-
const current = source[index];
|
|
103
|
-
const previous = source[index - 1];
|
|
104
|
-
if (/[/_.\-\s]/.test(previous)) return true;
|
|
105
|
-
return previous.toLowerCase() === previous && current.toUpperCase() === current && current.toLowerCase() !== current;
|
|
106
|
-
}
|