1/**
2 * The `alchemy-test` CLI.
3 *
4 * ```sh
5 * alchemy-test [paths...] [-t pattern] [--exclude path]... [--timeout ms]
6 * [--retry n] [--concurrency n] [--sequential] [--tui]
7 * [--profile name] [--fast]
8 * [--tags 'unit || (e2e && !live)']
9 * ```
10 *
11 * Runs every `*.test.ts` under the given paths (default `./test`) in a single
12 * bun process. Interactive terminals get the opentui view; otherwise tests
13 * are logged line-by-line and failures are dumped at the end.
14 */
15// The runner executes every test file's effects on ONE event loop, so any
16// fiber touching a relative path can race a transient process-wide chdir.
17// cross-spawn (bundled in vite/build tools the tests exercise) chdirs the
18// whole process to resolve a spawn's binary; `process.chdir.disabled` is
19// its own escape hatch (built for worker threads) — with it set, commands
20// still resolve via PATH and absolutize against the spawn's `cwd`.
21(process.chdir as { disabled?: boolean }).disabled = true;
22
23import * as BunRuntime from "@effect/platform-bun/BunRuntime";
24import * as BunServices from "@effect/platform-bun/BunServices";
25import * as Effect from "effect/Effect";
26import * as FileSystem from "effect/FileSystem";
27import * as Layer from "effect/Layer";
28import * as Option from "effect/Option";
29import * as Path from "effect/Path";
30import * as Runtime from "effect/Runtime";
31import * as Argument from "effect/cli/Argument";
32import * as Command from "effect/cli/Command";
33import * as Flag from "effect/cli/Flag";
34
35import { parsePlan } from "./Plan.ts";
36
37import packageJson from "../package.json" with { type: "json" };
38import { PlainReporterLive, printSummary } from "./PlainReporter.ts";
39import { Reporter } from "./Reporter.ts";
40import { run, type RunOptions } from "./Runner.ts";
41import { captureStrayOutput } from "./StrayOutput.ts";
42import { TuiReporter } from "./Tui.ts";
43
44const paths = Argument.String("paths").pipe(
45 Argument.withDescription("Test files or directories (default: ./test)"),
46 Argument.variadic(),
47);
48
49const testNamePattern = Flag.String("test-name-pattern").pipe(
50 Flag.withAlias("t"),
51 Flag.withDescription("Only run tests whose title matches this regex"),
52 Flag.optional,
53);
54
55const exclude = Flag.String("exclude").pipe(
56 Flag.withDescription(
57 "Skip test files under this path (repeatable). Existing files/directories exclude by prefix; anything else is a case-insensitive substring filter. Explicitly passing an excluded path as a positional argument overrides the exclusion.",
58 ),
59 Flag.atLeast(0),
60);
61
62const tagsFilter = Flag.String("tags").pipe(
63 Flag.withDescription(
64 'Select tags using &&, ||, !, parentheses and * wildcards (e.g. "e2e && provider:aws && !slow"). Repeated filters are ANDed.',
65 ),
66 Flag.atLeast(0),
67);
68
69const plan = Flag.String("plan").pipe(
70 Flag.withDescription(
71 "JSON array of sequential phases; nested arrays run branches in parallel. Each branch has tags (ANDed expressions) and optional file concurrency. First match wins; shared hooks live until the file's final phase.",
72 ),
73 Flag.optional,
74);
75
76const dryRun = Flag.Boolean("dry-run").pipe(
77 Flag.withDescription(
78 "Collect tests and show the execution plan without running tests or hooks",
79 ),
80 Flag.withDefault(false),
81);
82
83const timeout = Flag.Int("timeout").pipe(
84 Flag.withDescription("Default per-test timeout in milliseconds"),
85 Flag.withDefault(120_000),
86);
87
88const retry = Flag.Int("retry").pipe(
89 Flag.withDescription(
90 "Times a failing test is retried before failing the run",
91 ),
92 Flag.withDefault(2),
93);
94
95const concurrency = Flag.String("concurrency").pipe(
96 Flag.withAlias("c"),
97 Flag.withDescription(
98 'Maximum number of files running concurrently: a number (default 32) or "unbounded". Unbounded runs of large suites saturate the event loop and drown real failures in spurious 0ms beforeAll timeouts.',
99 ),
100 Flag.withDefault("32"),
101);
102
103const toConcurrency = (value: string): number | "unbounded" => {
104 if (value === "unbounded") return "unbounded";
105 const parsed = Number.parseInt(value, 10);
106 if (!Number.isFinite(parsed) || parsed < 1) {
107 throw new Error(
108 `--concurrency must be a positive integer or "unbounded", got: ${value}`,
109 );
110 }
111 return parsed;
112};
113
114const sequential = Flag.Boolean("sequential").pipe(
115 Flag.withDescription("Run tests within each file sequentially"),
116 Flag.withDefault(false),
117);
118
119const tui = Flag.Boolean("tui").pipe(
120 Flag.withDescription(
121 "Opt in to the interactive TUI (default is plain line output)",
122 ),
123 Flag.withDefault(false),
124);
125
126const profile = Flag.String("profile").pipe(
127 Flag.withDescription(
128 'Set ALCHEMY_PROFILE for the run (e.g. "testing") before any test module is imported',
129 ),
130 Flag.optional,
131);
132
133const fast = Flag.Boolean("fast").pipe(
134 Flag.withDescription(
135 "Set FAST=1 — suites skip their slow tests (long-provisioning resources, smoke tests)",
136 ),
137 Flag.withDefault(false),
138);
139
140/**
141 * Build the `-t` matcher — bun/vitest semantics: the pattern is a regex
142 * tested against the full nested title (`file > describe chain > name`).
143 * A syntactically INVALID regex (e.g. `-t "[worker"`) degrades to a literal
144 * substring match instead of erroring.
145 */
146const toFilter = (
147 pattern: Option.Option<string>,
148): ((fullTitle: string) => boolean) | undefined =>
149 Option.match(pattern, {
150 onNone: () => undefined,
151 onSome: (source) => {
152 let regex: RegExp;
153 try {
154 regex = new RegExp(source);
155 } catch {
156 // Not a valid regex — treat it as a literal substring.
157 regex = new RegExp(source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
158 }
159 return (fullTitle) => regex.test(fullTitle);
160 },
161 });
162
163const rootCommand = Command.make(
164 "alchemy-test",
165 {
166 paths,
167 testNamePattern,
168 tagsFilter,
169 plan,
170 dryRun,
171 exclude,
172 timeout,
173 retry,
174 concurrency,
175 sequential,
176 tui,
177 profile,
178 fast,
179 },
180 Effect.fn(function* (args) {
181 // Environment knobs — set BEFORE any test module is imported (imports
182 // happen inside `run` during collection), so `skipIf(process.env.FAST)`
183 // gates and profile-dependent layers see the final values.
184 // Inherit CI from the caller: setting it here also disables local auth
185 // profiles, even when the caller explicitly selected one.
186 yield* Effect.sync(() => {
187 if (Option.isSome(args.profile)) {
188 process.env.ALCHEMY_PROFILE = args.profile.value;
189 }
190 if (args.fast) {
191 process.env.FAST = "1";
192 }
193 // The runner owns the terminal: stdout belongs to the reporter (or
194 // the TUI) and stdin carries TUI keystrokes. Code under test must see
195 // the same non-interactive, colorless process CI gives it, regardless
196 // of the terminal this run was launched from — otherwise assertions
197 // on CLI output and on interactive-vs-plain copy depend on whether a
198 // human or a pipeline started the run. Sigil's detection honors
199 // FORCE_COLOR over everything else, so pin it rather than NO_COLOR.
200 process.env.ALCHEMY_NO_TUI = "1";
201 process.env.NO_COLOR = "1";
202 process.env.FORCE_COLOR = "0";
203 });
204
205 // Plain line output by default; the TUI is opt-in (`--tui`) and requires
206 // an interactive terminal.
207 const interactive =
208 !args.dryRun && args.tui && process.stdout.isTTY === true;
209 const path = yield* Path.Path;
210 const root = process.cwd();
211 // Per-run log file (timestamp + pid) so concurrent runs in different
212 // terminals never trample each other's logs.
213 const timestamp = new Date()
214 .toISOString()
215 .slice(0, 19)
216 .replaceAll(":", "-");
217 const logFile = path.resolve(
218 root,
219 ".alchemy",
220 "log",
221 "test",
222 `${timestamp}-pid${process.pid}.log`,
223 );
224
225 const options: RunOptions = {
226 root,
227 paths: args.paths,
228 exclude: args.exclude,
229 filter: toFilter(args.testNamePattern),
230 tagsFilter: args.tagsFilter,
231 dryRun: args.dryRun,
232 plan: Option.isSome(args.plan) ? parsePlan(args.plan.value) : undefined,
233 timeout: args.timeout,
234 retry: args.retry,
235 concurrency: toConcurrency(args.concurrency),
236 sequential: args.sequential,
237 logFile,
238 };
239
240 // In plain mode, divert stray JS-level stdout/stderr writes into the
241 // run log for the duration of the run (the reporter writes through the
242 // real stream); the TUI installs its own diversion after the renderer
243 // is created.
244 const restoreStrayCapture = interactive
245 ? undefined
246 : captureStrayOutput(logFile);
247
248 const summary = yield* Effect.gen(function* () {
249 const reporter = yield* Reporter;
250 const summary = yield* run(options);
251 yield* reporter.waitForExit(summary);
252 return summary;
253 }).pipe(
254 Effect.provide(interactive ? TuiReporter(logFile) : PlainReporterLive),
255 Effect.scoped,
256 Effect.onExit(() => Effect.sync(() => restoreStrayCapture?.())),
257 );
258
259 // After the TUI tears down, leave a plain record in the terminal.
260 if (interactive) {
261 yield* printSummary(summary);
262 }
263
264 // Point agents/tooling at the full run log, with its size so the reader
265 // knows what they're getting into before opening it.
266 yield* Effect.gen(function* () {
267 const fs = yield* FileSystem.FileSystem;
268 const stats = yield* fs
269 .stat(logFile)
270 .pipe(Effect.orElseSucceed(() => undefined));
271 const contents = yield* fs
272 .readFileString(logFile)
273 .pipe(Effect.orElseSucceed(() => ""));
274 const lines = contents === "" ? 0 : contents.split("\n").length;
275 const kb = stats === undefined ? 0 : Number(stats.size) / 1024;
276 yield* Effect.sync(() => {
277 process.stdout.write(
278 `\nFull log: ${logFile} (${lines} lines, ${kb.toFixed(1)} KB)\n`,
279 );
280 });
281 });
282
283 if (summary.failed > 0) {
284 yield* Effect.sync(() => {
285 process.exitCode = 1;
286 });
287 }
288 }),
289);
290
291const cli = Command.run(rootCommand, {
292 version: packageJson.version,
293});
294
295cli.pipe(
296 Effect.provide(Layer.mergeAll(BunServices.layer)),
297 Effect.scoped,
298 (effect) =>
299 BunRuntime.runMain(effect as Effect.Effect<void>, {
300 // ALWAYS exit once the main effect completes. runMain's default only
301 // force-exits on failure/signal — but tests can leak live handles
302 // (vite watchers, workerd sidecar sockets, keep-alive agents) that
303 // keep bun's event loop alive forever after a green run. Everything
304 // is already flushed by now (summary + log written in the main
305 // effect); the macrotask hop lets any buffered stdout drain.
306 teardown: (exit, onExit) => {
307 Runtime.defaultTeardown(exit, (code) => {
308 const finalCode =
309 code !== 0 ? code : Number(process.exitCode ?? 0) || 0;
310 setTimeout(() => process.exit(finalCode), 0);
311 onExit(finalCode);
312 });
313 },
314 }),
315);