@vitest-agent/ui 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/LICENSE +21 -0
- package/README.md +53 -0
- package/dispatcher/cells/single-file-fail.js +28 -0
- package/dispatcher/cells/single-file-pass.js +19 -0
- package/dispatcher/cells/single-file-threshold.js +22 -0
- package/dispatcher/cells/single-project-fail.js +34 -0
- package/dispatcher/cells/single-project-pass.js +17 -0
- package/dispatcher/cells/single-project-threshold.js +25 -0
- package/dispatcher/cells/single-test-fail.js +22 -0
- package/dispatcher/cells/single-test-pass.js +19 -0
- package/dispatcher/cells/single-test-threshold.js +12 -0
- package/dispatcher/cells/workspace-fail.js +27 -0
- package/dispatcher/cells/workspace-pass.js +25 -0
- package/dispatcher/cells/workspace-threshold.js +28 -0
- package/dispatcher/classify.js +48 -0
- package/dispatcher/dispatch.js +73 -0
- package/dispatcher/footer.js +55 -0
- package/dispatcher/helpers.js +186 -0
- package/dispatcher/ink-helpers.js +79 -0
- package/format-duration.js +34 -0
- package/index.d.ts +1843 -0
- package/index.js +40 -0
- package/package.json +59 -0
- package/pubsub/Channel.js +30 -0
- package/pubsub/Publisher.js +37 -0
- package/pubsub/Subscriber.js +77 -0
- package/pubsub/index.js +5 -0
- package/reducer.js +260 -0
- package/render-agent.js +137 -0
- package/render-ink/CountColumns.js +34 -0
- package/render-ink/CoverageBlock.js +80 -0
- package/render-ink/FailureSection.js +69 -0
- package/render-ink/FailuresSection.js +85 -0
- package/render-ink/ModuleHeader.js +43 -0
- package/render-ink/ProjectRow.js +56 -0
- package/render-ink/StatusIcon.js +44 -0
- package/render-ink/StreamApp.js +453 -0
- package/render-ink/SuggestedActions.js +49 -0
- package/render-ink/TestRow.js +33 -0
- package/render-ink/TrendLine.js +42 -0
- package/render-ink/index.js +14 -0
- package/render-ink/spinner.js +62 -0
- package/render-ink/tag-suffix.js +18 -0
- package/synthesize.js +335 -0
- package/tsdoc-metadata.json +11 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { formatDisplayDuration } from "../format-duration.js";
|
|
2
|
+
|
|
3
|
+
//#region src/dispatcher/helpers.ts
|
|
4
|
+
/**
|
|
5
|
+
* Truncate a line to a maximum width with an ellipsis suffix.
|
|
6
|
+
*/
|
|
7
|
+
const truncate = (line, max) => {
|
|
8
|
+
if (line.length <= max) return line;
|
|
9
|
+
const slice = max - 1;
|
|
10
|
+
if (slice <= 0) return "…";
|
|
11
|
+
return `${line.slice(0, slice)}…`;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Format the `Tests:` header line — `<pass>/<total> passed[, <fail> failed][, <skip> skipped] (Xms)`.
|
|
15
|
+
*/
|
|
16
|
+
const formatTotals = (state) => {
|
|
17
|
+
const { passCount, failCount, skipCount, durationMs } = state.totals;
|
|
18
|
+
const parts = [`${passCount}/${passCount + failCount + skipCount} passed`];
|
|
19
|
+
if (failCount > 0) parts.push(`${failCount} failed`);
|
|
20
|
+
if (skipCount > 0) parts.push(`${skipCount} skipped`);
|
|
21
|
+
return `Tests: ${parts.join(", ")} (${formatDisplayDuration(durationMs)})`;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Locate the sole {@link TestRecord} in a `single-test` shape state.
|
|
25
|
+
* Returns `undefined` for malformed inputs; cells fall through to an
|
|
26
|
+
* empty rendering rather than throwing.
|
|
27
|
+
*/
|
|
28
|
+
const soleTest = (state) => {
|
|
29
|
+
const moduleEntries = Object.values(state.modules);
|
|
30
|
+
if (moduleEntries.length !== 1) return void 0;
|
|
31
|
+
const sole = moduleEntries[0];
|
|
32
|
+
if (sole?.tests.length !== 1) return void 0;
|
|
33
|
+
return sole.tests[0];
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Locate the sole module path in a `single-file` shape state.
|
|
37
|
+
*/
|
|
38
|
+
const soleModulePath = (state) => {
|
|
39
|
+
if (state.moduleOrder.length !== 1) return void 0;
|
|
40
|
+
return state.moduleOrder[0];
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Format a test name in `<suite > test name>` form, or just `<test name>`
|
|
44
|
+
* when no suite path is present.
|
|
45
|
+
*/
|
|
46
|
+
const formatTestName = (test) => {
|
|
47
|
+
if (test.suitePath.length === 0) return test.testName;
|
|
48
|
+
return `${test.suitePath.join(" > ")} > ${test.testName}`;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Render one failure block — `- <path > suite > name> [classification]`
|
|
52
|
+
* followed by the indented message and diff. Stack traces are omitted
|
|
53
|
+
* by default to keep the agent string compact.
|
|
54
|
+
*/
|
|
55
|
+
const formatFailure = (f, width) => {
|
|
56
|
+
const suite = f.suitePath.length > 0 ? `${f.suitePath.join(" > ")} > ` : "";
|
|
57
|
+
const classification = f.classification !== null ? ` [${f.classification}]` : "";
|
|
58
|
+
const lines = [`- ${f.modulePath} > ${suite}${f.testName}${classification}`];
|
|
59
|
+
if (f.error?.message !== void 0) {
|
|
60
|
+
const firstLine = f.error.message.split("\n", 1)[0] ?? "";
|
|
61
|
+
lines.push(` ${truncate(firstLine, Math.max(20, width - 2))}`);
|
|
62
|
+
}
|
|
63
|
+
if (f.error?.diff !== void 0) for (const diffLine of f.error.diff.split("\n")) lines.push(` ${truncate(diffLine, Math.max(20, width - 2))}`);
|
|
64
|
+
return lines;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* One coverage judgment line — `Coverage: ✓ all metrics meet thresholds`
|
|
68
|
+
* for a clean run, `Coverage: ✗ <N> files below minimum thresholds (...)`
|
|
69
|
+
* for a violation. Returns `null` when the run carries no coverage block.
|
|
70
|
+
*/
|
|
71
|
+
const formatCoverageJudgmentLine = (state) => {
|
|
72
|
+
const cov = state.coverage;
|
|
73
|
+
if (cov === null) return null;
|
|
74
|
+
if (cov.violations.length === 0) return "Coverage: ✓ all metrics meet thresholds";
|
|
75
|
+
const metrics = cov.violations.map((v) => v.metric).join(", ");
|
|
76
|
+
const fileCount = countLowCoverageFiles(cov.gaps);
|
|
77
|
+
return `Coverage: ✗ ${fileCount} ${fileCount === 1 ? "file" : "files"} below minimum thresholds (${metrics})`;
|
|
78
|
+
};
|
|
79
|
+
const countLowCoverageFiles = (gaps) => {
|
|
80
|
+
const seen = /* @__PURE__ */ new Set();
|
|
81
|
+
for (const g of gaps) seen.add(g.file);
|
|
82
|
+
return seen.size;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Format the `Trend: …` line for runs that carry trend history.
|
|
86
|
+
* Returns `null` when no trend is available.
|
|
87
|
+
*/
|
|
88
|
+
const formatTrendLine = (trend) => {
|
|
89
|
+
if (trend === null) return null;
|
|
90
|
+
const runs = trend.runCount === 1 ? "1 run" : `${trend.runCount} runs`;
|
|
91
|
+
return `Trend: ${trend.direction} (${runs})`;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Format a compact projects-table line for one project. Pads the name
|
|
95
|
+
* column to `nameWidth` so a column of rows aligns when joined with
|
|
96
|
+
* newlines. Status glyph is `✓` for clean projects, `✗` for any
|
|
97
|
+
* project carrying failures or violations.
|
|
98
|
+
*/
|
|
99
|
+
const formatProjectRow = (project, nameWidth) => {
|
|
100
|
+
const total = project.passCount + project.failCount + project.skipCount;
|
|
101
|
+
const glyph = project.failCount > 0 ? "✗" : "✓";
|
|
102
|
+
const counts = project.failCount > 0 ? `${project.passCount}/${total} passed, ${project.failCount} failed` : `${project.passCount} passed`;
|
|
103
|
+
const tagSuffix = formatTagCountSuffix(project.tagCounts);
|
|
104
|
+
const base = ` ${glyph} ${project.name.padEnd(nameWidth)} ${counts} (${formatDisplayDuration(project.durationMs)})`;
|
|
105
|
+
return tagSuffix.length === 0 ? base : `${base} ${tagSuffix}`;
|
|
106
|
+
};
|
|
107
|
+
const formatTagCountSuffix = (tagCounts) => {
|
|
108
|
+
if (tagCounts === void 0) return "";
|
|
109
|
+
const entries = Object.entries(tagCounts);
|
|
110
|
+
if (entries.length <= 1) return "";
|
|
111
|
+
return [...entries].sort(([a], [b]) => a.localeCompare(b)).map(([tag, count]) => `${tag}:${count}`).join(" ");
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Format the `Projects (N):` block as an array of lines including the
|
|
115
|
+
* leading header and each project row. The longest project name sets
|
|
116
|
+
* the padded column width so the counts align vertically.
|
|
117
|
+
*/
|
|
118
|
+
const formatProjectsTable = (projects) => {
|
|
119
|
+
if (projects.length === 0) return [];
|
|
120
|
+
const nameWidth = projects.reduce((max, p) => Math.max(max, p.name.length), 0);
|
|
121
|
+
return [`Projects (${projects.length}):`, ...projects.map((p) => formatProjectRow(p, nameWidth))];
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Format the `Total:` footer for a workspace run.
|
|
125
|
+
*/
|
|
126
|
+
const formatWorkspaceTotal = (projects) => {
|
|
127
|
+
let pass = 0;
|
|
128
|
+
let fail = 0;
|
|
129
|
+
let skip = 0;
|
|
130
|
+
let durationMs = 0;
|
|
131
|
+
for (const p of projects) {
|
|
132
|
+
pass += p.passCount;
|
|
133
|
+
fail += p.failCount;
|
|
134
|
+
skip += p.skipCount;
|
|
135
|
+
durationMs += p.durationMs;
|
|
136
|
+
}
|
|
137
|
+
const total = pass + fail + skip;
|
|
138
|
+
const parts = [`${pass}/${total} passed`];
|
|
139
|
+
if (fail > 0) parts.push(`${fail} failed`);
|
|
140
|
+
if (skip > 0) parts.push(`${skip} skipped`);
|
|
141
|
+
return `Total: ${parts.join(", ")} (${formatDisplayDuration(durationMs)})`;
|
|
142
|
+
};
|
|
143
|
+
const TABLE_COL_FILE = 60;
|
|
144
|
+
/**
|
|
145
|
+
* Format the `Files below aspirational target:` block — a pipe-delimited
|
|
146
|
+
* table truncated to the first `limit` files with a "+N more" suffix.
|
|
147
|
+
* Returns an empty array when `belowTarget` is empty.
|
|
148
|
+
*/
|
|
149
|
+
const formatBelowTargetTable = (belowTarget, limit) => {
|
|
150
|
+
if (belowTarget.length === 0) return [];
|
|
151
|
+
const top = belowTarget.slice(0, limit);
|
|
152
|
+
const omitted = belowTarget.length - top.length;
|
|
153
|
+
const header = [
|
|
154
|
+
"Files below aspirational target:",
|
|
155
|
+
buildTableSeparator(),
|
|
156
|
+
buildTableHeader(),
|
|
157
|
+
buildTableSeparator()
|
|
158
|
+
];
|
|
159
|
+
const rows = top.map((file) => buildTableRow(file));
|
|
160
|
+
const footer = [];
|
|
161
|
+
if (omitted > 0) footer.push(`… ${omitted} more (use the test_coverage MCP tool for the full list)`);
|
|
162
|
+
return [
|
|
163
|
+
...header,
|
|
164
|
+
...rows,
|
|
165
|
+
...footer
|
|
166
|
+
];
|
|
167
|
+
};
|
|
168
|
+
const buildTableSeparator = () => {
|
|
169
|
+
return `${"-".repeat(TABLE_COL_FILE)}|---------|---------|---------|---------|-------------------`;
|
|
170
|
+
};
|
|
171
|
+
const buildTableHeader = () => {
|
|
172
|
+
return ` ${"File".padEnd(TABLE_COL_FILE - 1)}| % Stmts | % Branch| % Funcs | % Lines | Uncovered Line #s`;
|
|
173
|
+
};
|
|
174
|
+
const buildTableRow = (file) => {
|
|
175
|
+
return `${` ${truncate(file.file, TABLE_COL_FILE - 2).padEnd(TABLE_COL_FILE - 1)}`}|${pctCell(file.summary.statements)}|${pctCell(file.summary.branches)}|${pctCell(file.summary.functions)}|${pctCell(file.summary.lines)}|${` ${file.uncoveredLines}`}`;
|
|
176
|
+
};
|
|
177
|
+
const pctCell = (n) => {
|
|
178
|
+
const text = `${Math.round(n)}`;
|
|
179
|
+
const pad = Math.max(0, 9 - text.length);
|
|
180
|
+
const left = Math.floor(pad / 2);
|
|
181
|
+
const right = pad - left;
|
|
182
|
+
return `${" ".repeat(left + 1)}${text}${" ".repeat(right)}`;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
//#endregion
|
|
186
|
+
export { formatBelowTargetTable, formatCoverageJudgmentLine, formatFailure, formatProjectsTable, formatTestName, formatTotals, formatTrendLine, formatWorkspaceTotal, soleModulePath, soleTest };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
|
|
4
|
+
//#region src/dispatcher/ink-helpers.tsx
|
|
5
|
+
/**
|
|
6
|
+
* Ink rendering helpers shared by the dispatcher cells.
|
|
7
|
+
*
|
|
8
|
+
* Most cells re-express their agent-half string in a simple
|
|
9
|
+
* `<Box flexDirection="column">` with one `<Text>` per line plus
|
|
10
|
+
* targeted color coding on glyph characters (✓ green, ✗ red,
|
|
11
|
+
* Trend regressing/improving in matching colors).
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Render an agent-string output as a column of Ink Text rows, applying
|
|
17
|
+
* color to the leading status glyph on each line.
|
|
18
|
+
*/
|
|
19
|
+
const renderAgentStringAsInk = (agentString) => {
|
|
20
|
+
return /* @__PURE__ */ jsx(Box, {
|
|
21
|
+
flexDirection: "column",
|
|
22
|
+
children: agentString.split("\n").map((line, idx) => /* @__PURE__ */ jsx(Text, { children: colorize(line) }, `${idx}-${line}`))
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
const PASS_GLYPH = "✓";
|
|
26
|
+
const FAIL_GLYPH = "✗";
|
|
27
|
+
const colorize = (line) => {
|
|
28
|
+
const trimmed = line.trimStart();
|
|
29
|
+
if (trimmed.startsWith(PASS_GLYPH)) {
|
|
30
|
+
const idx = line.indexOf(PASS_GLYPH);
|
|
31
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
32
|
+
line.slice(0, idx),
|
|
33
|
+
/* @__PURE__ */ jsx(Text, {
|
|
34
|
+
color: "green",
|
|
35
|
+
children: PASS_GLYPH
|
|
36
|
+
}),
|
|
37
|
+
line.slice(idx + 1)
|
|
38
|
+
] });
|
|
39
|
+
}
|
|
40
|
+
if (trimmed.startsWith(FAIL_GLYPH)) {
|
|
41
|
+
const idx = line.indexOf(FAIL_GLYPH);
|
|
42
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
43
|
+
line.slice(0, idx),
|
|
44
|
+
/* @__PURE__ */ jsx(Text, {
|
|
45
|
+
color: "red",
|
|
46
|
+
children: FAIL_GLYPH
|
|
47
|
+
}),
|
|
48
|
+
line.slice(idx + 1)
|
|
49
|
+
] });
|
|
50
|
+
}
|
|
51
|
+
if (line.startsWith("Trend: regressing")) return /* @__PURE__ */ jsx(Text, {
|
|
52
|
+
color: "yellow",
|
|
53
|
+
children: line
|
|
54
|
+
});
|
|
55
|
+
if (line.startsWith("Trend: improving")) return /* @__PURE__ */ jsx(Text, {
|
|
56
|
+
color: "green",
|
|
57
|
+
children: line
|
|
58
|
+
});
|
|
59
|
+
if (line.startsWith("Coverage: ✓")) return /* @__PURE__ */ jsx(Text, {
|
|
60
|
+
color: "green",
|
|
61
|
+
children: line
|
|
62
|
+
});
|
|
63
|
+
if (line.startsWith("Coverage: ✗")) return /* @__PURE__ */ jsx(Text, {
|
|
64
|
+
color: "red",
|
|
65
|
+
children: line
|
|
66
|
+
});
|
|
67
|
+
if (line.startsWith("Failures:")) return /* @__PURE__ */ jsx(Text, {
|
|
68
|
+
bold: true,
|
|
69
|
+
children: line
|
|
70
|
+
});
|
|
71
|
+
if (line.startsWith("Use `")) return /* @__PURE__ */ jsx(Text, {
|
|
72
|
+
dimColor: true,
|
|
73
|
+
children: line
|
|
74
|
+
});
|
|
75
|
+
return line;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
export { renderAgentStringAsInk };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
//#region src/format-duration.ts
|
|
2
|
+
/**
|
|
3
|
+
* Shared display formatter for run / module / test durations.
|
|
4
|
+
*
|
|
5
|
+
* Vitest hands the reporter full-float millisecond durations
|
|
6
|
+
* (`14.87745800000016`). Rendering those verbatim is noise. This is
|
|
7
|
+
* the single formatter every render path calls — `render-agent.ts`,
|
|
8
|
+
* the `render-ink/` components, the dispatcher cells, and `StreamApp`
|
|
9
|
+
* — so a duration looks the same wherever it appears.
|
|
10
|
+
*
|
|
11
|
+
* Display only. Full-precision durations continue to persist to the
|
|
12
|
+
* database unchanged; nothing in the trend / baseline / classification
|
|
13
|
+
* pipeline reads a duration through a formatter.
|
|
14
|
+
*/
|
|
15
|
+
const SECOND_MS = 1e3;
|
|
16
|
+
/**
|
|
17
|
+
* Format a duration in milliseconds for display.
|
|
18
|
+
*
|
|
19
|
+
* Sub-second values round to one decimal place and render as `<N.N>ms`;
|
|
20
|
+
* values at or above one second render as `<N.N>s`. A value that rounds
|
|
21
|
+
* to a whole number drops the trailing `.0` naturally (`Number`
|
|
22
|
+
* stringification), so `1000` → `1s` and `250` → `250ms`.
|
|
23
|
+
*
|
|
24
|
+
* @param ms - duration in milliseconds
|
|
25
|
+
* @returns formatted duration string
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
28
|
+
const formatDisplayDuration = (ms) => {
|
|
29
|
+
if (ms < SECOND_MS) return `${Math.round(ms * 10) / 10}ms`;
|
|
30
|
+
return `${Math.round(ms / SECOND_MS * 10) / 10}s`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
export { formatDisplayDuration };
|