| 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 | |
| 23 | import * as BunRuntime from "@effect/platform-bun/BunRuntime"; |
| 24 | import * as BunServices from "@effect/platform-bun/BunServices"; |
| 25 | import * as Effect from "effect/Effect"; |
| 26 | import * as FileSystem from "effect/FileSystem"; |
| 27 | import * as Layer from "effect/Layer"; |
| 28 | import * as Option from "effect/Option"; |
| 29 | import * as Path from "effect/Path"; |
| 30 | import * as Runtime from "effect/Runtime"; |
| 31 | import * as Argument from "effect/cli/Argument"; |
| 32 | import * as Command from "effect/cli/Command"; |
| 33 | import * as Flag from "effect/cli/Flag"; |
| 34 | |
| 35 | import { parsePlan } from "./Plan.ts"; |
| 36 | |
| 37 | import packageJson from "../package.json" with { type: "json" }; |
| 38 | import { PlainReporterLive, printSummary } from "./PlainReporter.ts"; |
| 39 | import { Reporter } from "./Reporter.ts"; |
| 40 | import { run, type RunOptions } from "./Runner.ts"; |
| 41 | import { captureStrayOutput } from "./StrayOutput.ts"; |
| 42 | import { TuiReporter } from "./Tui.ts"; |
| 43 | |
| 44 | const paths = Argument.String("paths").pipe( |
| 45 | Argument.withDescription("Test files or directories (default: ./test)"), |
| 46 | Argument.variadic(), |
| 47 | ); |
| 48 | |
| 49 | const 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 | |
| 55 | const 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 | |
| 62 | const 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 | |
| 69 | const 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 | |
| 76 | const 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 | |
| 83 | const timeout = Flag.Int("timeout").pipe( |
| 84 | Flag.withDescription("Default per-test timeout in milliseconds"), |
| 85 | Flag.withDefault(120_000), |
| 86 | ); |
| 87 | |
| 88 | const 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 | |
| 95 | const 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 | |
| 103 | const 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 | |
| 114 | const sequential = Flag.Boolean("sequential").pipe( |
| 115 | Flag.withDescription("Run tests within each file sequentially"), |
| 116 | Flag.withDefault(false), |
| 117 | ); |
| 118 | |
| 119 | const 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 | |
| 126 | const 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 | |
| 133 | const 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 | */ |
| 146 | const 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 | |
| 163 | const 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 | |
| 291 | const cli = Command.run(rootCommand, { |
| 292 | version: packageJson.version, |
| 293 | }); |
| 294 | |
| 295 | cli.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 | ); |