1/**
2 * The Reporter is an Effect service the runner emits structured events into.
3 * Two implementations ship with the CLI: a plain line-oriented reporter for
4 * non-interactive terminals / CI, and an opentui TUI for interactive use.
5 */
6import * as Context from "effect/Context";
7import type * as Effect from "effect/Effect";
8
9import type { LogEntry } from "./Model.ts";
10
11export type TestStatus = "pass" | "fail" | "skip" | "todo";
12
13export interface TestMeta {
14 readonly tags: ReadonlyArray<string>;
15 readonly optInTags: ReadonlyArray<string>;
16 /** Stable id: `<file> > <describe chain> > <name>`. */
17 readonly id: string;
18 readonly file: string;
19 readonly titlePath: ReadonlyArray<string>;
20 readonly name: string;
21}
22
23export interface TestResult {
24 readonly status: TestStatus;
25 readonly durationMs: number;
26 /** Pretty-printed failure (message + relevant stack), if failed. */
27 readonly error?: string | undefined;
28 /** Buffered Effect log / Console output captured during the test. */
29 readonly logs: ReadonlyArray<LogEntry>;
30 /** Number of retries that were attempted before this result. */
31 readonly retries: number;
32}
33
34export interface RunSummary {
35 readonly dryRun?: boolean;
36 /** Coverage within the command's path, name, .only and global tag filters. */
37 readonly plan?:
38 | {
39 readonly found: number;
40 readonly selected: number;
41 readonly excluded: number;
42 }
43 | undefined;
44 readonly files: number;
45 readonly passed: number;
46 readonly failed: number;
47 readonly skipped: number;
48 readonly todo: number;
49 readonly durationMs: number;
50 readonly failures: ReadonlyArray<{ meta: TestMeta; result: TestResult }>;
51 /**
52 * Files that failed as a whole (import error, or a `beforeAll`/`afterAll`
53 * hook failure) rather than through an individual test. Counted in
54 * {@link failed} alongside `failures`.
55 */
56 readonly fileFailures: ReadonlyArray<{ file: string; error: string }>;
57}
58
59export type TestEvent =
60 | {
61 readonly _tag: "PlanPreview";
62 readonly phases: ReadonlyArray<
63 ReadonlyArray<{
64 readonly tags: ReadonlyArray<string>;
65 readonly concurrency: number | "unbounded";
66 readonly tests: number;
67 readonly files: number;
68 readonly skipped: number;
69 }>
70 >;
71 }
72 | {
73 readonly _tag: "PlanPhaseStart";
74 readonly phase: number;
75 readonly phases: number;
76 readonly tests: number;
77 }
78 | { readonly _tag: "CollectStart"; readonly files: ReadonlyArray<string> }
79 | {
80 /** Import progress: one per file during the collection phase. */
81 readonly _tag: "FileCollected";
82 readonly file: string;
83 }
84 | {
85 /**
86 * Collection is complete; execution is about to begin. Carries the
87 * full (filtered) test list for the whole run.
88 */
89 readonly _tag: "RunStart";
90 readonly files: number;
91 readonly tests: ReadonlyArray<TestMeta>;
92 }
93 | {
94 readonly _tag: "FileStart";
95 readonly file: string;
96 /**
97 * LIVE reference to the file's hook log buffer (deploy/destroy). The
98 * runner appends to it as hooks execute — single-process, so reporters
99 * may read it incrementally (e.g. the TUI tailing a detail pane).
100 */
101 readonly logs?: ReadonlyArray<LogEntry>;
102 }
103 | {
104 /** A file-level hook (deploy/destroy) started running. */
105 readonly _tag: "HookStart";
106 readonly file: string;
107 readonly hook: "beforeAll" | "afterAll";
108 }
109 | {
110 readonly _tag: "HookEnd";
111 readonly file: string;
112 readonly hook: "beforeAll" | "afterAll";
113 }
114 | {
115 readonly _tag: "FileEnd";
116 readonly file: string;
117 /** Buffered output of file-level hooks (deploy/destroy). */
118 readonly logs: ReadonlyArray<LogEntry>;
119 readonly error?: string | undefined;
120 }
121 | {
122 readonly _tag: "TestStart";
123 readonly test: TestMeta;
124 /** LIVE reference to the test's captured-output buffer (see FileStart). */
125 readonly logs?: ReadonlyArray<LogEntry>;
126 }
127 | {
128 readonly _tag: "TestEnd";
129 readonly test: TestMeta;
130 readonly result: TestResult;
131 }
132 | { readonly _tag: "RunEnd"; readonly summary: RunSummary };
133
134/**
135 * Interactive control over a live (or finished) run. The runner hands this
136 * to the Reporter so the TUI can retry and kill tests.
137 *
138 * Retries re-run the test body as-is: for files whose state was torn down by
139 * `afterAll` (deploy-once suites) the retried body may fail on missing
140 * infrastructure — `test.provider`-style self-contained tests retry cleanly.
141 */
142export interface RunController {
143 /** Re-run a finished test. No-op while the test is still running/queued. */
144 readonly retryTest: (id: string) => void;
145 /** Re-run every finished test of a file. */
146 readonly retryFile: (file: string) => void;
147 /** Interrupt a currently-running test (reported as failed/killed). */
148 readonly killTest: (id: string) => void;
149}
150
151export interface ReporterService {
152 readonly emit: (event: TestEvent) => Effect.Effect<void>;
153 /**
154 * Runs after RunEnd. The plain reporter returns immediately; the TUI blocks
155 * until the user quits so results can be inspected.
156 */
157 readonly waitForExit: (summary: RunSummary) => Effect.Effect<void>;
158 /** Called once by the runner when interactive control becomes available. */
159 readonly attachController?: (
160 controller: RunController,
161 ) => Effect.Effect<void>;
162}
163
164export class Reporter extends Context.Service<Reporter, ReporterService>()(
165 "alchemy-test/Reporter",
166) {}