@vitest-agent/reporter 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,119 @@
1
+ import { SPINNER_FRAME_MS, StreamApp, reduceRenderState, spinnerFrameForTime } from "@vitest-agent/ui";
2
+ import { initialRenderState } from "@vitest-agent/sdk";
3
+ import { Box, render, renderToString } from "ink";
4
+ import { createElement } from "react";
5
+
6
+ //#region src/LiveInkRenderer.tsx
7
+ /**
8
+ * Create a {@link LiveInkRenderer} that drives a live Ink mount for
9
+ * `consoleMode: "stream"`. Call `event(e)` for each `RunEvent` published
10
+ * by the plugin; the renderer mounts on `RunStarted`, rerenders on each
11
+ * subsequent event, and commits the final frame on `RunFinished` /
12
+ * `RunTimedOut`.
13
+ *
14
+ * @public
15
+ */
16
+ const createLiveInk = (options = {}) => {
17
+ let state = initialRenderState;
18
+ let instance = null;
19
+ let clock = null;
20
+ let firstRunStarted = false;
21
+ const targetStream = options.stream ?? process.stdout;
22
+ const frameWidth = () => {
23
+ const cols = targetStream.columns;
24
+ return typeof cols === "number" && cols > 1 ? cols - 1 : void 0;
25
+ };
26
+ const frameElement = () => {
27
+ const now = Date.now();
28
+ return createElement(Box, {
29
+ flexDirection: "column",
30
+ width: frameWidth()
31
+ }, createElement(StreamApp, {
32
+ state,
33
+ frameIndex: spinnerFrameForTime(now),
34
+ nowMs: now
35
+ }));
36
+ };
37
+ const mount = () => {
38
+ if (instance !== null) return;
39
+ instance = render(frameElement(), { stdout: targetStream });
40
+ };
41
+ const stopClock = () => {
42
+ if (clock === null) return;
43
+ clearInterval(clock);
44
+ clock = null;
45
+ };
46
+ const startClock = () => {
47
+ if (clock !== null) return;
48
+ clock = setInterval(() => {
49
+ if (instance === null) return;
50
+ try {
51
+ instance.rerender(frameElement());
52
+ } catch {
53
+ instance = null;
54
+ stopClock();
55
+ }
56
+ }, SPINNER_FRAME_MS);
57
+ clock.unref?.();
58
+ };
59
+ const tearDown = () => {
60
+ stopClock();
61
+ if (instance === null) return;
62
+ instance.unmount();
63
+ instance = null;
64
+ firstRunStarted = false;
65
+ };
66
+ return {
67
+ event(event) {
68
+ const previousPhase = state.phase;
69
+ state = reduceRenderState(state, event);
70
+ if (event._tag === "RunStarted" || previousPhase === "idle" && state.phase !== "idle") {
71
+ if (firstRunStarted) {
72
+ if (instance !== null) try {
73
+ instance.clear();
74
+ } catch {}
75
+ } else try {
76
+ mount();
77
+ } catch (err) {
78
+ process.stderr.write(`@vitest-agent/reporter: live ink renderer failed; falling back silently (${err.message})\n`);
79
+ instance = null;
80
+ }
81
+ firstRunStarted = true;
82
+ startClock();
83
+ } else if (instance !== null) try {
84
+ instance.rerender(frameElement());
85
+ } catch (err) {
86
+ process.stderr.write(`@vitest-agent/reporter: live ink renderer failed; falling back silently (${err.message})\n`);
87
+ instance = null;
88
+ stopClock();
89
+ }
90
+ if (event._tag === "RunFinished" || event._tag === "RunTimedOut") {
91
+ stopClock();
92
+ if (instance !== null) {
93
+ try {
94
+ instance.unmount();
95
+ } catch {}
96
+ instance = null;
97
+ } else {
98
+ const now = Date.now();
99
+ const finalFrameText = renderToString(createElement(StreamApp, {
100
+ state,
101
+ frameIndex: spinnerFrameForTime(now),
102
+ nowMs: now
103
+ }));
104
+ targetStream.write(`${finalFrameText}\n`);
105
+ }
106
+ firstRunStarted = false;
107
+ }
108
+ },
109
+ unmount() {
110
+ tearDown();
111
+ },
112
+ snapshot() {
113
+ return state;
114
+ }
115
+ };
116
+ };
117
+
118
+ //#endregion
119
+ export { createLiveInk };
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # @vitest-agent/reporter
2
+
3
+ [![npm](https://img.shields.io/npm/v/@vitest-agent/reporter?label=npm&color=cb3837)](https://www.npmjs.com/package/@vitest-agent/reporter)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![TypeScript 6.0](https://img.shields.io/badge/TypeScript-6.0-3178c6.svg)](https://www.typescriptlang.org/)
6
+
7
+ > **Part of the [vitest-agent](https://vitest-agent.dev) ecosystem.** Most users want **[@vitest-agent/plugin](https://www.npmjs.com/package/@vitest-agent/plugin)**, which pulls this package in automatically. Install `@vitest-agent/reporter` directly only if you are writing a custom reporter.
8
+
9
+ The default reporter package and the reference for custom-reporter authors. Ships `DefaultVitestAgentReporter`, owns the live React Ink mount lifecycle, and re-exports the reporter contract types and dispatch helpers — everything a custom factory needs in one import.
10
+
11
+ ## Features
12
+
13
+ - **`DefaultVitestAgentReporter`** — the production default `VitestAgentReporterFactory` the plugin wires automatically; classifies runs into one of 12 shape × outcome cells, owns the Ink live-mount lifecycle, and emits a GitHub Actions Step Summary in CI
14
+ - **Dispatch helpers** — `buildDispatchInputs`, `resolveCellOptions`, `renderAgentStringForReport`, `renderHumanStringForReport`
15
+ - **Contract re-exports** — `VitestAgentReporterFactory`, `ReporterKit`, `ReporterRenderInput`, `RenderedOutput`, `VitestAgentReporter`, `ResolvedReporterConfig` all re-exported from `@vitest-agent/sdk` so you only need one import
16
+ - **Worked example** — `DefaultVitestAgentReporter` source in this package is the canonical reference for the `VitestAgentReporterFactory` contract
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install --save-dev @vitest-agent/reporter
22
+ # or
23
+ pnpm add -D @vitest-agent/reporter
24
+ ```
25
+
26
+ `@vitest-agent/reporter` arrives automatically as a dependency of `@vitest-agent/plugin`. Install it directly only when authoring a custom reporter.
27
+
28
+ ## Quick start
29
+
30
+ ```ts
31
+ import type {
32
+ ReporterKit,
33
+ ReporterRenderInput,
34
+ RenderedOutput,
35
+ VitestAgentReporter,
36
+ VitestAgentReporterFactory,
37
+ } from "@vitest-agent/reporter";
38
+
39
+ const myReporter: VitestAgentReporterFactory = (kit: ReporterKit): VitestAgentReporter => ({
40
+ render(input: ReporterRenderInput): ReadonlyArray<RenderedOutput> {
41
+ const passed = input.reports.reduce((n, r) => n + r.summary.passed, 0);
42
+ return [{ target: "stdout", content: `${passed} passed\n` }];
43
+ },
44
+ });
45
+ ```
46
+
47
+ Pass the factory to the plugin:
48
+
49
+ ```ts
50
+ import { AgentPlugin } from "@vitest-agent/plugin";
51
+
52
+ AgentPlugin({ reporter: myReporter });
53
+ ```
54
+
55
+ ## Documentation
56
+
57
+ Custom-reporter guide at [vitest-agent.dev/reporter](https://vitest-agent.dev/reporter).
58
+
59
+ ## License
60
+
61
+ [MIT](LICENSE)
@@ -0,0 +1,208 @@
1
+ import { createLiveInk } from "./LiveInkRenderer.js";
2
+ import { classifyOutcome, classifyRunShape, dispatch, dispatcherTable, reduceRenderStateAll, synthesizeFromAgentReport } from "@vitest-agent/ui";
3
+ import { Effect, PubSub, Queue } from "effect";
4
+
5
+ //#region src/defaultReporter.ts
6
+ const summarizeProject = (report) => {
7
+ const collapsedTagCounts = report.tagCounts !== void 0 ? collapseTagCounts(report.tagCounts) : void 0;
8
+ const tagCountsHasEntries = collapsedTagCounts !== void 0 && Object.keys(collapsedTagCounts).length > 0;
9
+ const belowTargetCount = report.coverage?.belowTargetFiles?.length;
10
+ const violationsCount = report.coverage !== void 0 && report.coverage.lowCoverage.length > 0 ? report.coverage.lowCoverage.length : void 0;
11
+ return {
12
+ name: report.project ?? "default",
13
+ passCount: report.summary.passed,
14
+ failCount: report.summary.failed,
15
+ skipCount: report.summary.skipped,
16
+ durationMs: report.summary.duration,
17
+ ...tagCountsHasEntries && collapsedTagCounts !== void 0 ? { tagCounts: collapsedTagCounts } : {},
18
+ ...belowTargetCount !== void 0 ? { belowTarget: belowTargetCount } : {},
19
+ ...violationsCount !== void 0 ? { violations: violationsCount } : {}
20
+ };
21
+ };
22
+ const collapseTagCounts = (entries) => {
23
+ const out = {};
24
+ if (entries === void 0) return out;
25
+ for (const [tag, entry] of Object.entries(entries)) {
26
+ const total = (entry.passed ?? 0) + (entry.failed ?? 0) + (entry.skipped ?? 0);
27
+ if (total > 0) out[tag] = total;
28
+ }
29
+ return out;
30
+ };
31
+ const collectBelowTarget = (reports) => {
32
+ const out = [];
33
+ for (const r of reports) {
34
+ const list = r.coverage?.belowTarget;
35
+ if (list !== void 0) out.push(...list);
36
+ }
37
+ return out;
38
+ };
39
+ const liftTrendSummary = (input) => {
40
+ const trend = input.trendSummary;
41
+ if (trend === void 0) return null;
42
+ return {
43
+ direction: trend.direction,
44
+ runCount: trend.runCount,
45
+ ...trend.firstMetric !== void 0 ? { firstMetric: trend.firstMetric } : {}
46
+ };
47
+ };
48
+ /**
49
+ * Build a `DispatchInputs` from a {@link ReporterRenderInput} and
50
+ * the reduced `RenderState`.
51
+ *
52
+ * Exported so a custom reporter built on the same dispatcher can reuse
53
+ * this assembly step without rebuilding it from scratch.
54
+ *
55
+ * @public
56
+ */
57
+ const buildDispatchInputs = (state, input, overrides = {}) => {
58
+ const projects = input.reports.map((r) => summarizeProject(r));
59
+ return {
60
+ state,
61
+ shape: overrides.shape ?? classifyRunShape(state, projects),
62
+ outcome: overrides.outcome ?? classifyOutcome(state),
63
+ projects,
64
+ trend: liftTrendSummary(input),
65
+ belowTarget: collectBelowTarget(input.reports),
66
+ runCommand: overrides.runCommand ?? null
67
+ };
68
+ };
69
+ /**
70
+ * Build `CellOptions` from a {@link ReporterKit}. Picks the kit's
71
+ * resolved `noColor` value and the pre-bound OSC-8 hyperlink helper.
72
+ *
73
+ * @public
74
+ */
75
+ const resolveCellOptions = (kit) => ({
76
+ noColor: kit.config.noColor,
77
+ osc8: kit.stdOsc8
78
+ });
79
+ const shouldRenderForMode = (mode) => mode === "agent";
80
+ /**
81
+ * Convenience helper for one-shot consumers (e.g. a CLI command
82
+ * replaying a stored run). Synthesizes a minimal `ReporterRenderInput`
83
+ * from a single `AgentReport`, classifies the shape and outcome, and
84
+ * returns the dispatched agent-string for the matching cell. Equivalent
85
+ * to the pre-2.0 `renderRun(events, "agent")` shortcut.
86
+ *
87
+ * @public
88
+ */
89
+ const renderAgentStringForReport = (report) => {
90
+ const state = reduceRenderStateAll(synthesizeFromAgentReport(report));
91
+ const projects = [summarizeProject(report)];
92
+ return dispatch({
93
+ state,
94
+ shape: classifyRunShape(state, projects),
95
+ outcome: classifyOutcome(state),
96
+ projects,
97
+ trend: null,
98
+ belowTarget: collectBelowTarget([report]),
99
+ runCommand: null
100
+ }, {
101
+ noColor: true,
102
+ osc8: (_url, label) => label
103
+ });
104
+ };
105
+ /**
106
+ * Same as {@link renderAgentStringForReport} but returns the Ink-half
107
+ * rendered to a string via Ink's `renderToString`. ANSI escape
108
+ * sequences are preserved so a terminal renders the colors live.
109
+ * Returns the agent-string fallback when the matched cell has no Ink
110
+ * half.
111
+ *
112
+ * @public
113
+ */
114
+ const renderHumanStringForReport = async (report, options = {}) => {
115
+ const { renderToString } = await import("ink");
116
+ const state = reduceRenderStateAll(synthesizeFromAgentReport(report));
117
+ const projects = [summarizeProject(report)];
118
+ const inputs = {
119
+ state,
120
+ shape: classifyRunShape(state, projects),
121
+ outcome: classifyOutcome(state),
122
+ projects,
123
+ trend: null,
124
+ belowTarget: collectBelowTarget([report]),
125
+ runCommand: null
126
+ };
127
+ const opts = {
128
+ noColor: false,
129
+ osc8: (_url, label) => label
130
+ };
131
+ const cell = dispatcherTable[inputs.shape][inputs.outcome];
132
+ if (cell.ink === void 0) return dispatch(inputs, opts);
133
+ return renderToString(cell.ink(inputs, opts), { columns: options.width ?? 80 });
134
+ };
135
+ const renderGithubSummary = (input) => {
136
+ const out = [];
137
+ for (const report of input.reports) {
138
+ const body = `${`## ${report.project ?? "Vitest Results"}`}\n\n${`${report.summary.passed}/${report.summary.total} passed, ${report.summary.failed} failed, ${report.summary.skipped} skipped`}\n`;
139
+ out.push({
140
+ target: "github-summary",
141
+ content: body,
142
+ contentType: "text/markdown"
143
+ });
144
+ }
145
+ return out;
146
+ };
147
+ /**
148
+ * Subscribe a live Ink mount to the kit's run-event channel.
149
+ *
150
+ * Called from the factory when `consoleMode` is `stream`. The factory runs
151
+ * at run start — before the plugin publishes the first `RunStarted`
152
+ * event — so the subscription is registered in time. `Effect.runFork`
153
+ * advances the forked fiber up to its first suspension (the
154
+ * `Queue.take` below); that suspension point is past `PubSub.subscribe`,
155
+ * so the subscription is live before this function returns. The drain
156
+ * loop runs forever: `createLiveInk` handles `RunFinished` (schedules
157
+ * unmount) and a subsequent `RunStarted` (remounts) itself, so the loop
158
+ * stays open across watch-mode reruns and ends only when the process
159
+ * exits.
160
+ *
161
+ * @internal
162
+ */
163
+ const subscribeLiveInk = (channel) => {
164
+ const live = createLiveInk();
165
+ Effect.runFork(Effect.scoped(Effect.gen(function* () {
166
+ const dequeue = yield* PubSub.subscribe(channel);
167
+ yield* Effect.forever(Queue.take(dequeue).pipe(Effect.flatMap((event) => Effect.sync(() => live.event(event)))));
168
+ })));
169
+ };
170
+ /**
171
+ * The default reporter factory.
172
+ *
173
+ * The plugin uses this as its built-in when no user `reporter` option
174
+ * is supplied, and a custom-reporter author reads it as the canonical
175
+ * worked example of the `VitestAgentReporterFactory` contract.
176
+ *
177
+ * The factory is invoked once at run start with the run-start kit. In
178
+ * `consoleMode: "stream"` it subscribes a live Ink mount to the kit's
179
+ * run-event channel and owns that mount's lifecycle end to end.
180
+ *
181
+ * The `render` call (invoked once at run end with the health-aware kit)
182
+ * assembles the reduced state, classifies the shape and outcome, and
183
+ * dispatches to the matching cell. Output is one stdout entry carrying
184
+ * the cell's string. When `kit.config.githubActions` is true a GFM
185
+ * step-summary payload is appended for routing to GITHUB_STEP_SUMMARY.
186
+ * In `stream` mode `render` emits nothing — the live mount painted the run.
187
+ *
188
+ * @public
189
+ */
190
+ const DefaultVitestAgentReporter = (kit) => {
191
+ if (kit.config.consoleMode === "stream" && kit.runEvents !== void 0) subscribeLiveInk(kit.runEvents);
192
+ return { render(input, renderKit) {
193
+ const out = [];
194
+ if (shouldRenderForMode(renderKit.config.consoleMode)) {
195
+ const content = dispatch(buildDispatchInputs(reduceRenderStateAll(input.reports.flatMap((r) => synthesizeFromAgentReport(r))), input, { runCommand: renderKit.config.runCommand ?? null }), resolveCellOptions(renderKit));
196
+ if (content.length > 0) out.push({
197
+ target: "stdout",
198
+ content,
199
+ contentType: "text/plain"
200
+ });
201
+ }
202
+ if (renderKit.config.githubActions === true) out.push(...renderGithubSummary(input));
203
+ return out;
204
+ } };
205
+ };
206
+
207
+ //#endregion
208
+ export { DefaultVitestAgentReporter, buildDispatchInputs, renderAgentStringForReport, renderHumanStringForReport, resolveCellOptions };