@vitest-agent/sdk 1.0.0
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/DataReader-2kJ9I9Hh.d.ts +1809 -0
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dispatch.d.ts +48 -0
- package/dispatch.js +5 -0
- package/errors/AgentErrors.js +65 -0
- package/errors/DataStoreError.js +44 -0
- package/errors/DiscoveryError.js +21 -0
- package/errors/PathResolutionError.js +27 -0
- package/errors/ProjectIdentityError.js +29 -0
- package/errors/RunContextError.js +35 -0
- package/errors/TddErrors.js +66 -0
- package/exit-code-for-tag.js +18 -0
- package/formatters/ci-annotations.js +61 -0
- package/formatters/gfm.js +20 -0
- package/formatters/json.js +18 -0
- package/formatters/markdown.js +39 -0
- package/formatters/silent.js +9 -0
- package/formatters/terminal.js +56 -0
- package/index.d.ts +3334 -0
- package/index.js +120 -0
- package/internal-inject-env.js +42 -0
- package/layers/ConfigLive.js +47 -0
- package/layers/DataReaderLive.js +1666 -0
- package/layers/DataStoreLive.js +1185 -0
- package/layers/DetailResolverLive.js +9 -0
- package/layers/DiscoveryRegistryLive.js +70 -0
- package/layers/EnvironmentDetectorLive.js +21 -0
- package/layers/EnvironmentDetectorTest.js +13 -0
- package/layers/ExecutorResolverLive.js +9 -0
- package/layers/FormatSelectorLive.js +20 -0
- package/layers/HistoryTrackerLive.js +47 -0
- package/layers/HistoryTrackerTest.js +25 -0
- package/layers/LoggerLive.js +64 -0
- package/layers/OutputPipelineLive.js +13 -0
- package/layers/OutputRendererLive.js +28 -0
- package/layers/PathResolutionLive.js +36 -0
- package/layers/PerClientSessionMapLive.js +145 -0
- package/layers/ProjectDiscoveryLive.js +70 -0
- package/layers/ProjectDiscoveryTest.js +12 -0
- package/layers/ProjectIdentityLive.js +92 -0
- package/layers/RunContextLive.js +81 -0
- package/lib/format-triage.js +103 -0
- package/lib/format-wrapup.js +67 -0
- package/migrations/0001_initial.js +849 -0
- package/migrations/registry_0001_initial.js +39 -0
- package/migrations/session_map_0001_initial.js +61 -0
- package/package.json +64 -0
- package/schemas/Agent.js +42 -0
- package/schemas/AgentReport.js +71 -0
- package/schemas/Baselines.js +16 -0
- package/schemas/CacheManifest.js +27 -0
- package/schemas/ChannelEvent.js +138 -0
- package/schemas/Common.js +105 -0
- package/schemas/Config.js +28 -0
- package/schemas/Coverage.js +51 -0
- package/schemas/CoverageLevel.js +117 -0
- package/schemas/CoverageTargets.js +39 -0
- package/schemas/History.js +31 -0
- package/schemas/Identity.js +64 -0
- package/schemas/Options.js +57 -0
- package/schemas/RenderState.js +161 -0
- package/schemas/RunEvent.js +163 -0
- package/schemas/Tdd.js +44 -0
- package/schemas/Thresholds.js +30 -0
- package/schemas/Transport.js +16 -0
- package/schemas/Trends.js +23 -0
- package/schemas/turns/FileEditPayload.js +15 -0
- package/schemas/turns/HookFirePayload.js +13 -0
- package/schemas/turns/HypothesisPayload.js +13 -0
- package/schemas/turns/NotePayload.js +13 -0
- package/schemas/turns/ToolCallPayload.js +13 -0
- package/schemas/turns/ToolResultPayload.js +15 -0
- package/schemas/turns/UserPromptPayload.js +12 -0
- package/schemas/turns/index.js +15 -0
- package/services/Config.js +15 -0
- package/services/DataReader.js +8 -0
- package/services/DataStore.js +8 -0
- package/services/DetailResolver.js +8 -0
- package/services/DiscoveryRegistry.js +8 -0
- package/services/EnvironmentDetector.js +8 -0
- package/services/ExecutorResolver.js +8 -0
- package/services/FormatSelector.js +8 -0
- package/services/HistoryTracker.js +8 -0
- package/services/OutputRenderer.js +8 -0
- package/services/PerClientSessionMap.js +22 -0
- package/services/ProjectDiscovery.js +8 -0
- package/services/ProjectIdentity.js +63 -0
- package/services/RunContext.js +53 -0
- package/services/idempotency.js +57 -0
- package/sidecar-dispatch.js +84 -0
- package/sql/assemblers.js +59 -0
- package/testing/layers.js +21 -0
- package/testing.d.ts +25 -0
- package/testing.js +243 -0
- package/tsdoc-metadata.json +11 -0
- package/utils/ansi.js +49 -0
- package/utils/build-report.js +130 -0
- package/utils/canonicalize-git-url.js +66 -0
- package/utils/classify-test.js +22 -0
- package/utils/compress-lines.js +45 -0
- package/utils/compute-trend.js +88 -0
- package/utils/detect-pm.js +58 -0
- package/utils/detect-timeout.js +17 -0
- package/utils/ensure-migrated.js +52 -0
- package/utils/failure-signature.js +33 -0
- package/utils/format-console.js +219 -0
- package/utils/format-fatal-error.js +35 -0
- package/utils/format-gfm.js +161 -0
- package/utils/format-terminal.js +480 -0
- package/utils/function-boundary.js +75 -0
- package/utils/hyperlink.js +11 -0
- package/utils/match-vitest-command.js +88 -0
- package/utils/normalize-workspace-key.js +34 -0
- package/utils/probe-host-metadata.js +99 -0
- package/utils/resolve-data-path.js +66 -0
- package/utils/resolve-project-key-from-cwd.js +52 -0
- package/utils/resolve-workspace-key.js +33 -0
- package/utils/safe-filename.js +34 -0
- package/utils/validate-coverage-targets-shape.js +84 -0
- package/utils/validate-phase-transition.js +113 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { ansi } from "./ansi.js";
|
|
2
|
+
import { relativePath } from "./format-console.js";
|
|
3
|
+
import { compressLines } from "./compress-lines.js";
|
|
4
|
+
|
|
5
|
+
//#region src/utils/format-terminal.ts
|
|
6
|
+
/**
|
|
7
|
+
* Compress a comma-separated line list into ranges where consecutive
|
|
8
|
+
* numbers run together. Defensive: callers may pass already-compressed
|
|
9
|
+
* strings (e.g. "29-32, 34-38") or uncompressed (e.g. "29,30,31,32") —
|
|
10
|
+
* both are handled. If parsing fails, the original string is returned
|
|
11
|
+
* unchanged.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
const compressUncov = (uncoveredLines) => {
|
|
16
|
+
if (uncoveredLines.length === 0) return "";
|
|
17
|
+
if (uncoveredLines.includes("-")) return uncoveredLines;
|
|
18
|
+
const numbers = [];
|
|
19
|
+
for (const piece of uncoveredLines.split(",")) {
|
|
20
|
+
const n = Number.parseInt(piece.trim(), 10);
|
|
21
|
+
if (Number.isNaN(n)) return uncoveredLines;
|
|
22
|
+
numbers.push(n);
|
|
23
|
+
}
|
|
24
|
+
return compressLines(numbers);
|
|
25
|
+
};
|
|
26
|
+
const METRICS = [
|
|
27
|
+
{
|
|
28
|
+
key: "lines",
|
|
29
|
+
label: "lines"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
key: "branches",
|
|
33
|
+
label: "branches"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
key: "functions",
|
|
37
|
+
label: "funcs"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: "statements",
|
|
41
|
+
label: "stmts"
|
|
42
|
+
}
|
|
43
|
+
];
|
|
44
|
+
/**
|
|
45
|
+
* Format a duration in milliseconds as a short human string.
|
|
46
|
+
*
|
|
47
|
+
* Sub-second durations stay in ms (`"328ms"`); 1s and longer switch to
|
|
48
|
+
* seconds with one decimal (`"5.6s"`). Avoids the noise of "5577ms" in
|
|
49
|
+
* summary rows where second-level precision is plenty.
|
|
50
|
+
*
|
|
51
|
+
* @internal
|
|
52
|
+
*/
|
|
53
|
+
const formatDuration = (ms) => {
|
|
54
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
55
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Decide whether the project label should appear in the row.
|
|
59
|
+
*
|
|
60
|
+
* Single-project setups with no explicit `project.name` (Vitest's
|
|
61
|
+
* default) have `project` set to `"default"`. In that case there's
|
|
62
|
+
* nothing to disambiguate; suppress the label.
|
|
63
|
+
*
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
const showProjectLabel = (reports) => {
|
|
67
|
+
if (reports.length > 1) return true;
|
|
68
|
+
const single = reports[0];
|
|
69
|
+
if (!single?.project) return false;
|
|
70
|
+
if (single.project === "default") return false;
|
|
71
|
+
return true;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Right-pad a column value to a fixed width with spaces. Plain ASCII
|
|
75
|
+
* width — does not account for ANSI escapes (caller is responsible).
|
|
76
|
+
*
|
|
77
|
+
* @internal
|
|
78
|
+
*/
|
|
79
|
+
const pad = (s, width) => s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
80
|
+
/**
|
|
81
|
+
* Build an inline tag-count summary suffix for a project row.
|
|
82
|
+
* Only shown when two or more distinct tag kinds are present.
|
|
83
|
+
*
|
|
84
|
+
* @internal
|
|
85
|
+
*/
|
|
86
|
+
const buildTagSummary = (tagCounts) => {
|
|
87
|
+
if (!tagCounts) return "";
|
|
88
|
+
const keys = Object.keys(tagCounts).sort();
|
|
89
|
+
if (keys.length < 2) return "";
|
|
90
|
+
return " " + keys.map((k) => {
|
|
91
|
+
const c = tagCounts[k];
|
|
92
|
+
return `${k}:${(c?.passed ?? 0) + (c?.failed ?? 0) + (c?.skipped ?? 0)}`;
|
|
93
|
+
}).join(" ");
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Render indented per-tag pass/fail breakdown rows for a project that has failures.
|
|
97
|
+
* The `s` (skipped) column only appears when skipped \> 0 for that tag.
|
|
98
|
+
*
|
|
99
|
+
* @internal
|
|
100
|
+
*/
|
|
101
|
+
const renderTagFailureBreakdown = (tagCounts) => {
|
|
102
|
+
const lines = [];
|
|
103
|
+
const keys = Object.keys(tagCounts).sort();
|
|
104
|
+
if (keys.length === 0) return lines;
|
|
105
|
+
const tagWidth = Math.max(...keys.map((k) => k.length));
|
|
106
|
+
for (const k of keys) {
|
|
107
|
+
const c = tagCounts[k];
|
|
108
|
+
const p = c?.passed ?? 0;
|
|
109
|
+
const f = c?.failed ?? 0;
|
|
110
|
+
const s = c?.skipped ?? 0;
|
|
111
|
+
const skipPart = s > 0 ? ` ${s}s` : "";
|
|
112
|
+
lines.push(` ${k.padEnd(tagWidth)} ${p}p ${f}f${skipPart}`);
|
|
113
|
+
}
|
|
114
|
+
return lines;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Project row: `✓ name 103 passed (2.1s)` or
|
|
118
|
+
* `✗ name 102 passed, 1 failed (2.1s)`.
|
|
119
|
+
*
|
|
120
|
+
* @internal
|
|
121
|
+
*/
|
|
122
|
+
const renderProjectRow = (report, ao, nameWidth) => {
|
|
123
|
+
const failed = report.summary.failed;
|
|
124
|
+
const passed = report.summary.passed;
|
|
125
|
+
const skipped = report.summary.skipped;
|
|
126
|
+
const duration = formatDuration(report.summary.duration);
|
|
127
|
+
const name = report.project ?? "default";
|
|
128
|
+
const tick = failed === 0 ? ansi("✓", "green", ao) : ansi("✗", "red", ao);
|
|
129
|
+
const counts = [];
|
|
130
|
+
if (failed > 0) counts.push(`${failed} failed`);
|
|
131
|
+
counts.push(`${passed} passed`);
|
|
132
|
+
if (skipped > 0) counts.push(`${skipped} skipped`);
|
|
133
|
+
return `${` ${tick} ${pad(name, nameWidth)} ${counts.join(", ")} (${duration})`}${buildTagSummary(report.tagCounts)}`;
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Format the global target spec compactly.
|
|
137
|
+
*
|
|
138
|
+
* If every metric has the same target (the common case), collapse to
|
|
139
|
+
* `≥ 80% lines, branches, funcs, stmts`. If targets differ, list per
|
|
140
|
+
* metric: `lines ≥ 80%, branches ≥ 75%, funcs ≥ 70%, stmts ≥ 80%`.
|
|
141
|
+
*
|
|
142
|
+
* @internal
|
|
143
|
+
*/
|
|
144
|
+
const formatTargetSpec = (targets) => {
|
|
145
|
+
const set = /* @__PURE__ */ new Set();
|
|
146
|
+
const present = [];
|
|
147
|
+
for (const m of METRICS) {
|
|
148
|
+
const v = targets[m.key];
|
|
149
|
+
if (v == null) continue;
|
|
150
|
+
set.add(v);
|
|
151
|
+
present.push({
|
|
152
|
+
label: m.label,
|
|
153
|
+
v
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (present.length === 0) return "";
|
|
157
|
+
if (set.size === 1) {
|
|
158
|
+
const v = [...set][0];
|
|
159
|
+
return `${present.map((p) => p.label).join(", ")} ≥ ${v}%`;
|
|
160
|
+
}
|
|
161
|
+
return present.map((p) => `${p.label} ≥ ${p.v}%`).join(", ");
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Header labels for the v8-style coverage table. The order is fixed
|
|
165
|
+
* and matches the column order rendered by {@link renderBelowTargetTable}.
|
|
166
|
+
*
|
|
167
|
+
* @internal
|
|
168
|
+
*/
|
|
169
|
+
const TABLE_HEADERS = {
|
|
170
|
+
file: "File",
|
|
171
|
+
stmts: "% Stmts",
|
|
172
|
+
branches: "% Branch",
|
|
173
|
+
funcs: "% Funcs",
|
|
174
|
+
lines: "% Lines",
|
|
175
|
+
uncov: "Uncovered Line #s"
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* Pad a cell with one space on each side of its content. Numeric
|
|
179
|
+
* cells are right-aligned, string cells are left-aligned. The width
|
|
180
|
+
* argument is the inner content width (without the padding spaces).
|
|
181
|
+
*
|
|
182
|
+
* @internal
|
|
183
|
+
*/
|
|
184
|
+
const cell = (s, width, align) => {
|
|
185
|
+
return ` ${align === "right" ? s.padStart(width) : s.padEnd(width)} `;
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Render a separator row matching the column geometry of the header.
|
|
189
|
+
* Width per column = inner width + 2 (the padding spaces) so the row
|
|
190
|
+
* lines up with the headers and data rows when joined by `|`.
|
|
191
|
+
*
|
|
192
|
+
* @internal
|
|
193
|
+
*/
|
|
194
|
+
const sepRow = (widths) => widths.map((w) => "-".repeat(w + 2)).join("|");
|
|
195
|
+
/**
|
|
196
|
+
* v8-style coverage table for files below aspirational targets.
|
|
197
|
+
*
|
|
198
|
+
* Columns: File, % Stmts, % Branch, % Funcs, % Lines, Uncovered
|
|
199
|
+
* Line #s. Numeric columns are right-aligned and show the file's
|
|
200
|
+
* actual coverage value (no `%` suffix — the header carries it).
|
|
201
|
+
* Showing every metric per row, not just the below-target ones,
|
|
202
|
+
* matches v8's reporter and gives an LLM agent a complete picture
|
|
203
|
+
* of where the file stands relative to all four metrics.
|
|
204
|
+
*
|
|
205
|
+
* Returns header rows + data rows + a trailing separator. The caller
|
|
206
|
+
* is responsible for the surrounding caption and any truncation
|
|
207
|
+
* footer.
|
|
208
|
+
*
|
|
209
|
+
* @internal
|
|
210
|
+
*/
|
|
211
|
+
const renderBelowTargetTable = (files) => {
|
|
212
|
+
if (files.length === 0) return [];
|
|
213
|
+
const rows = files.map((f) => ({
|
|
214
|
+
file: relativePath(f.file),
|
|
215
|
+
stmts: String(Math.round(f.summary.statements)),
|
|
216
|
+
branches: String(Math.round(f.summary.branches)),
|
|
217
|
+
funcs: String(Math.round(f.summary.functions)),
|
|
218
|
+
lines: String(Math.round(f.summary.lines)),
|
|
219
|
+
uncov: compressUncov(f.uncoveredLines)
|
|
220
|
+
}));
|
|
221
|
+
const widths = [
|
|
222
|
+
Math.max(TABLE_HEADERS.file.length, ...rows.map((r) => r.file.length)),
|
|
223
|
+
Math.max(TABLE_HEADERS.stmts.length, ...rows.map((r) => r.stmts.length)),
|
|
224
|
+
Math.max(TABLE_HEADERS.branches.length, ...rows.map((r) => r.branches.length)),
|
|
225
|
+
Math.max(TABLE_HEADERS.funcs.length, ...rows.map((r) => r.funcs.length)),
|
|
226
|
+
Math.max(TABLE_HEADERS.lines.length, ...rows.map((r) => r.lines.length)),
|
|
227
|
+
Math.max(TABLE_HEADERS.uncov.length, ...rows.map((r) => r.uncov.length))
|
|
228
|
+
];
|
|
229
|
+
const out = [];
|
|
230
|
+
out.push(sepRow(widths));
|
|
231
|
+
out.push([
|
|
232
|
+
cell(TABLE_HEADERS.file, widths[0], "left"),
|
|
233
|
+
cell(TABLE_HEADERS.stmts, widths[1], "right"),
|
|
234
|
+
cell(TABLE_HEADERS.branches, widths[2], "right"),
|
|
235
|
+
cell(TABLE_HEADERS.funcs, widths[3], "right"),
|
|
236
|
+
cell(TABLE_HEADERS.lines, widths[4], "right"),
|
|
237
|
+
cell(TABLE_HEADERS.uncov, widths[5], "left")
|
|
238
|
+
].join("|"));
|
|
239
|
+
out.push(sepRow(widths));
|
|
240
|
+
for (const r of rows) out.push([
|
|
241
|
+
cell(r.file, widths[0], "left"),
|
|
242
|
+
cell(r.stmts, widths[1], "right"),
|
|
243
|
+
cell(r.branches, widths[2], "right"),
|
|
244
|
+
cell(r.funcs, widths[3], "right"),
|
|
245
|
+
cell(r.lines, widths[4], "right"),
|
|
246
|
+
cell(r.uncov, widths[5], "left")
|
|
247
|
+
].join("|"));
|
|
248
|
+
out.push(sepRow(widths));
|
|
249
|
+
return out;
|
|
250
|
+
};
|
|
251
|
+
/**
|
|
252
|
+
* Aggregated coverage state across reports.
|
|
253
|
+
*
|
|
254
|
+
* @internal
|
|
255
|
+
*/
|
|
256
|
+
const aggregateCoverage = (reports) => {
|
|
257
|
+
let hasCoverage = false;
|
|
258
|
+
let thresholdsGlobal;
|
|
259
|
+
let targetsGlobal;
|
|
260
|
+
const belowTargetByFile = /* @__PURE__ */ new Map();
|
|
261
|
+
const belowThresholdByFile = /* @__PURE__ */ new Map();
|
|
262
|
+
for (const r of reports) {
|
|
263
|
+
const cov = r.coverage;
|
|
264
|
+
if (!cov) continue;
|
|
265
|
+
hasCoverage = true;
|
|
266
|
+
thresholdsGlobal ??= cov.thresholds.global;
|
|
267
|
+
targetsGlobal ??= cov.targets?.global;
|
|
268
|
+
if (cov.lowCoverage) {
|
|
269
|
+
for (const f of cov.lowCoverage) if (!belowThresholdByFile.has(f.file)) belowThresholdByFile.set(f.file, f);
|
|
270
|
+
}
|
|
271
|
+
if (cov.belowTarget) {
|
|
272
|
+
for (const f of cov.belowTarget) if (!belowTargetByFile.has(f.file)) belowTargetByFile.set(f.file, f);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const belowTargetFiles = [...belowTargetByFile.values()];
|
|
276
|
+
const belowThresholdCount = belowThresholdByFile.size;
|
|
277
|
+
return {
|
|
278
|
+
hasCoverage,
|
|
279
|
+
thresholdsMet: belowThresholdCount === 0,
|
|
280
|
+
belowThresholdCount,
|
|
281
|
+
belowTargetFiles,
|
|
282
|
+
...thresholdsGlobal !== void 0 ? { thresholdsGlobal } : {},
|
|
283
|
+
...targetsGlobal !== void 0 ? { targetsGlobal } : {}
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
/**
|
|
287
|
+
* Coverage section: one or two summary lines plus per-file rows.
|
|
288
|
+
*
|
|
289
|
+
* @internal
|
|
290
|
+
*/
|
|
291
|
+
const renderCoverageSection = (reports, options, ao) => {
|
|
292
|
+
const agg = aggregateCoverage(reports);
|
|
293
|
+
if (!agg.hasCoverage) return [];
|
|
294
|
+
const lines = [];
|
|
295
|
+
const thresholdSpec = agg.thresholdsGlobal ? formatTargetSpec(agg.thresholdsGlobal) : "";
|
|
296
|
+
const targetSpec = agg.targetsGlobal ? formatTargetSpec(agg.targetsGlobal) : "";
|
|
297
|
+
if (!agg.thresholdsMet) {
|
|
298
|
+
const cross = ansi("✗", "red", ao);
|
|
299
|
+
const fileWord = agg.belowThresholdCount === 1 ? "file" : "files";
|
|
300
|
+
lines.push(`Coverage: ${cross} ${agg.belowThresholdCount} ${fileWord} below minimum thresholds${thresholdSpec ? ` (${thresholdSpec})` : ""}`);
|
|
301
|
+
} else if (agg.belowTargetFiles.length > 0) {
|
|
302
|
+
const tick = ansi("✓", "green", ao);
|
|
303
|
+
const warn = ansi("⚠", "yellow", ao);
|
|
304
|
+
const fileWord = agg.belowTargetFiles.length === 1 ? "file" : "files";
|
|
305
|
+
lines.push(`Coverage: ${tick} minimum thresholds met${thresholdSpec ? ` (${thresholdSpec})` : ""}`);
|
|
306
|
+
lines.push(`Coverage: ${warn} ${agg.belowTargetFiles.length} ${fileWord} below aspirational targets${targetSpec ? ` (${targetSpec})` : ""}`);
|
|
307
|
+
} else {
|
|
308
|
+
const tick = ansi("✓", "green", ao);
|
|
309
|
+
if (agg.targetsGlobal) lines.push(`Coverage: ${tick} minimum thresholds + aspirational targets met${targetSpec ? ` (${targetSpec})` : ""}`);
|
|
310
|
+
else lines.push(`Coverage: ${tick} minimum thresholds met${thresholdSpec ? ` (${thresholdSpec})` : ""}`);
|
|
311
|
+
}
|
|
312
|
+
if (options.trendSummary) {
|
|
313
|
+
const { direction, runCount } = options.trendSummary;
|
|
314
|
+
lines.push(`Trend: ${direction} (${runCount} runs)`);
|
|
315
|
+
}
|
|
316
|
+
if (agg.belowTargetFiles.length > 0 && agg.targetsGlobal) {
|
|
317
|
+
lines.push("");
|
|
318
|
+
lines.push("Files below aspirational target:");
|
|
319
|
+
const limit = options.coverageConsoleLimit;
|
|
320
|
+
const shown = agg.belowTargetFiles.slice(0, limit);
|
|
321
|
+
lines.push(...renderBelowTargetTable(shown));
|
|
322
|
+
if (agg.belowTargetFiles.length > limit) lines.push(`… ${agg.belowTargetFiles.length - limit} more (use the test_coverage MCP tool for the full list)`);
|
|
323
|
+
}
|
|
324
|
+
return lines;
|
|
325
|
+
};
|
|
326
|
+
/**
|
|
327
|
+
* Failures block: grouped by project, one entry per failed test with
|
|
328
|
+
* inline error message and stack hint.
|
|
329
|
+
*
|
|
330
|
+
* @internal
|
|
331
|
+
*/
|
|
332
|
+
const renderFailuresSection = (reports, ao) => {
|
|
333
|
+
const lines = [];
|
|
334
|
+
const reportsWithFailures = reports.filter((r) => r.summary.failed > 0 || r.unhandledErrors.length > 0);
|
|
335
|
+
if (reportsWithFailures.length === 0) return lines;
|
|
336
|
+
lines.push("Failures:");
|
|
337
|
+
for (const report of reportsWithFailures) {
|
|
338
|
+
const projectName = report.project ?? "default";
|
|
339
|
+
lines.push(` ${projectName}`);
|
|
340
|
+
for (const mod of report.failed) {
|
|
341
|
+
const file = relativePath(mod.file);
|
|
342
|
+
const failedTests = mod.tests.filter((t) => t.state === "failed");
|
|
343
|
+
for (const test of failedTests) lines.push(...renderFailedTest(file, test, ao));
|
|
344
|
+
if (mod.errors && mod.errors.length > 0) for (const err of mod.errors) {
|
|
345
|
+
lines.push(` ${ansi("✗", "red", ao)} ${file} (module-level error)`);
|
|
346
|
+
lines.push(` ${ansi(err.message, "red", ao)}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
for (const err of report.unhandledErrors) {
|
|
350
|
+
lines.push(` ${ansi("✗", "red", ao)} unhandled error`);
|
|
351
|
+
lines.push(` ${ansi(err.message, "red", ao)}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return lines;
|
|
355
|
+
};
|
|
356
|
+
const renderFailedTest = (file, test, ao) => {
|
|
357
|
+
const cross = ansi("✗", "red", ao);
|
|
358
|
+
const cls = test.classification ? ansi(` [${test.classification}]`, "dim", ao) : "";
|
|
359
|
+
const lines = [` ${cross} ${file} > ${test.fullName}${cls}`];
|
|
360
|
+
if (test.errors && test.errors.length > 0) for (const err of test.errors) lines.push(` ${ansi(err.message, "dim", ao)}`);
|
|
361
|
+
return lines;
|
|
362
|
+
};
|
|
363
|
+
/**
|
|
364
|
+
* Total line: aggregated pass/fail/skip counts + max-project duration.
|
|
365
|
+
*
|
|
366
|
+
* @internal
|
|
367
|
+
*/
|
|
368
|
+
const renderTotal = (reports, ao) => {
|
|
369
|
+
let passed = 0;
|
|
370
|
+
let failed = 0;
|
|
371
|
+
let skipped = 0;
|
|
372
|
+
let maxDuration = 0;
|
|
373
|
+
for (const r of reports) {
|
|
374
|
+
passed += r.summary.passed;
|
|
375
|
+
failed += r.summary.failed;
|
|
376
|
+
skipped += r.summary.skipped;
|
|
377
|
+
if (r.summary.duration > maxDuration) maxDuration = r.summary.duration;
|
|
378
|
+
}
|
|
379
|
+
const parts = [];
|
|
380
|
+
if (failed > 0) parts.push(ansi(`${failed} failed`, "red", ao));
|
|
381
|
+
parts.push(`${passed} passed`);
|
|
382
|
+
if (skipped > 0) parts.push(`${skipped} skipped`);
|
|
383
|
+
return `Total: ${parts.join(", ")} (${formatDuration(maxDuration)})`;
|
|
384
|
+
};
|
|
385
|
+
/**
|
|
386
|
+
* Re-run hints when failures exist.
|
|
387
|
+
*
|
|
388
|
+
* @internal
|
|
389
|
+
*/
|
|
390
|
+
const renderNextSteps = (reports, options) => {
|
|
391
|
+
const lines = [];
|
|
392
|
+
const allFailedFiles = /* @__PURE__ */ new Set();
|
|
393
|
+
for (const r of reports) for (const f of r.failedFiles) allFailedFiles.add(relativePath(f));
|
|
394
|
+
if (allFailedFiles.size === 0) return lines;
|
|
395
|
+
lines.push("Next steps:");
|
|
396
|
+
for (const file of allFailedFiles) lines.push(` Re-run: pnpm vitest run ${file}`);
|
|
397
|
+
if (options.mcp) {
|
|
398
|
+
lines.push(" Use the test_history MCP tool to check failure trends");
|
|
399
|
+
lines.push(" Use the test_errors MCP tool to search errors by type");
|
|
400
|
+
}
|
|
401
|
+
return lines;
|
|
402
|
+
};
|
|
403
|
+
/**
|
|
404
|
+
* Header line for multi-project runs.
|
|
405
|
+
*
|
|
406
|
+
* @internal
|
|
407
|
+
*/
|
|
408
|
+
const renderHeader = (reports) => {
|
|
409
|
+
const lines = [];
|
|
410
|
+
if (showProjectLabel(reports) && reports.length > 1) lines.push(`Projects (${reports.length}):`);
|
|
411
|
+
else if (showProjectLabel(reports) && reports.length === 1) lines.push(`Project: ${reports[0]?.project ?? "default"}`);
|
|
412
|
+
return lines;
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* Public entry point: render a collection of `AgentReport` rows as
|
|
416
|
+
* a single plain-text block for stdout.
|
|
417
|
+
*
|
|
418
|
+
* @public
|
|
419
|
+
*/
|
|
420
|
+
const formatTerminal = (reports, options) => {
|
|
421
|
+
if (reports.length === 0) return "";
|
|
422
|
+
const ao = { noColor: options.noColor };
|
|
423
|
+
const out = [];
|
|
424
|
+
const header = renderHeader(reports);
|
|
425
|
+
if (header.length > 0) out.push(...header);
|
|
426
|
+
if (reports.length > 1) {
|
|
427
|
+
const widths = reports.map((r) => (r.project ?? "default").length);
|
|
428
|
+
const nameWidth = Math.max(0, ...widths);
|
|
429
|
+
for (const r of reports) {
|
|
430
|
+
out.push(renderProjectRow(r, ao, nameWidth));
|
|
431
|
+
if (r.summary.failed > 0 && r.tagCounts) out.push(...renderTagFailureBreakdown(r.tagCounts));
|
|
432
|
+
}
|
|
433
|
+
out.push("");
|
|
434
|
+
} else if (showProjectLabel(reports)) {
|
|
435
|
+
const r = reports[0];
|
|
436
|
+
if (r) {
|
|
437
|
+
const tick = r.summary.failed === 0 ? ansi("✓", "green", ao) : ansi("✗", "red", ao);
|
|
438
|
+
const counts = [];
|
|
439
|
+
if (r.summary.failed > 0) counts.push(`${r.summary.failed} failed`);
|
|
440
|
+
counts.push(`${r.summary.passed} passed`);
|
|
441
|
+
if (r.summary.skipped > 0) counts.push(`${r.summary.skipped} skipped`);
|
|
442
|
+
const baseRow = `${tick} ${counts.join(", ")} (${formatDuration(r.summary.duration)})`;
|
|
443
|
+
const tagSummary = buildTagSummary(r.tagCounts);
|
|
444
|
+
out.push(`${baseRow}${tagSummary}`);
|
|
445
|
+
if (r.summary.failed > 0 && r.tagCounts) out.push(...renderTagFailureBreakdown(r.tagCounts));
|
|
446
|
+
out.push("");
|
|
447
|
+
}
|
|
448
|
+
} else if (reports.length === 1) {
|
|
449
|
+
const r = reports[0];
|
|
450
|
+
if (r) {
|
|
451
|
+
const tick = r.summary.failed === 0 ? ansi("✓", "green", ao) : ansi("✗", "red", ao);
|
|
452
|
+
const counts = [];
|
|
453
|
+
if (r.summary.failed > 0) counts.push(`${r.summary.failed} failed`);
|
|
454
|
+
counts.push(`${r.summary.passed} passed`);
|
|
455
|
+
if (r.summary.skipped > 0) counts.push(`${r.summary.skipped} skipped`);
|
|
456
|
+
out.push(`${tick} ${counts.join(", ")} (${formatDuration(r.summary.duration)})`);
|
|
457
|
+
out.push("");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const failures = renderFailuresSection(reports, ao);
|
|
461
|
+
if (failures.length > 0) {
|
|
462
|
+
out.push(...failures);
|
|
463
|
+
out.push("");
|
|
464
|
+
}
|
|
465
|
+
const coverage = renderCoverageSection(reports, options, ao);
|
|
466
|
+
if (coverage.length > 0) {
|
|
467
|
+
out.push(...coverage);
|
|
468
|
+
out.push("");
|
|
469
|
+
}
|
|
470
|
+
out.push(renderTotal(reports, ao));
|
|
471
|
+
const nextSteps = renderNextSteps(reports, options);
|
|
472
|
+
if (nextSteps.length > 0) {
|
|
473
|
+
out.push("");
|
|
474
|
+
out.push(...nextSteps);
|
|
475
|
+
}
|
|
476
|
+
return out.join("\n");
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
//#endregion
|
|
480
|
+
export { formatTerminal };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as acorn from "acorn";
|
|
2
|
+
import { tsPlugin } from "acorn-typescript";
|
|
3
|
+
|
|
4
|
+
//#region src/utils/function-boundary.ts
|
|
5
|
+
const Parser = acorn.Parser.extend(tsPlugin());
|
|
6
|
+
const isFunctionLike = (node) => node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression";
|
|
7
|
+
const containsLine = (node, line) => node.loc !== void 0 && node.loc.start.line <= line && node.loc.end.line >= line;
|
|
8
|
+
const nodeName = (node, parent) => {
|
|
9
|
+
if (node.id !== null && node.id !== void 0) return node.id.name;
|
|
10
|
+
if (parent !== null && parent.type === "VariableDeclarator") {
|
|
11
|
+
const decl = parent;
|
|
12
|
+
if (decl.id !== void 0 && decl.id.name !== void 0) return decl.id.name;
|
|
13
|
+
}
|
|
14
|
+
if (parent !== null && parent.type === "MethodDefinition") {
|
|
15
|
+
const method = parent;
|
|
16
|
+
if (method.key !== void 0 && method.key.name !== void 0) return method.key.name;
|
|
17
|
+
}
|
|
18
|
+
return "<anonymous>";
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Parse `source` (JavaScript or TypeScript, via `acorn` + `acorn-typescript`)
|
|
22
|
+
* and return the smallest enclosing function's start line and name for `line`.
|
|
23
|
+
*
|
|
24
|
+
* Returns `null` only when the parser rejects the source outright (rare —
|
|
25
|
+
* usually a syntax error). Type annotations, generics, decorators, and `as`
|
|
26
|
+
* casts are all accepted.
|
|
27
|
+
* @public
|
|
28
|
+
*/
|
|
29
|
+
const findFunctionBoundary = (source, line) => {
|
|
30
|
+
let ast;
|
|
31
|
+
try {
|
|
32
|
+
ast = Parser.parse(source, {
|
|
33
|
+
ecmaVersion: "latest",
|
|
34
|
+
sourceType: "module",
|
|
35
|
+
locations: true
|
|
36
|
+
});
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
let smallest = null;
|
|
41
|
+
const walk = (node, parent) => {
|
|
42
|
+
if (node === null || node === void 0) return;
|
|
43
|
+
if (Array.isArray(node)) {
|
|
44
|
+
for (const child of node) walk(child, parent);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (typeof node !== "object") return;
|
|
48
|
+
if (isFunctionLike(node) && containsLine(node, line)) if (smallest === null) smallest = {
|
|
49
|
+
node,
|
|
50
|
+
parent
|
|
51
|
+
};
|
|
52
|
+
else {
|
|
53
|
+
const currSpan = smallest.node.loc.end.line - smallest.node.loc.start.line;
|
|
54
|
+
if (node.loc.end.line - node.loc.start.line < currSpan) smallest = {
|
|
55
|
+
node,
|
|
56
|
+
parent
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
for (const key of Object.keys(node)) {
|
|
60
|
+
if (key === "loc" || key === "type") continue;
|
|
61
|
+
const value = node[key];
|
|
62
|
+
if (value !== null && typeof value === "object") walk(value, node);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
walk(ast, null);
|
|
66
|
+
if (smallest === null) return null;
|
|
67
|
+
const s = smallest;
|
|
68
|
+
return {
|
|
69
|
+
line: s.node.loc.start.line,
|
|
70
|
+
name: nodeName(s.node, s.parent)
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
75
|
+
export { findFunctionBoundary };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/utils/hyperlink.ts
|
|
2
|
+
const ESC = "\x1B";
|
|
3
|
+
const BEL = "\x07";
|
|
4
|
+
/** @public */
|
|
5
|
+
const osc8 = (url, label, options) => {
|
|
6
|
+
if (!options.enabled) return label;
|
|
7
|
+
return `${ESC}]8;;${url}${BEL}${label}${ESC}]8;;${BEL}`;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
//#endregion
|
|
11
|
+
export { osc8 };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//#region src/utils/match-vitest-command.ts
|
|
2
|
+
const RE_DIRECT = /^\s*vitest(\s|$)/;
|
|
3
|
+
const RE_RUNNER = /^\s*(npx|pnpx|bunx)\s+vitest(\s|$)/;
|
|
4
|
+
const RE_PM_EXEC = /^\s*(npm|pnpm|yarn|bun)\s+(exec|dlx|x)\s+vitest(\s|$)/;
|
|
5
|
+
const RE_NODE_BIN = /^\s*node\s+(\.\/)?node_modules\/\.bin\/vitest(\s|$)/;
|
|
6
|
+
const matchPmScript = (command, scripts) => {
|
|
7
|
+
if (scripts.size === 0) return false;
|
|
8
|
+
const trimmed = command.trimStart();
|
|
9
|
+
for (const scriptName of scripts) {
|
|
10
|
+
const escaped = scriptName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11
|
+
if (new RegExp(`^(npm|pnpm|yarn|bun)\\s+(run\\s+)?${escaped}(\\s|$)`).test(trimmed)) return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Return `true` if the command invokes Vitest in any of the five
|
|
17
|
+
* supported shapes. The `vitestScripts` set carries the names of
|
|
18
|
+
* package.json scripts that were detected as containing `vitest`
|
|
19
|
+
* (computed once per project and cached by the sidecar).
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
const isVitestInvocation = (command, vitestScripts) => {
|
|
23
|
+
if (RE_DIRECT.test(command)) return true;
|
|
24
|
+
if (RE_RUNNER.test(command)) return true;
|
|
25
|
+
if (RE_PM_EXEC.test(command)) return true;
|
|
26
|
+
if (RE_NODE_BIN.test(command)) return true;
|
|
27
|
+
if (matchPmScript(command, vitestScripts)) return true;
|
|
28
|
+
return false;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Build the env-var prefix string to prepend to a matched command.
|
|
32
|
+
*
|
|
33
|
+
* Returns the assignments in the documented order:
|
|
34
|
+
* `VITEST_AGENT_CONVERSATION_ID=<uuid> VITEST_AGENT_AGENT_ID=<uuid> [VITEST_AGENT_PARENT_AGENT_ID=<uuid>]`
|
|
35
|
+
*
|
|
36
|
+
* POSIX env-prefix syntax scopes the assignments to the
|
|
37
|
+
* immediately-following process — no sticky-env across subsequent
|
|
38
|
+
* Bash calls, no risk of cross-contamination.
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
const buildEnvPrefix = (input) => {
|
|
42
|
+
const parts = [];
|
|
43
|
+
parts.push(`VITEST_AGENT_CONVERSATION_ID=${input.conversationId}`);
|
|
44
|
+
parts.push(`VITEST_AGENT_AGENT_ID=${input.agentId}`);
|
|
45
|
+
if (input.parentAgentId !== void 0) parts.push(`VITEST_AGENT_PARENT_AGENT_ID=${input.parentAgentId}`);
|
|
46
|
+
return parts.join(" ");
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Compute the rewritten command for a Bash tool invocation. When the
|
|
50
|
+
* command does not match a Vitest shape, returns the original
|
|
51
|
+
* unchanged. When it matches, prepends the env prefix.
|
|
52
|
+
* @public
|
|
53
|
+
*/
|
|
54
|
+
const rewriteBashCommand = (input) => {
|
|
55
|
+
if (!isVitestInvocation(input.command, input.vitestScripts)) return input.command;
|
|
56
|
+
return `${buildEnvPrefix({
|
|
57
|
+
conversationId: input.conversationId,
|
|
58
|
+
agentId: input.agentId,
|
|
59
|
+
...input.parentAgentId !== void 0 && { parentAgentId: input.parentAgentId }
|
|
60
|
+
})} ${input.command}`;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Detect which package.json scripts invoke Vitest by inspecting the
|
|
64
|
+
* `scripts` field. Resolves one hop of script reference (e.g.,
|
|
65
|
+
* `"test": "pnpm test:unit && pnpm test:int"` finds vitest if either
|
|
66
|
+
* `test:unit` or `test:int` mentions vitest). Multi-hop indirection
|
|
67
|
+
* is a documented limitation.
|
|
68
|
+
* @public
|
|
69
|
+
*/
|
|
70
|
+
const detectVitestScripts = (scripts) => {
|
|
71
|
+
const directHits = /* @__PURE__ */ new Set();
|
|
72
|
+
for (const [name, body] of Object.entries(scripts)) if (/\bvitest\b/.test(body)) directHits.add(name);
|
|
73
|
+
const includesScriptRef = (body, scriptName) => {
|
|
74
|
+
const escaped = scriptName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
75
|
+
return new RegExp(`(npm|pnpm|yarn|bun)\\s+(run\\s+)?${escaped}(\\s|$|&)`).test(body);
|
|
76
|
+
};
|
|
77
|
+
for (const [name, body] of Object.entries(scripts)) {
|
|
78
|
+
if (directHits.has(name)) continue;
|
|
79
|
+
for (const hit of directHits) if (includesScriptRef(body, hit)) {
|
|
80
|
+
directHits.add(name);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return directHits;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
//#endregion
|
|
88
|
+
export { buildEnvPrefix, detectVitestScripts, isVitestInvocation, rewriteBashCommand };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
//#region src/utils/normalize-workspace-key.ts
|
|
2
|
+
/**
|
|
3
|
+
* Normalize a workspace name into a filesystem-safe directory segment.
|
|
4
|
+
*
|
|
5
|
+
* The workspace key drives the per-project subdirectory under the XDG data
|
|
6
|
+
* directory (e.g. `$XDG_DATA_HOME/vitest-agent/<key>/data.db`). The
|
|
7
|
+
* key must be deterministic for a given input and safe to use as a single
|
|
8
|
+
* path segment on macOS, Linux, and Windows.
|
|
9
|
+
*
|
|
10
|
+
* Rules (applied in order):
|
|
11
|
+
*
|
|
12
|
+
* 1. Replace `/` with `__` so scoped names like `@org/pkg` collapse to
|
|
13
|
+
* `@org__pkg` rather than introducing a subdirectory boundary. Scoped
|
|
14
|
+
* npm names contain at most one `/` (between scope and name), and
|
|
15
|
+
* leading-`@` requires the scope/name form, so `@org__pkg` is not a
|
|
16
|
+
* valid unscoped npm name -- the `__` token is unambiguous in
|
|
17
|
+
* practice.
|
|
18
|
+
* 2. Replace any character outside `[A-Za-z0-9._@-]` with `_`. This catches
|
|
19
|
+
* Windows-reserved characters (`\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`),
|
|
20
|
+
* control characters, whitespace, and anything else that varies in
|
|
21
|
+
* interpretation across filesystems.
|
|
22
|
+
* 3. Collapse runs of underscores produced by the substitution above into a
|
|
23
|
+
* single `_` so the output stays compact.
|
|
24
|
+
*
|
|
25
|
+
* @param name - The raw `name` field from a workspace's `package.json`.
|
|
26
|
+
* @returns A filesystem-safe single path segment.
|
|
27
|
+
* @public
|
|
28
|
+
*/
|
|
29
|
+
function normalizeWorkspaceKey(name) {
|
|
30
|
+
return name.replaceAll("/", "__").replace(/[^A-Za-z0-9._@-]/g, "_").replace(/_{3,}/g, "__");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
export { normalizeWorkspaceKey };
|