@vitest-agent/reporter 3.1.2 → 4.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.
@@ -1,8 +1,11 @@
1
1
  import { renderGithubLog, toDisplayPath } from "./githubLog.js";
2
- import { createLiveInk } from "./LiveInkRenderer.js";
2
+ import { LiveViewEnv, startLiveView } from "./liveView.js";
3
+ import { Doc, Render, Status } from "@effected/cli";
4
+ import { CliUi, UiProvider } from "@effected/cli/ui";
3
5
  import { RUN_REPORT_FILE_SCHEMA_URL, countSuiteFailures, isTimeoutError } from "@vitest-agent/sdk";
4
6
  import { classifyOutcome, classifyRunShape, dispatch, dispatcherTable, reduceRenderStateAll, synthesizeFromAgentReport } from "@vitest-agent/ui";
5
- import { Effect, PubSub } from "effect";
7
+ import { Effect } from "effect";
8
+ import { createElement } from "react";
6
9
 
7
10
  //#region src/defaultReporter.ts
8
11
  const countTimeouts = (report) => {
@@ -118,8 +121,10 @@ const renderAgentStringForReport = (report) => {
118
121
  };
119
122
  /**
120
123
  * Same as {@link renderAgentStringForReport} but returns the Ink-half
121
- * rendered to a string via Ink's `renderToString`. ANSI escape
122
- * sequences are preserved so a terminal renders the colors live.
124
+ * rendered to a string via Ink's `renderToString`, inside the kit's
125
+ * `UiProvider` (the components draw the terminal's glyph set, ASCII under
126
+ * `TERM=dumb`). ANSI escape sequences are preserved so a terminal renders
127
+ * the colors live.
123
128
  * Returns the agent-string fallback when the matched cell has no Ink
124
129
  * half.
125
130
  *
@@ -145,7 +150,9 @@ const renderHumanStringForReport = async (report, options = {}) => {
145
150
  };
146
151
  const cell = dispatcherTable[inputs.shape][inputs.outcome];
147
152
  if (cell.ink === void 0) return dispatch(inputs, opts);
148
- return renderToString(cell.ink(inputs, opts), { columns: options.width ?? 80 });
153
+ const columns = options.width ?? 80;
154
+ const context = await Effect.runPromise(CliUi.context.pipe(Effect.provide(LiveViewEnv)));
155
+ return renderToString(createElement(UiProvider, { value: context }, cell.ink(inputs, opts)), { columns });
149
156
  };
150
157
  const NON_STABLE_SUMMARY_CLASSIFICATIONS = [
151
158
  "new-failure",
@@ -154,52 +161,51 @@ const NON_STABLE_SUMMARY_CLASSIFICATIONS = [
154
161
  "recovered"
155
162
  ];
156
163
  const MAX_SUMMARY_COVERAGE_ROWS = 10;
164
+ /**
165
+ * The step summary and `summary.md` are files, not log lines, so their
166
+ * markdown context opts out of the `ci` default that neutralizes workflow
167
+ * commands.
168
+ */
169
+ const summaryRenderContext = Render.contextOf({
170
+ audience: "ci",
171
+ displayPath: toDisplayPath,
172
+ neutralizeWorkflowCommands: false
173
+ });
157
174
  const renderClassificationsSection = (classifications) => {
158
175
  const counts = /* @__PURE__ */ new Map();
159
176
  for (const classification of classifications.values()) counts.set(classification, (counts.get(classification) ?? 0) + 1);
160
- const rows = NON_STABLE_SUMMARY_CLASSIFICATIONS.filter((kind) => (counts.get(kind) ?? 0) > 0).map((kind) => `| ${kind} | ${counts.get(kind)} |`);
177
+ const rows = NON_STABLE_SUMMARY_CLASSIFICATIONS.filter((kind) => (counts.get(kind) ?? 0) > 0).map((kind) => [kind, String(counts.get(kind))]);
161
178
  if (rows.length === 0) return null;
162
- return [
163
- "### Classifications",
164
- "",
165
- "| Classification | Count |",
166
- "| --- | --- |",
167
- ...rows
168
- ].join("\n");
179
+ return Render.markdown([Doc.heading(3, "Classifications"), Doc.table([{ header: "Classification" }, { header: "Count" }], rows)], summaryRenderContext);
169
180
  };
170
181
  const formatCoveragePercent = (totals) => `stmts ${totals.statements}%, branches ${totals.branches}%, funcs ${totals.functions}%, lines ${totals.lines}%`;
171
182
  const renderCoverageSection = (reports) => {
172
183
  const belowTarget = reports.flatMap((r) => r.coverage?.belowTarget ?? []);
173
184
  if (belowTarget.length === 0) return null;
174
- const rows = belowTarget.slice(0, MAX_SUMMARY_COVERAGE_ROWS).map((f) => `| ${toDisplayPath(f.file)} | ${formatCoveragePercent(f.summary)} |`);
175
- const lines = [
176
- "### Coverage",
177
- "",
178
- `${belowTarget.length} file(s) below target.`,
179
- "",
180
- "| File | Coverage |",
181
- "| --- | --- |",
182
- ...rows
183
- ];
184
- if (belowTarget.length > MAX_SUMMARY_COVERAGE_ROWS) lines.push("", `(+${belowTarget.length - MAX_SUMMARY_COVERAGE_ROWS} more not shown)`);
185
- return lines.join("\n");
185
+ const rows = belowTarget.map((f) => [Doc.file(f.file), formatCoveragePercent(f.summary)]);
186
+ return Render.markdown([
187
+ Doc.heading(3, "Coverage"),
188
+ Doc.paragraph(`${belowTarget.length} file(s) below target.`),
189
+ Doc.table([{ header: "File" }, { header: "Coverage" }], rows, {
190
+ cap: MAX_SUMMARY_COVERAGE_ROWS,
191
+ overflow: (hidden) => `(+${hidden} more not shown)`
192
+ })
193
+ ], summaryRenderContext);
186
194
  };
195
+ /**
196
+ * The trend as one line per fact. `Doc.lines` joins them with GFM hard breaks
197
+ * (a trailing `\\`), so a GFM reader cannot collapse them into one paragraph.
198
+ */
187
199
  const renderTrendSection = (trendSummary) => {
188
200
  if (trendSummary === void 0) return null;
189
- const lines = [
190
- "### Trend",
191
- "",
192
- `Direction: ${trendSummary.direction}`,
193
- `Run count: ${trendSummary.runCount}`
194
- ];
201
+ const lines = [`Direction: ${trendSummary.direction}`, `Run count: ${trendSummary.runCount}`];
195
202
  const firstMetric = trendSummary.firstMetric;
196
203
  if (firstMetric !== void 0) {
197
204
  const targetSuffix = firstMetric.target !== void 0 ? ` (target: ${firstMetric.target})` : "";
198
205
  lines.push(`${firstMetric.name}: ${firstMetric.from} → ${firstMetric.to}${targetSuffix}`);
199
206
  }
200
- return lines.join("\n");
207
+ return Render.markdown([Doc.heading(3, "Trend"), Doc.lines(lines)], summaryRenderContext);
201
208
  };
202
- const formatSummaryDuration = (ms) => ms >= 1e3 ? `${(ms / 1e3).toFixed(1)}s` : `${Math.round(ms)}ms`;
203
209
  /**
204
210
  * Per-project pass/fail/timeout/skip/duration table — the unconditional
205
211
  * half of the summary body.
@@ -219,35 +225,36 @@ const formatSummaryDuration = (ms) => ms >= 1e3 ? `${(ms / 1e3).toFixed(1)}s` :
219
225
  * would show a timed-out test as `Failed: 1` while every console surface
220
226
  * says `0 failed, 1 timed out`.
221
227
  *
228
+ * A plain `Doc.table`, not `Doc.countsTable`: the counts table heads its
229
+ * label column with nothing (this one reads `Project`) and has no column for
230
+ * a formatted, non-count cell like the duration.
231
+ *
222
232
  * @internal
223
233
  */
224
234
  const renderTotalsSection = (reports) => {
225
235
  const rows = reports.map((report) => summarizeProject(report));
226
- const cells = (name, passed, failed, timedOut, skipped, duration) => `| ${name} | ${passed} | ${failed} | ${timedOut} | ${skipped} | ${formatSummaryDuration(duration)} |`;
227
- const lines = [
228
- "### Totals",
229
- "",
230
- "| Project | Passed | Failed | Timed out | Skipped | Duration |",
231
- "| --- | --- | --- | --- | --- | --- |",
232
- ...rows.map((r) => cells(r.name, r.passCount, r.failCount, r.timeoutCount ?? 0, r.skipCount, r.durationMs))
233
- ];
234
- if (rows.length > 1) {
235
- const total = rows.reduce((acc, r) => ({
236
- passed: acc.passed + r.passCount,
237
- failed: acc.failed + r.failCount,
238
- timedOut: acc.timedOut + (r.timeoutCount ?? 0),
239
- skipped: acc.skipped + r.skipCount,
240
- duration: acc.duration + r.durationMs
241
- }), {
242
- passed: 0,
243
- failed: 0,
244
- timedOut: 0,
245
- skipped: 0,
246
- duration: 0
247
- });
248
- lines.push(cells("**Total**", total.passed, total.failed, total.timedOut, total.skipped, total.duration));
249
- }
250
- return lines.join("\n");
236
+ const counter = (status, key, label, n) => Doc.counter(Status.core, status, {
237
+ key,
238
+ label,
239
+ n,
240
+ showZero: true
241
+ });
242
+ const tableRows = rows.map((r) => ({
243
+ label: r.name,
244
+ counters: [
245
+ counter("success", "passed", "Passed", r.passCount),
246
+ counter("failure", "failed", "Failed", r.failCount),
247
+ counter("warning", "timedOut", "Timed out", r.timeoutCount ?? 0),
248
+ counter("skip", "skipped", "Skipped", r.skipCount)
249
+ ],
250
+ durationMs: r.durationMs
251
+ }));
252
+ const table = Doc.countsTable(tableRows, {
253
+ labelHeader: "Project",
254
+ durationHeader: "Duration",
255
+ ...rows.length > 1 ? { totalRow: Doc.strong("Total") } : {}
256
+ });
257
+ return Render.markdown([Doc.heading(3, "Totals"), table], summaryRenderContext);
251
258
  };
252
259
  /**
253
260
  * Assemble the vitest-agent markdown body shared by the GitHub step
@@ -296,29 +303,6 @@ const renderGithubSummary = (input) => {
296
303
  }];
297
304
  };
298
305
  /**
299
- * Subscribe a live Ink mount to the kit's run-event channel.
300
- *
301
- * Called from the factory when `consoleMode` is `stream`. The factory runs
302
- * at run start — before the plugin publishes the first `RunStarted`
303
- * event — so the subscription is registered in time. `Effect.runFork`
304
- * advances the forked fiber up to its first suspension (the
305
- * `PubSub.take` below); that suspension point is past `PubSub.subscribe`,
306
- * so the subscription is live before this function returns. The drain
307
- * loop runs forever: `createLiveInk` handles `RunFinished` (schedules
308
- * unmount) and a subsequent `RunStarted` (remounts) itself, so the loop
309
- * stays open across watch-mode reruns and ends only when the process
310
- * exits.
311
- *
312
- * @internal
313
- */
314
- const subscribeLiveInk = (channel) => {
315
- const live = createLiveInk();
316
- Effect.runFork(Effect.scoped(Effect.gen(function* () {
317
- const subscription = yield* PubSub.subscribe(channel);
318
- yield* Effect.forever(PubSub.take(subscription).pipe(Effect.flatMap((event) => Effect.sync(() => live.event(event)))));
319
- })));
320
- };
321
- /**
322
306
  * The default reporter factory.
323
307
  *
324
308
  * The plugin uses this as its built-in when no user `reporter` option
@@ -326,8 +310,12 @@ const subscribeLiveInk = (channel) => {
326
310
  * worked example of the `VitestAgentReporterFactory` contract.
327
311
  *
328
312
  * The factory is invoked once at run start with the run-start kit. In
329
- * `consoleMode: "stream"` it subscribes a live Ink mount to the kit's
330
- * run-event channel and owns that mount's lifecycle end to end.
313
+ * `consoleMode: "stream"` it starts a live view on the kit's run-event
314
+ * channel (subscribed before the factory returns, so the first
315
+ * `RunStarted` is seen) and owns it for the reporter's life: the
316
+ * reporter's `close`, which the plugin calls at Vitest's close before
317
+ * shutting the channel down, drains the queued events, waits for the last
318
+ * frame and closes it.
331
319
  *
332
320
  * The `render` call (invoked once at run end with the health-aware kit)
333
321
  * assembles the reduced state, classifies the shape and outcome, and
@@ -339,44 +327,47 @@ const subscribeLiveInk = (channel) => {
339
327
  * @public
340
328
  */
341
329
  const DefaultVitestAgentReporter = (kit) => {
342
- if (kit.config.consoleMode === "stream" && kit.runEvents !== void 0) subscribeLiveInk(kit.runEvents);
343
- return { render(input, renderKit) {
344
- const out = [];
345
- if (shouldRenderForMode(renderKit.config.consoleMode)) {
346
- const events = input.reports.flatMap((r) => synthesizeFromAgentReport(r));
347
- const state = reduceRenderStateAll(events);
348
- const inputs = buildDispatchInputs(state, input, { runCommand: renderKit.config.runCommand ?? null });
349
- const opts = resolveCellOptions(renderKit);
350
- const content = dispatch(inputs, opts);
351
- if (content.length > 0) out.push({
352
- target: "stdout",
353
- content,
354
- contentType: "text/plain"
330
+ const live = kit.config.consoleMode === "stream" && kit.runEvents !== void 0 ? startLiveView(kit.runEvents) : void 0;
331
+ return {
332
+ ...live !== void 0 ? { close: live.close } : {},
333
+ render(input, renderKit) {
334
+ const out = [];
335
+ if (shouldRenderForMode(renderKit.config.consoleMode)) {
336
+ const events = input.reports.flatMap((r) => synthesizeFromAgentReport(r));
337
+ const state = reduceRenderStateAll(events);
338
+ const inputs = buildDispatchInputs(state, input, { runCommand: renderKit.config.runCommand ?? null });
339
+ const opts = resolveCellOptions(renderKit);
340
+ const content = dispatch(inputs, opts);
341
+ if (content.length > 0) out.push({
342
+ target: "stdout",
343
+ content,
344
+ contentType: "text/plain"
345
+ });
346
+ }
347
+ if (renderKit.config.githubActions === true) {
348
+ out.push(...renderGithubSummary(input));
349
+ out.push(renderGithubLog(input, renderKit));
350
+ }
351
+ out.push({
352
+ target: "report",
353
+ filename: "run.json",
354
+ contentType: "application/json",
355
+ content: `${JSON.stringify({
356
+ $schema: RUN_REPORT_FILE_SCHEMA_URL,
357
+ schemaVersion: 1,
358
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
359
+ reports: input.reports
360
+ }, null, 2)}\n`
355
361
  });
362
+ out.push({
363
+ target: "report",
364
+ filename: "summary.md",
365
+ contentType: "text/markdown",
366
+ content: `${buildSummaryMarkdown(input)}\n`
367
+ });
368
+ return out;
356
369
  }
357
- if (renderKit.config.githubActions === true) {
358
- out.push(...renderGithubSummary(input));
359
- out.push(renderGithubLog(input, renderKit));
360
- }
361
- out.push({
362
- target: "report",
363
- filename: "run.json",
364
- contentType: "application/json",
365
- content: `${JSON.stringify({
366
- $schema: RUN_REPORT_FILE_SCHEMA_URL,
367
- schemaVersion: 1,
368
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
369
- reports: input.reports
370
- }, null, 2)}\n`
371
- });
372
- out.push({
373
- target: "report",
374
- filename: "summary.md",
375
- contentType: "text/markdown",
376
- content: `${buildSummaryMarkdown(input)}\n`
377
- });
378
- return out;
379
- } };
370
+ };
380
371
  };
381
372
 
382
373
  //#endregion
package/githubLog.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Doc, Render, Status } from "@effected/cli";
1
2
  import { relative } from "node:path";
2
3
 
3
4
  //#region src/githubLog.ts
@@ -12,6 +13,14 @@ import { relative } from "node:path";
12
13
  * fields already on `ReporterRenderInput` / `ReporterKit` — no new
13
14
  * plumbing. No ANSI escapes: GitHub renders raw log text.
14
15
  *
16
+ * Built as an `@effected/cli` `Doc` (a top-level collapsible is a
17
+ * `::group::`) and rendered with `Render.githubLog`, which also neutralizes
18
+ * any workflow command a project name or path could otherwise inject.
19
+ *
20
+ * Known inconsistency, kept deliberately for now: the per-project line counts
21
+ * a timed-out test as `failed` (it reads `report.summary.failed`), while the
22
+ * step-summary totals table splits timeouts into their own column.
23
+ *
15
24
  * @internal
16
25
  */
17
26
  const MAX_NAMED_FILES = 3;
@@ -32,27 +41,55 @@ const NON_STABLE_CLASSIFICATIONS = [
32
41
  "flaky",
33
42
  "recovered"
34
43
  ];
44
+ /** `<project>: <passed>/<total> passed, <failed> failed, <skipped> skipped` — every counter shown, zeros included. */
45
+ const projectCounts = (report) => {
46
+ const { passed, failed, skipped, total } = report.summary;
47
+ return Doc.counts({
48
+ label: report.project ?? "default",
49
+ layout: "inline",
50
+ total: () => total,
51
+ counters: [
52
+ Doc.counter(Status.core, "success", {
53
+ key: "passed",
54
+ label: "passed",
55
+ n: passed,
56
+ showZero: true
57
+ }),
58
+ Doc.counter(Status.core, "failure", {
59
+ key: "failed",
60
+ label: "failed",
61
+ n: failed,
62
+ showZero: true
63
+ }),
64
+ Doc.counter(Status.core, "skip", {
65
+ key: "skipped",
66
+ label: "skipped",
67
+ n: skipped,
68
+ showZero: true
69
+ })
70
+ ]
71
+ });
72
+ };
35
73
  function renderGithubLog(input, kit) {
36
- const lines = ["::group::vitest-agent"];
37
- for (const report of input.reports) {
38
- const name = report.project ?? "default";
39
- lines.push(`${name}: ${report.summary.passed}/${report.summary.total} passed, ${report.summary.failed} failed, ${report.summary.skipped} skipped`);
40
- }
74
+ const body = input.reports.map(projectCounts);
41
75
  const belowTarget = input.reports.flatMap((r) => r.coverage?.belowTarget ?? []);
42
76
  if (belowTarget.length > 0) {
43
77
  const names = belowTarget.slice(0, MAX_NAMED_FILES).map((f) => toDisplayPath(f.file));
44
78
  const suffix = belowTarget.length > MAX_NAMED_FILES ? `, +${belowTarget.length - MAX_NAMED_FILES} more` : "";
45
- lines.push(`coverage: ${belowTarget.length} file(s) below target (${names.join(", ")}${suffix})`);
79
+ body.push(Doc.paragraph(`coverage: ${belowTarget.length} file(s) below target (${names.join(", ")}${suffix})`));
46
80
  }
47
81
  const classificationCounts = /* @__PURE__ */ new Map();
48
82
  for (const classification of input.classifications.values()) classificationCounts.set(classification, (classificationCounts.get(classification) ?? 0) + 1);
49
83
  const classificationParts = NON_STABLE_CLASSIFICATIONS.filter((kind) => (classificationCounts.get(kind) ?? 0) > 0).map((kind) => `${kind}: ${classificationCounts.get(kind)}`);
50
- if (classificationParts.length > 0) lines.push(`classifications: ${classificationParts.join(", ")}`);
51
- if (kit.config.dbPath !== void 0) lines.push(`db: ${kit.config.dbPath}`);
52
- lines.push("::endgroup::");
84
+ if (classificationParts.length > 0) body.push(Doc.paragraph(`classifications: ${classificationParts.join(", ")}`));
85
+ if (kit.config.dbPath !== void 0) body.push(Doc.paragraph(`db: ${kit.config.dbPath}`));
86
+ const ctx = Render.contextOf({
87
+ audience: "ci",
88
+ displayPath: toDisplayPath
89
+ });
53
90
  return {
54
91
  target: "stdout",
55
- content: lines.join("\n"),
92
+ content: Render.githubLog([Doc.collapsible("vitest-agent", body)], ctx),
56
93
  contentType: "text/plain"
57
94
  };
58
95
  }
package/index.d.ts CHANGED
@@ -938,6 +938,16 @@ interface ReporterRenderInput {
938
938
  */
939
939
  interface VitestAgentReporter {
940
940
  readonly render: (input: ReporterRenderInput, kit: ReporterKit) => ReadonlyArray<RenderedOutput>;
941
+ /**
942
+ * Release what the factory acquired, once, when Vitest closes (never per
943
+ * run: watch mode reruns `render` many times on one reporter). The plugin
944
+ * calls it before it shuts the {@link ReporterKit.runEvents | run-event
945
+ * channel} down, so a subscriber can still drain what was published (a
946
+ * shutdown drops what a subscriber has not pulled) and end its own stream;
947
+ * a live view waits here for its last frame to commit. Optional; the plugin
948
+ * awaits it and treats a rejection as a logged warning.
949
+ */
950
+ readonly close?: () => Promise<void>;
941
951
  }
942
952
  /**
943
953
  * Factory that the plugin's `reporter` option accepts. The plugin calls
@@ -994,8 +1004,10 @@ export declare const resolveCellOptions: (kit: ReporterKit) => CellOptions;
994
1004
  export declare const renderAgentStringForReport: (report: AgentReport) => string;
995
1005
  /**
996
1006
  * Same as {@link renderAgentStringForReport} but returns the Ink-half
997
- * rendered to a string via Ink's `renderToString`. ANSI escape
998
- * sequences are preserved so a terminal renders the colors live.
1007
+ * rendered to a string via Ink's `renderToString`, inside the kit's
1008
+ * `UiProvider` (the components draw the terminal's glyph set, ASCII under
1009
+ * `TERM=dumb`). ANSI escape sequences are preserved so a terminal renders
1010
+ * the colors live.
999
1011
  * Returns the agent-string fallback when the matched cell has no Ink
1000
1012
  * half.
1001
1013
  *
@@ -1012,8 +1024,12 @@ export declare const renderHumanStringForReport: (report: AgentReport, options?:
1012
1024
  * worked example of the `VitestAgentReporterFactory` contract.
1013
1025
  *
1014
1026
  * The factory is invoked once at run start with the run-start kit. In
1015
- * `consoleMode: "stream"` it subscribes a live Ink mount to the kit's
1016
- * run-event channel and owns that mount's lifecycle end to end.
1027
+ * `consoleMode: "stream"` it starts a live view on the kit's run-event
1028
+ * channel (subscribed before the factory returns, so the first
1029
+ * `RunStarted` is seen) and owns it for the reporter's life: the
1030
+ * reporter's `close`, which the plugin calls at Vitest's close before
1031
+ * shutting the channel down, drains the queued events, waits for the last
1032
+ * frame and closes it.
1017
1033
  *
1018
1034
  * The `render` call (invoked once at run end with the health-aware kit)
1019
1035
  * assembles the reduced state, classifies the shape and outcome, and
@@ -1026,56 +1042,6 @@ export declare const renderHumanStringForReport: (report: AgentReport, options?:
1026
1042
  */
1027
1043
  export declare const DefaultVitestAgentReporter: VitestAgentReporterFactory;
1028
1044
  //#endregion
1029
- //#region src/LiveInkRenderer.d.ts
1030
- /**
1031
- * Options for `createLiveInk`.
1032
- *
1033
- * @public
1034
- */
1035
- interface CreateLiveInkOptions {
1036
- /**
1037
- * Override the stream Ink writes to. Defaults to `process.stdout`.
1038
- * Tests pass a captured Writable here to assert on rendered frames.
1039
- */
1040
- readonly stream?: NodeJS.WriteStream;
1041
- }
1042
- /**
1043
- * Imperative handle returned by `createLiveInk`. Drives a live
1044
- * Ink mount across the lifetime of a test run, including watch-mode reruns.
1045
- *
1046
- * @public
1047
- */
1048
- interface LiveInkRenderer {
1049
- /**
1050
- * Process a `RunEvent`. Mounts the Ink tree and starts the
1051
- * animation clock on `RunStarted`, rerenders on every event that
1052
- * mutates state, and unmounts on the terminal event so Ink commits
1053
- * the final frame to scrollback.
1054
- */
1055
- readonly event: (event: RunEvent) => void;
1056
- /**
1057
- * Force an immediate unmount and stop the animation clock. Safe to
1058
- * call multiple times. Useful in tests or when the host needs to
1059
- * tear down out of band (e.g. a cancellation signal).
1060
- */
1061
- readonly unmount: () => void;
1062
- /**
1063
- * Latest reduced state. Exposed for hosts that want to assert on the
1064
- * accumulated value at any point during the run.
1065
- */
1066
- readonly snapshot: () => RenderState;
1067
- }
1068
- /**
1069
- * Create a {@link LiveInkRenderer} that drives a live Ink mount for
1070
- * `consoleMode: "stream"`. Call `event(e)` for each `RunEvent` published
1071
- * by the plugin; the renderer mounts on `RunStarted`, rerenders on each
1072
- * subsequent event, and commits the final frame on `RunFinished` /
1073
- * `RunTimedOut`.
1074
- *
1075
- * @public
1076
- */
1077
- declare const createLiveInk: (options?: CreateLiveInkOptions) => LiveInkRenderer;
1078
- //#endregion
1079
1045
  //#region src/index.d.ts
1080
1046
  /**
1081
1047
  * The version of this package, inlined at build time from
@@ -1086,5 +1052,5 @@ declare const createLiveInk: (options?: CreateLiveInkOptions) => LiveInkRenderer
1086
1052
  */
1087
1053
  export declare const CURRENT_REPORTER_VERSION: string;
1088
1054
  //#endregion
1089
- export { type AgentReport, type CellOptions, type ConsoleMode, type CreateLiveInkOptions, type DetailLevel, type DispatchInputs, type Environment, type Executor, type FileCoverageReport, type LiveInkRenderer, type OutputFormat, type ProjectSummary, type RenderState, type RenderedOutput, type ReporterKit, type ReporterRenderInput, type ResolvedReporterConfig, type ResolvedThresholds, type RunEvent, type RunOutcome, type RunShape, type TestClassification, type Transport, type TrendSummary, type VitestAgentReporter, type VitestAgentReporterFactory, createLiveInk as _createLiveInk };
1055
+ export type { AgentReport, CellOptions, ConsoleMode, DetailLevel, DispatchInputs, Environment, Executor, FileCoverageReport, OutputFormat, ProjectSummary, RenderState, RenderedOutput, ReporterKit, ReporterRenderInput, ResolvedReporterConfig, ResolvedThresholds, RunEvent, RunOutcome, RunShape, TestClassification, Transport, TrendSummary, VitestAgentReporter, VitestAgentReporterFactory };
1090
1056
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import { createLiveInk } from "./LiveInkRenderer.js";
2
1
  import { DefaultVitestAgentReporter, buildDispatchInputs, renderAgentStringForReport, renderHumanStringForReport, resolveCellOptions } from "./defaultReporter.js";
3
2
 
4
3
  //#region src/index.ts
@@ -9,7 +8,7 @@ import { DefaultVitestAgentReporter, buildDispatchInputs, renderAgentStringForRe
9
8
  *
10
9
  * @public
11
10
  */
12
- const CURRENT_REPORTER_VERSION = "3.1.2";
11
+ const CURRENT_REPORTER_VERSION = "4.0.0";
13
12
 
14
13
  //#endregion
15
- export { CURRENT_REPORTER_VERSION, DefaultVitestAgentReporter, createLiveInk as _createLiveInk, buildDispatchInputs, renderAgentStringForReport, renderHumanStringForReport, resolveCellOptions };
14
+ export { CURRENT_REPORTER_VERSION, DefaultVitestAgentReporter, buildDispatchInputs, renderAgentStringForReport, renderHumanStringForReport, resolveCellOptions };
package/liveView.js ADDED
@@ -0,0 +1,96 @@
1
+ import { CliEnv } from "@effected/cli";
2
+ import { CliUi } from "@effected/cli/ui";
3
+ import { initialRenderState } from "@vitest-agent/sdk";
4
+ import { SPINNER_FRAME_MS, StreamApp, reduceRenderState } from "@vitest-agent/ui";
5
+ import { Cause, Deferred, Effect, Exit, Layer, PubSub, Scope } from "effect";
6
+ import { createElement } from "react";
7
+ import * as NodeServices from "@effect/platform-node/NodeServices";
8
+
9
+ //#region src/liveView.ts
10
+ /**
11
+ * The `stream` console mode's live view: the plugin's run-event channel
12
+ * folded through `reduceRenderState` and drawn as {@link StreamApp} by the
13
+ * kit's `CliUi.live`.
14
+ *
15
+ * The kit owns the mount: a run mounts at `RunStarted` (or where `begins`
16
+ * says), redraws on every event and on an 80 ms tick, and commits its final
17
+ * frame to scrollback at the terminal event (`RunFinished` /
18
+ * `RunTimedOut`), never clearing the screen. The next run in watch mode
19
+ * mounts afresh below it. When the run is not interactive (piped, an agent
20
+ * audience, CI) nothing is mounted and, in the `owned` mode, each run's final
21
+ * frame is written once to stdout as a string, so a `vitest run | cat` still
22
+ * receives the result.
23
+ *
24
+ * Lifetime: one scope per live view, held for the reporter's whole life. The
25
+ * subscription is made in it synchronously, before the factory returns, so
26
+ * the first `RunStarted` is never lost, and the view takes from it directly
27
+ * (`events: subscription`). At Vitest's close the plugin calls
28
+ * {@link LiveRunView.close} before it shuts the channel down: close runs the
29
+ * kit's `LiveHandle.close` (it folds every message still queued in the
30
+ * subscription and commits the last run, so a tail published just before the
31
+ * close is kept), then closes the scope. The view never relies on
32
+ * `PubSub.shutdown` to end it: shutdown drops whatever a subscriber has not
33
+ * taken yet. It is never closed at `onTestRunEnd`, which fires on every
34
+ * watch rerun.
35
+ */
36
+ /**
37
+ * The live view's options, everything but `events`: what the reporter hands
38
+ * `CliUi.live`, and what the tests hand `CliUiTest.live`, so both run the
39
+ * same fold, drawing and run predicates.
40
+ *
41
+ * @internal
42
+ */
43
+ const liveViewOptions = {
44
+ initial: initialRenderState,
45
+ reduce: reduceRenderState,
46
+ render: (state, frame) => createElement(StreamApp, {
47
+ state,
48
+ frameIndex: frame,
49
+ nowMs: frame * SPINNER_FRAME_MS
50
+ }),
51
+ isStart: (event) => event._tag === "RunStarted",
52
+ isTerminal: (event) => event._tag === "RunFinished" || event._tag === "RunTimedOut",
53
+ begins: (event, before, after) => event._tag === "RunStarted" || before.phase === "idle" && after.phase !== "idle",
54
+ mode: "owned",
55
+ tickMillis: SPINNER_FRAME_MS
56
+ };
57
+ /**
58
+ * The environment a Vitest-hosted live view reads: the kit's `CliEnv` over
59
+ * Node's stdio and terminal. It builds `CliTheme` (colour from the terminal,
60
+ * glyphs `auto`, so `TERM=dumb` draws ASCII) and sets `CliInteractive` (a
61
+ * human audience with a TTY on stdin and stdout).
62
+ *
63
+ * @internal
64
+ */
65
+ const LiveViewEnv = CliEnv.layer().pipe(Layer.provide(NodeServices.layer));
66
+ /**
67
+ * Start a live view over `channel`. The subscription is taken before this
68
+ * returns, so an event published right after is seen.
69
+ *
70
+ * @param channel - the plugin's run-event channel
71
+ * @param env - the environment the view reads; {@link LiveViewEnv} by default (a test
72
+ * merges `UiStreams` / `CliInteractive` into a fixed theme instead)
73
+ *
74
+ * @internal
75
+ */
76
+ const startLiveView = (channel, env = LiveViewEnv) => {
77
+ const scope = Effect.runSync(Scope.make());
78
+ const subscription = Effect.runSync(PubSub.subscribe(channel).pipe(Scope.provide(scope)));
79
+ const mounted = Effect.runSync(Deferred.make());
80
+ const program = CliUi.live({
81
+ ...liveViewOptions,
82
+ events: subscription
83
+ }).pipe(Effect.onExit((exit) => Deferred.done(mounted, exit)), Effect.flatMap((handle) => handle.done), Scope.provide(scope), Effect.provide(env));
84
+ Effect.runFork(program);
85
+ let closing;
86
+ return {
87
+ handle: Effect.runPromise(Deferred.await(mounted)),
88
+ close: () => {
89
+ closing ??= Effect.runPromise(Deferred.await(mounted).pipe(Effect.flatMap((handle) => handle.close), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("vitest-agent: the live view ended with an error", cause)), Effect.ensuring(Scope.close(scope, Exit.void))));
90
+ return closing;
91
+ }
92
+ };
93
+ };
94
+
95
+ //#endregion
96
+ export { LiveViewEnv, liveViewOptions, startLiveView };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/reporter",
3
- "version": "3.1.2",
3
+ "version": "4.0.0",
4
4
  "private": false,
5
5
  "description": "Named VitestAgentReporterFactory implementations for the vitest-agent ecosystem.",
6
6
  "keywords": [
@@ -39,14 +39,18 @@
39
39
  "./package.json": "./package.json"
40
40
  },
41
41
  "dependencies": {
42
- "@effect/platform-node": "4.0.0-rc.117",
43
- "@vitest-agent/sdk": "5.1.2",
44
- "@vitest-agent/ui": "2.5.2",
45
- "effect": "4.0.0-rc.117",
42
+ "@effect/platform-node": "^4.0.0",
43
+ "@vitest-agent/sdk": "5.2.0",
44
+ "@vitest-agent/ui": "3.0.0",
45
+ "effect": "^4.0.0",
46
46
  "ink": "^7.1.1",
47
47
  "react": "^19.3.0"
48
48
  },
49
49
  "peerDependencies": {
50
+ "@effected/cli": "^0.11.0",
51
+ "@effected/env": "^0.1.0",
52
+ "@effected/glob": "^0.10.0",
53
+ "@effected/walker": "^0.15.0",
50
54
  "@vitest/coverage-istanbul": "^5.0.0",
51
55
  "@vitest/coverage-v8": "^5.0.0",
52
56
  "vitest": "^5.0.0"
@@ -1,146 +0,0 @@
1
- import { initialRenderState } from "@vitest-agent/sdk";
2
- import { SPINNER_FRAME_MS, StreamApp, reduceRenderState, spinnerFrameForTime } from "@vitest-agent/ui";
3
- import { Box, render, renderToString } from "ink";
4
- import { createElement } from "react";
5
-
6
- //#region src/LiveInkRenderer.tsx
7
- /**
8
- * Drain the global user-timing buffer after a render.
9
- *
10
- * React 19's *development* reconciler — the build Ink loads whenever
11
- * `NODE_ENV !== "production"`, which is the norm under Vitest — emits a
12
- * `performance.measure()` into the global user-timing buffer on every
13
- * component render for its "Component Performance Track" DevTools feature.
14
- * Nothing in a Node process consumes that buffer, so across a long run the
15
- * live mount's per-event and per-spinner-tick rerenders pile up millions of
16
- * `measure` entries until Node prints `MaxPerformanceEntryBufferExceededWarning`
17
- * (issue #97). The renderer owns every render call, so it drains the buffer
18
- * right after each one, bounding it to a single render's worth of entries.
19
- *
20
- * Clearing the whole measure/mark buffer is safe here: this runs in the
21
- * Vitest main process, where test code executes in forked workers (separate
22
- * perf buffers) and nothing downstream reads these entries. Guarded against
23
- * runtimes that lack the user-timing API and never allowed to throw — buffer
24
- * hygiene must not derail a run.
25
- */
26
- const drainRenderPerfEntries = () => {
27
- try {
28
- performance.clearMeasures?.();
29
- performance.clearMarks?.();
30
- } catch {}
31
- };
32
- /**
33
- * Create a {@link LiveInkRenderer} that drives a live Ink mount for
34
- * `consoleMode: "stream"`. Call `event(e)` for each `RunEvent` published
35
- * by the plugin; the renderer mounts on `RunStarted`, rerenders on each
36
- * subsequent event, and commits the final frame on `RunFinished` /
37
- * `RunTimedOut`.
38
- *
39
- * @public
40
- */
41
- const createLiveInk = (options = {}) => {
42
- let state = initialRenderState;
43
- let instance = null;
44
- let clock = null;
45
- let firstRunStarted = false;
46
- const targetStream = options.stream ?? process.stdout;
47
- const frameWidth = () => {
48
- const cols = targetStream.columns;
49
- return typeof cols === "number" && cols > 1 ? cols - 1 : void 0;
50
- };
51
- const frameElement = () => {
52
- const now = Date.now();
53
- return createElement(Box, {
54
- flexDirection: "column",
55
- width: frameWidth()
56
- }, createElement(StreamApp, {
57
- state,
58
- frameIndex: spinnerFrameForTime(now),
59
- nowMs: now
60
- }));
61
- };
62
- const mount = () => {
63
- if (instance !== null) return;
64
- instance = render(frameElement(), { stdout: targetStream });
65
- };
66
- const stopClock = () => {
67
- if (clock === null) return;
68
- clearInterval(clock);
69
- clock = null;
70
- };
71
- const startClock = () => {
72
- if (clock !== null) return;
73
- clock = setInterval(() => {
74
- if (instance === null) return;
75
- try {
76
- instance.rerender(frameElement());
77
- drainRenderPerfEntries();
78
- } catch {
79
- instance = null;
80
- stopClock();
81
- }
82
- }, SPINNER_FRAME_MS);
83
- clock.unref?.();
84
- };
85
- const tearDown = () => {
86
- stopClock();
87
- if (instance === null) return;
88
- instance.unmount();
89
- instance = null;
90
- firstRunStarted = false;
91
- };
92
- return {
93
- event(event) {
94
- const previousPhase = state.phase;
95
- state = reduceRenderState(state, event);
96
- if (event._tag === "RunStarted" || previousPhase === "idle" && state.phase !== "idle") {
97
- if (firstRunStarted) {
98
- if (instance !== null) try {
99
- instance.clear();
100
- } catch {}
101
- } else try {
102
- mount();
103
- } catch (err) {
104
- process.stderr.write(`@vitest-agent/reporter: live ink renderer failed; falling back silently (${err.message})\n`);
105
- instance = null;
106
- }
107
- firstRunStarted = true;
108
- startClock();
109
- } else if (instance !== null) try {
110
- instance.rerender(frameElement());
111
- } catch (err) {
112
- process.stderr.write(`@vitest-agent/reporter: live ink renderer failed; falling back silently (${err.message})\n`);
113
- instance = null;
114
- stopClock();
115
- }
116
- if (event._tag === "RunFinished" || event._tag === "RunTimedOut") {
117
- stopClock();
118
- if (instance !== null) {
119
- try {
120
- instance.unmount();
121
- } catch {}
122
- instance = null;
123
- } else {
124
- const now = Date.now();
125
- const finalFrameText = renderToString(createElement(StreamApp, {
126
- state,
127
- frameIndex: spinnerFrameForTime(now),
128
- nowMs: now
129
- }));
130
- targetStream.write(`${finalFrameText}\n`);
131
- }
132
- firstRunStarted = false;
133
- }
134
- drainRenderPerfEntries();
135
- },
136
- unmount() {
137
- tearDown();
138
- },
139
- snapshot() {
140
- return state;
141
- }
142
- };
143
- };
144
-
145
- //#endregion
146
- export { createLiveInk };