1import { formatPlanPreview } from "./Plan.ts";
2/**
3 * Line-oriented reporter for non-interactive terminals and CI.
4 *
5 * Reports progress through the collection phase (importing a large suite's
6 * files takes tens of seconds before any test can run), then logs one line per
7 * test as it finishes, prefixed with a `[done/total]` counter (failures include
8 * their error and captured output inline); if nothing finishes for 10s it
9 * prints the list of currently-running tests; at the end every failure is
10 * repeated consolidated.
11 */
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14
15import type { LogEntry } from "./Model.ts";
16import {
17 Reporter,
18 type RunSummary,
19 type TestEvent,
20 type TestResult,
21} from "./Reporter.ts";
22import { writeDirect } from "./StrayOutput.ts";
23
24const isTty = process.stdout.isTTY === true;
25
26const useColor = isTty && process.env.NO_COLOR === undefined;
27
28const paint = (code: string) => (text: string) =>
29 useColor ? `\u001B[${code}m${text}\u001B[0m` : text;
30
31const red = paint("31");
32const green = paint("32");
33const yellow = paint("33");
34const dim = paint("2");
35const bold = paint("1");
36
37const formatDuration = (ms: number): string =>
38 ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
39
40// Through the REAL stream — while a run is active, bare process.stdout is
41// diverted into the run log (see StrayOutput.ts).
42const write = (line: string): Effect.Effect<void> =>
43 Effect.sync(() => {
44 writeDirect(`${line}\n`);
45 });
46
47// Captured output is replayed verbatim — no timestamp/level prefixes, no
48// indentation. It should read exactly as it would have on a normal terminal.
49const formatLogs = (logs: ReadonlyArray<LogEntry>): string =>
50 logs.map((log) => log.message).join("\n");
51
52const indent = (text: string, prefix = " "): string =>
53 text
54 .split("\n")
55 .map((line) => `${prefix}${line}`)
56 .join("\n");
57
58/** Mutable per-run reporter state. */
59interface ReporterState {
60 readonly hookLogs: Map<string, ReadonlyArray<LogEntry>>;
61 /**
62 * Currently-executing work items: tests AND file-level hooks. Hooks must
63 * be tracked too — a `beforeAll` deploy can legitimately run for many
64 * minutes with no test starting or finishing, and if the stall report
65 * only covered tests, such a run would print nothing at all and read as
66 * deadlocked.
67 */
68 readonly running: Map<string, { label: string; startedAt: number }>;
69 /** Total tests in the run, known once RunStart arrives. */
70 total: number;
71 /** Tests that have reported a result so far, and how they went. */
72 completed: number;
73 passed: number;
74 failed: number;
75 lastEnd: number;
76 /** Running-set fingerprint of the last stall report — dedupes repeats. */
77 lastStallKey: string;
78 stallTimer: ReturnType<typeof setInterval> | undefined;
79 /** Files discovered for the collection phase. */
80 collectTotal: number;
81 collectDone: number;
82 collectStartedAt: number;
83 /** Last file whose import finished — the next one is what's importing now. */
84 collectCurrent: string;
85 lastCollectRender: number;
86 /** An un-terminated in-place progress line is on the terminal. */
87 collectLinePending: boolean;
88 /** True between CollectStart and RunStart. */
89 collecting: boolean;
90}
91
92/** Redraw cadence of the collection progress line. */
93const COLLECT_RENDER_MS = 100;
94/** Cadence of collection progress LINES when stdout isn't a terminal. */
95const COLLECT_LINE_MS = 5_000;
96
97const clearCollectLine = (state: ReporterState): void => {
98 if (!state.collectLinePending) return;
99 state.collectLinePending = false;
100 writeDirect("\r\u001B[2K");
101};
102
103/**
104 * Importing every test file takes tens of seconds on a large suite, and it
105 * happens before a single test can be reported — without progress the run is
106 * indistinguishable from a deadlock. On a TTY this repaints one line in place;
107 * otherwise it prints a line every few seconds. Either way the current file is
108 * shown, so a genuinely stuck import is identifiable by name.
109 *
110 * Also called from the reporter's 1s timer, not just on each imported file, so
111 * the elapsed time keeps ticking when an import is what's hanging.
112 */
113const renderCollect = (state: ReporterState, force: boolean): void => {
114 const now = Date.now();
115 const cadence = isTty ? COLLECT_RENDER_MS : COLLECT_LINE_MS;
116 if (!force && now - state.lastCollectRender < cadence) return;
117 state.lastCollectRender = now;
118 const head = `collecting ${state.collectDone}/${state.collectTotal} test files (${formatDuration(now - state.collectStartedAt)})`;
119 // A pty that reports 0 columns (e.g. under `script`) is unknown, not narrow.
120 const columns = process.stdout.columns || 120;
121 const room = columns - head.length - 2;
122 const tail =
123 state.collectCurrent !== "" && room > 12
124 ? ` ${state.collectCurrent.slice(-room)}`
125 : "";
126 if (isTty) {
127 state.collectLinePending = true;
128 writeDirect(`\r\u001B[2K${dim(head + tail)}`);
129 } else {
130 writeDirect(`${dim(head + tail)}\n`);
131 }
132};
133
134/** Settle the progress line on the final count so the total cost stays visible. */
135const finishCollect = (state: ReporterState): void => {
136 state.collecting = false;
137 clearCollectLine(state);
138 state.collectCurrent = "";
139 renderCollect(state, true);
140 if (state.collectLinePending) {
141 state.collectLinePending = false;
142 writeDirect("\n");
143 }
144};
145
146/**
147 * `[ 12/240 ✓10 ✗2]` counter prefixed to every result line: how much of the
148 * run is left, and the running pass/fail tally. Every number is right-aligned
149 * on the total's width so the columns after it stay aligned, and each tally is
150 * only colored once it is non-zero (a red `✗0` reads as a failure at a glance).
151 */
152const progress = (
153 state: ReporterState,
154 status: TestResult["status"],
155): string => {
156 state.completed += 1;
157 if (status === "pass") state.passed += 1;
158 else if (status === "fail") state.failed += 1;
159 const total = Math.max(state.total, state.completed);
160 const width = String(total).length;
161 const pad = (n: number) => String(n).padStart(width, " ");
162 const pass = `✓${pad(state.passed)}`;
163 const fail = `✗${pad(state.failed)}`;
164 return [
165 dim(`[${pad(state.completed)}/${total}`),
166 state.passed > 0 ? green(pass) : dim(pass),
167 `${state.failed > 0 ? red(fail) : dim(fail)}${dim("]")}`,
168 ].join(" ");
169};
170
171const STALL_AFTER_MS = 10_000;
172const STALL_LIST_MAX = 25;
173
174/**
175 * Print the currently-running tests when nothing has finished for a while.
176 * Printed AT MOST ONCE per distinct running set: while the exact same tests
177 * stay stuck, nothing is re-printed — the timestamp on the report tells the
178 * reader how long ago it was taken.
179 */
180const printStalled = (state: ReporterState): void => {
181 if (state.running.size === 0) return;
182 if (Date.now() - state.lastEnd < STALL_AFTER_MS) return;
183 const key = [...state.running.keys()].sort().join("\n");
184 if (key === state.lastStallKey) return;
185 state.lastStallKey = key;
186 const now = Date.now();
187 const timestamp = new Date(now).toTimeString().slice(0, 8);
188 const items = [...state.running.values()].sort(
189 (a, b) => a.startedAt - b.startedAt,
190 );
191 const lines = [
192 dim(
193 `⧗ [${timestamp}] no tests finished in the last 10s — ${items.length} still running:`,
194 ),
195 ];
196 for (const item of items.slice(0, STALL_LIST_MAX)) {
197 lines.push(
198 dim(` ${item.label} (${formatDuration(now - item.startedAt)})`),
199 );
200 }
201 if (items.length > STALL_LIST_MAX) {
202 lines.push(dim(` … ${items.length - STALL_LIST_MAX} more`));
203 }
204 writeDirect(`${lines.join("\n")}\n`);
205};
206
207const onEvent = (
208 event: TestEvent,
209 state: ReporterState,
210): Effect.Effect<void> => {
211 switch (event._tag) {
212 case "PlanPreview":
213 return Effect.sync(() => finishCollect(state)).pipe(
214 Effect.andThen(write(formatPlanPreview(event.phases))),
215 );
216 case "PlanPhaseStart":
217 return write(
218 `\nPlan phase ${event.phase}/${event.phases}: ${event.tests} tests`,
219 );
220 case "CollectStart":
221 state.collectTotal = event.files.length;
222 state.collectStartedAt = Date.now();
223 state.collecting = true;
224 return write(dim(`collecting ${event.files.length} test files...`));
225 case "FileCollected":
226 return Effect.sync(() => {
227 state.collectDone += 1;
228 state.collectCurrent = event.file;
229 renderCollect(state, false);
230 });
231 case "RunStart":
232 state.total = event.tests.length;
233 return Effect.sync(() => finishCollect(state)).pipe(
234 Effect.andThen(
235 write(
236 dim(
237 `running ${event.tests.length} tests from ${event.files} files\n`,
238 ),
239 ),
240 ),
241 );
242 case "TestStart":
243 return Effect.sync(() => {
244 state.running.set(event.test.id, {
245 label: `${event.test.file} > ${event.test.titlePath.join(" > ")}`,
246 startedAt: Date.now(),
247 });
248 });
249 case "HookStart":
250 return Effect.sync(() => {
251 state.running.set(`${event.file} :: ${event.hook}`, {
252 label: `${event.file} > [${event.hook} hook]`,
253 startedAt: Date.now(),
254 });
255 });
256 case "HookEnd":
257 return Effect.sync(() => {
258 state.running.delete(`${event.file} :: ${event.hook}`);
259 state.lastEnd = Date.now();
260 });
261 case "TestEnd": {
262 state.running.delete(event.test.id);
263 state.lastEnd = Date.now();
264 const title = `${dim(event.test.file)} ${dim(">")} ${event.test.titlePath.join(` ${dim(">")} `)}`;
265 const duration = dim(`(${formatDuration(event.result.durationMs)})`);
266 const retries =
267 event.result.retries > 0
268 ? yellow(` [retried x${event.result.retries}]`)
269 : "";
270 const count = progress(state, event.result.status);
271 switch (event.result.status) {
272 case "pass":
273 return write(`${count} ${green("✓")} ${title} ${duration}${retries}`);
274 case "fail": {
275 // Show the failure's details immediately so it can be inspected
276 // while the run continues (passed tests stay silent). The end-of-run
277 // Failures section repeats them consolidated, with file hook logs.
278 const lines = [`${count} ${red("✗")} ${title} ${duration}${retries}`];
279 if (event.result.error !== undefined) {
280 lines.push(indent(red(event.result.error)));
281 }
282 if (event.result.logs.length > 0) {
283 lines.push(dim("--- captured output ---"));
284 lines.push(formatLogs(event.result.logs));
285 lines.push(dim("--- end output ---"));
286 }
287 return write(lines.join("\n"));
288 }
289 case "skip":
290 return write(`${count} ${yellow("↓")} ${title} ${dim("[skipped]")}`);
291 case "todo":
292 // Warning-styled: a todo is unimplemented coverage, not a pass —
293 // it must not blend in with the dim noise around it.
294 return write(
295 `${count} ${yellow(
296 `○ ${event.test.file} > ${event.test.titlePath.join(" > ")} ${bold("[todo]")}`,
297 )}`,
298 );
299 }
300 }
301 case "FileEnd": {
302 if (event.logs.length > 0) {
303 state.hookLogs.set(event.file, event.logs);
304 }
305 if (event.error !== undefined) {
306 return write(
307 `${red("✗")} ${bold(event.file)} ${red("failed to run")}\n${indent(red(event.error))}` +
308 (event.logs.length > 0
309 ? `\n${dim("--- file hook output (deploy/destroy) ---")}\n${formatLogs(event.logs)}`
310 : ""),
311 );
312 }
313 return Effect.void;
314 }
315 case "RunEnd":
316 return printSummary(event.summary, state.hookLogs);
317 default:
318 return Effect.void;
319 }
320};
321
322export const printSummary = (
323 summary: RunSummary,
324 /** Buffered deploy/destroy output per file, printed once per failing file. */
325 hookLogs?: ReadonlyMap<string, ReadonlyArray<LogEntry>>,
326): Effect.Effect<void> =>
327 Effect.gen(function* () {
328 if (summary.failures.length > 0) {
329 yield* write(`\n${bold(red(`Failures (${summary.failures.length})`))}\n`);
330 const hookLogsPrinted = new Set<string>();
331 for (const { meta, result } of summary.failures) {
332 yield* write(
333 `${red("✗")} ${bold(meta.file)} ${dim(">")} ${meta.titlePath.join(` ${dim(">")} `)}`,
334 );
335 if (result.error !== undefined) {
336 yield* write(indent(red(result.error)));
337 }
338 if (result.logs.length > 0) {
339 yield* write(dim("--- captured output ---"));
340 yield* write(formatLogs(result.logs));
341 yield* write(dim("--- end output ---"));
342 }
343 const fileHookLogs = hookLogs?.get(meta.file);
344 if (
345 fileHookLogs !== undefined &&
346 fileHookLogs.length > 0 &&
347 !hookLogsPrinted.has(meta.file)
348 ) {
349 hookLogsPrinted.add(meta.file);
350 yield* write(dim("--- file hook output (deploy/destroy) ---"));
351 yield* write(formatLogs(fileHookLogs));
352 yield* write(dim("--- end output ---"));
353 }
354 yield* write("");
355 }
356 }
357 // A bare list of what failed, immediately above the counts — the failure
358 // details above are long enough that the names scroll out of reach, and
359 // each line here is exactly what `-t` matches for a re-run.
360 const failedNames = [
361 ...summary.failures.map(
362 ({ meta }) => `${meta.file} > ${meta.titlePath.join(" > ")}`,
363 ),
364 ...summary.fileFailures.map(({ file }) => `${file} (whole file)`),
365 ];
366 if (failedNames.length > 0) {
367 yield* write(bold(red(`Failed (${failedNames.length})`)));
368 for (const name of failedNames) {
369 yield* write(` ${red("✗")} ${name}`);
370 }
371 }
372 const parts = [
373 summary.failed > 0 ? red(`${summary.failed} failed`) : green("0 failed"),
374 ...(summary.dryRun ? [] : [green(`${summary.passed} passed`)]),
375 ...(summary.skipped > 0 ? [yellow(`${summary.skipped} skipped`)] : []),
376 ...(summary.todo > 0 ? [yellow(`${summary.todo} todo`)] : []),
377 ...(summary.plan
378 ? [
379 `${summary.plan.found} found`,
380 `${summary.plan.selected} selected by plan`,
381 yellow(`${summary.plan.excluded} excluded by plan`),
382 ]
383 : []),
384 ];
385 yield* write(
386 `\n${bold("Tests:")} ${parts.join(dim(" | "))} ${dim(`(${summary.files} files, ${formatDuration(summary.durationMs)})`)}`,
387 );
388 });
389
390export const PlainReporterLive: Layer.Layer<Reporter> = Layer.sync(Reporter)(
391 () => {
392 const state: ReporterState = {
393 hookLogs: new Map(),
394 running: new Map(),
395 total: 0,
396 completed: 0,
397 passed: 0,
398 failed: 0,
399 lastEnd: Date.now(),
400 lastStallKey: "",
401 stallTimer: undefined,
402 collectTotal: 0,
403 collectDone: 0,
404 collectStartedAt: Date.now(),
405 collectCurrent: "",
406 lastCollectRender: 0,
407 collectLinePending: false,
408 collecting: false,
409 };
410 // Ticks through both phases: repaints the collection line while files are
411 // importing, reports stalled tests once the run is executing.
412 const tick = (): void => {
413 if (state.collecting) renderCollect(state, false);
414 else printStalled(state);
415 };
416 return {
417 emit: (event) =>
418 Effect.suspend(() => {
419 if (
420 (event._tag === "CollectStart" || event._tag === "RunStart") &&
421 state.stallTimer === undefined
422 ) {
423 state.stallTimer = setInterval(tick, 1000);
424 // Don't hold the process open once the run's fibers finish.
425 (state.stallTimer as unknown as { unref?: () => void }).unref?.();
426 }
427 if (event._tag === "RunEnd" && state.stallTimer !== undefined) {
428 clearInterval(state.stallTimer);
429 state.stallTimer = undefined;
430 }
431 return onEvent(event, state);
432 }),
433 waitForExit: () => Effect.void,
434 };
435 },
436);