@vitest-agent/reporter 3.1.3 → 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.
- package/defaultReporter.js +110 -119
- package/githubLog.js +47 -10
- package/index.d.ts +21 -55
- package/index.js +2 -3
- package/liveView.js +96 -0
- package/package.json +9 -5
- package/LiveInkRenderer.js +0 -146
package/defaultReporter.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { renderGithubLog, toDisplayPath } from "./githubLog.js";
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
122
|
-
*
|
|
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
|
-
|
|
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) =>
|
|
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.
|
|
175
|
-
|
|
176
|
-
"
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
330
|
-
*
|
|
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
|
-
|
|
343
|
-
return {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
content
|
|
354
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
51
|
-
if (kit.config.dbPath !== void 0)
|
|
52
|
-
|
|
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:
|
|
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
|
|
998
|
-
*
|
|
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
|
|
1016
|
-
*
|
|
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 {
|
|
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 = "
|
|
11
|
+
const CURRENT_REPORTER_VERSION = "4.0.0";
|
|
13
12
|
|
|
14
13
|
//#endregion
|
|
15
|
-
export { CURRENT_REPORTER_VERSION, DefaultVitestAgentReporter,
|
|
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
|
+
"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
|
|
43
|
-
"@vitest-agent/sdk": "5.
|
|
44
|
-
"@vitest-agent/ui": "
|
|
45
|
-
"effect": "4.0.0
|
|
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"
|
package/LiveInkRenderer.js
DELETED
|
@@ -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 };
|