@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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/dispatcher/cells/single-file-fail.js +28 -0
  4. package/dispatcher/cells/single-file-pass.js +19 -0
  5. package/dispatcher/cells/single-file-threshold.js +22 -0
  6. package/dispatcher/cells/single-project-fail.js +34 -0
  7. package/dispatcher/cells/single-project-pass.js +17 -0
  8. package/dispatcher/cells/single-project-threshold.js +25 -0
  9. package/dispatcher/cells/single-test-fail.js +22 -0
  10. package/dispatcher/cells/single-test-pass.js +19 -0
  11. package/dispatcher/cells/single-test-threshold.js +12 -0
  12. package/dispatcher/cells/workspace-fail.js +27 -0
  13. package/dispatcher/cells/workspace-pass.js +25 -0
  14. package/dispatcher/cells/workspace-threshold.js +28 -0
  15. package/dispatcher/classify.js +48 -0
  16. package/dispatcher/dispatch.js +73 -0
  17. package/dispatcher/footer.js +55 -0
  18. package/dispatcher/helpers.js +186 -0
  19. package/dispatcher/ink-helpers.js +79 -0
  20. package/format-duration.js +34 -0
  21. package/index.d.ts +1843 -0
  22. package/index.js +40 -0
  23. package/package.json +59 -0
  24. package/pubsub/Channel.js +30 -0
  25. package/pubsub/Publisher.js +37 -0
  26. package/pubsub/Subscriber.js +77 -0
  27. package/pubsub/index.js +5 -0
  28. package/reducer.js +260 -0
  29. package/render-agent.js +137 -0
  30. package/render-ink/CountColumns.js +34 -0
  31. package/render-ink/CoverageBlock.js +80 -0
  32. package/render-ink/FailureSection.js +69 -0
  33. package/render-ink/FailuresSection.js +85 -0
  34. package/render-ink/ModuleHeader.js +43 -0
  35. package/render-ink/ProjectRow.js +56 -0
  36. package/render-ink/StatusIcon.js +44 -0
  37. package/render-ink/StreamApp.js +453 -0
  38. package/render-ink/SuggestedActions.js +49 -0
  39. package/render-ink/TestRow.js +33 -0
  40. package/render-ink/TrendLine.js +42 -0
  41. package/render-ink/index.js +14 -0
  42. package/render-ink/spinner.js +62 -0
  43. package/render-ink/tag-suffix.js +18 -0
  44. package/synthesize.js +335 -0
  45. 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 };