@alchemy.run/test 0.0.0-stage → 2.0.0-beta.85

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.
Files changed (119) hide show
  1. package/LICENSE +201 -0
  2. package/bin/alchemy-test.ts +2 -0
  3. package/lib/Alchemy.d.ts +78 -0
  4. package/lib/Alchemy.d.ts.map +1 -0
  5. package/lib/Alchemy.js +194 -0
  6. package/lib/Alchemy.js.map +1 -0
  7. package/lib/Api.d.ts +134 -0
  8. package/lib/Api.d.ts.map +1 -0
  9. package/lib/Api.js +245 -0
  10. package/lib/Api.js.map +1 -0
  11. package/lib/Bun.d.ts +67 -0
  12. package/lib/Bun.d.ts.map +1 -0
  13. package/lib/Bun.js +143 -0
  14. package/lib/Bun.js.map +1 -0
  15. package/lib/Capture.d.ts +10 -0
  16. package/lib/Capture.d.ts.map +1 -0
  17. package/lib/Capture.js +68 -0
  18. package/lib/Capture.js.map +1 -0
  19. package/lib/Core.d.ts +287 -0
  20. package/lib/Core.d.ts.map +1 -0
  21. package/lib/Core.js +429 -0
  22. package/lib/Core.js.map +1 -0
  23. package/lib/DevCli.d.ts +47 -0
  24. package/lib/DevCli.d.ts.map +1 -0
  25. package/lib/DevCli.js +181 -0
  26. package/lib/DevCli.js.map +1 -0
  27. package/lib/Expect.d.ts +84 -0
  28. package/lib/Expect.d.ts.map +1 -0
  29. package/lib/Expect.js +518 -0
  30. package/lib/Expect.js.map +1 -0
  31. package/lib/FileLog.d.ts +27 -0
  32. package/lib/FileLog.d.ts.map +1 -0
  33. package/lib/FileLog.js +136 -0
  34. package/lib/FileLog.js.map +1 -0
  35. package/lib/Http.d.ts +109 -0
  36. package/lib/Http.d.ts.map +1 -0
  37. package/lib/Http.js +123 -0
  38. package/lib/Http.js.map +1 -0
  39. package/lib/Model.d.ts +98 -0
  40. package/lib/Model.d.ts.map +1 -0
  41. package/lib/Model.js +59 -0
  42. package/lib/Model.js.map +1 -0
  43. package/lib/PlainReporter.d.ts +19 -0
  44. package/lib/PlainReporter.d.ts.map +1 -0
  45. package/lib/PlainReporter.js +348 -0
  46. package/lib/PlainReporter.js.map +1 -0
  47. package/lib/Plan.d.ts +19 -0
  48. package/lib/Plan.d.ts.map +1 -0
  49. package/lib/Plan.js +32 -0
  50. package/lib/Plan.js.map +1 -0
  51. package/lib/Registry.d.ts +34 -0
  52. package/lib/Registry.d.ts.map +1 -0
  53. package/lib/Registry.js +80 -0
  54. package/lib/Registry.js.map +1 -0
  55. package/lib/Reporter.d.ts +160 -0
  56. package/lib/Reporter.d.ts.map +1 -0
  57. package/lib/Reporter.js +9 -0
  58. package/lib/Reporter.js.map +1 -0
  59. package/lib/Runner.d.ts +51 -0
  60. package/lib/Runner.d.ts.map +1 -0
  61. package/lib/Runner.js +1052 -0
  62. package/lib/Runner.js.map +1 -0
  63. package/lib/StrayOutput.d.ts +8 -0
  64. package/lib/StrayOutput.d.ts.map +1 -0
  65. package/lib/StrayOutput.js +259 -0
  66. package/lib/StrayOutput.js.map +1 -0
  67. package/lib/Tags.d.ts +17 -0
  68. package/lib/Tags.d.ts.map +1 -0
  69. package/lib/Tags.js +84 -0
  70. package/lib/Tags.js.map +1 -0
  71. package/lib/TestState.d.ts +12 -0
  72. package/lib/TestState.d.ts.map +1 -0
  73. package/lib/TestState.js +15 -0
  74. package/lib/TestState.js.map +1 -0
  75. package/lib/Tui.d.ts +4 -0
  76. package/lib/Tui.d.ts.map +1 -0
  77. package/lib/Tui.js +1137 -0
  78. package/lib/Tui.js.map +1 -0
  79. package/lib/Vitest.d.ts +58 -0
  80. package/lib/Vitest.d.ts.map +1 -0
  81. package/lib/Vitest.js +132 -0
  82. package/lib/Vitest.js.map +1 -0
  83. package/lib/WorktreeStage.d.ts +17 -0
  84. package/lib/WorktreeStage.d.ts.map +1 -0
  85. package/lib/WorktreeStage.js +48 -0
  86. package/lib/WorktreeStage.js.map +1 -0
  87. package/lib/cli.d.ts +2 -0
  88. package/lib/cli.d.ts.map +1 -0
  89. package/lib/cli.js +222 -0
  90. package/lib/cli.js.map +1 -0
  91. package/lib/index.d.ts +14 -0
  92. package/lib/index.d.ts.map +1 -0
  93. package/lib/index.js +12 -0
  94. package/lib/index.js.map +1 -0
  95. package/package.json +88 -3
  96. package/src/Alchemy.ts +335 -0
  97. package/src/Api.ts +521 -0
  98. package/src/Bun.ts +256 -0
  99. package/src/Capture.ts +72 -0
  100. package/src/Core.ts +872 -0
  101. package/src/DevCli.ts +229 -0
  102. package/src/Expect.ts +763 -0
  103. package/src/FileLog.ts +158 -0
  104. package/src/Http.ts +201 -0
  105. package/src/Model.ts +160 -0
  106. package/src/PlainReporter.ts +426 -0
  107. package/src/Plan.ts +45 -0
  108. package/src/Registry.ts +101 -0
  109. package/src/Reporter.ts +171 -0
  110. package/src/Runner.ts +1341 -0
  111. package/src/StrayOutput.ts +283 -0
  112. package/src/Tags.ts +100 -0
  113. package/src/TestState.ts +35 -0
  114. package/src/Tui.ts +1272 -0
  115. package/src/Vitest.ts +239 -0
  116. package/src/WorktreeStage.ts +45 -0
  117. package/src/cli.ts +330 -0
  118. package/src/index.ts +57 -0
  119. package/README.md +0 -3
package/src/Runner.ts ADDED
@@ -0,0 +1,1341 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { inspect } from "node:util";
3
+ /**
4
+ * Single-process test runner.
5
+ *
6
+ * Discovers `*.test.ts` files, imports them concurrently with an AsyncLocalStorage
7
+ * per-file collector (see Registry.ts), then executes every collected
8
+ * test as an Effect — files run concurrently up to a limit, tests within a
9
+ * file run sequentially unless their suite is `describe.concurrent`. Each
10
+ * test gets a buffering Effect Logger + Console so its output can be shown
11
+ * in isolation.
12
+ */
13
+ import * as Cause from "effect/Cause";
14
+ import * as ConsoleModule from "effect/Console";
15
+ import * as Deferred from "effect/Deferred";
16
+ import * as Duration from "effect/Duration";
17
+ import * as Effect from "effect/Effect";
18
+ import * as Exit from "effect/Exit";
19
+ import * as Fiber from "effect/Fiber";
20
+ import * as FileSystem from "effect/FileSystem";
21
+ import * as Logger from "effect/Logger";
22
+ import * as Option from "effect/Option";
23
+ import * as Path from "effect/Path";
24
+ import * as Semaphore from "effect/Semaphore";
25
+ import { makeCapture } from "./Capture.ts";
26
+ import { makeFileLog, type FileLog } from "./FileLog.ts";
27
+ import type { Exclusive, FileSuite, Hook, LogEntry, Suite, TestCase } from "./Model.ts";
28
+ import { containsOnly, forEachTest, makeFileSuite, titlePath } from "./Model.ts";
29
+ import type { TestPlan } from "./Plan.ts";
30
+ import * as Registry from "./Registry.ts";
31
+ import {
32
+ Reporter,
33
+ type RunController,
34
+ type RunSummary,
35
+ type TestEvent,
36
+ type TestMeta,
37
+ type TestResult,
38
+ } from "./Reporter.ts";
39
+ import { compileTagsFilter, type TagsFilter } from "./Tags.ts";
40
+
41
+ export interface RunOptions {
42
+ /** Directory the run is rooted at (usually `packages/alchemy`). */
43
+ readonly root: string;
44
+ /**
45
+ * Positional filters. Existing files/directories are used as-is; anything
46
+ * else is a case-insensitive substring filter on test file paths (like
47
+ * vitest's positional filters). Defaults to `test`.
48
+ */
49
+ readonly paths: ReadonlyArray<string>;
50
+ /**
51
+ * Exclusion filters (`--exclude`). Existing files/directories exclude by
52
+ * path prefix; anything else is a case-insensitive substring exclusion on
53
+ * test file paths. A positional root explicitly given inside an excluded
54
+ * path overrides the exclusion (so `alchemy-test test/Railway` still works
55
+ * when the package script bakes in `--exclude test/Railway`).
56
+ */
57
+ readonly exclude?: ReadonlyArray<string> | undefined;
58
+ /**
59
+ * `-t` test-name filter, applied to the full title
60
+ * (`file > describe chain > name`).
61
+ */
62
+ readonly filter?: ((fullTitle: string) => boolean) | undefined;
63
+ /** Match the combined suite and test tags, in addition to other filters. */
64
+ readonly tagsFilter?: ReadonlyArray<string>;
65
+ readonly plan?: TestPlan | undefined;
66
+ readonly dryRun?: boolean;
67
+ /** Default per-test timeout in ms. */
68
+ readonly timeout: number;
69
+ /** Times a failing test body is re-run before being reported as failed. */
70
+ readonly retry: number;
71
+ /** Maximum number of files executing concurrently (default unbounded). */
72
+ readonly concurrency: number | "unbounded";
73
+ /** Force sequential execution within every file. */
74
+ readonly sequential: boolean;
75
+ /** Absolute path of the persistent run log (test.log). */
76
+ readonly logFile: string;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Log capture
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Strip ANSI escape sequences (SGR colors, cursor movement, OSC). Captured
85
+ * output is re-rendered by the reporters — embedded escapes corrupt the
86
+ * TUI's cell-based rendering and garble plain output.
87
+ */
88
+ const ANSI_RE =
89
+ // eslint-disable-next-line no-control-regex
90
+ /\u001B(?:\[[0-9;?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\)|[@-Z\\-_])/g;
91
+
92
+ const stripAnsi = (text: string): string => text.replace(ANSI_RE, "");
93
+
94
+ const formatArg = (value: unknown): string =>
95
+ stripAnsi(typeof value === "string" ? value : inspect(value, { depth: 4, colors: false }));
96
+
97
+ const formatArgs = (args: ReadonlyArray<unknown>): string => args.map(formatArg).join(" ");
98
+
99
+ const bufferingConsole = (logs: Array<LogEntry>): ConsoleModule.Console => {
100
+ const push = (level: string, args: ReadonlyArray<unknown>) => {
101
+ logs.push({ level, message: formatArgs(args), time: new Date() });
102
+ };
103
+ const times = new Map<string, number>();
104
+ return {
105
+ assert: (condition, ...args) => {
106
+ if (!condition) push("error", ["Assertion failed:", ...args]);
107
+ },
108
+ clear: () => {},
109
+ count: (label) => push("info", [`count: ${label ?? "default"}`]),
110
+ countReset: () => {},
111
+ debug: (...args) => push("debug", args),
112
+ dir: (item) => push("info", [item]),
113
+ dirxml: (...args) => push("info", args),
114
+ error: (...args) => push("error", args),
115
+ group: (...args) => push("info", args),
116
+ groupCollapsed: (...args) => push("info", args),
117
+ groupEnd: () => {},
118
+ info: (...args) => push("info", args),
119
+ log: (...args) => push("info", args),
120
+ table: (data) => push("info", [data]),
121
+ time: (label) => {
122
+ times.set(label ?? "default", Date.now());
123
+ },
124
+ timeEnd: (label) => {
125
+ const start = times.get(label ?? "default");
126
+ push("info", [`${label ?? "default"}: ${start === undefined ? "?" : Date.now() - start}ms`]);
127
+ },
128
+ timeLog: (label, ...args) => push("info", [label, ...args]),
129
+ trace: (...args) => push("debug", args),
130
+ warn: (...args) => push("warn", args),
131
+ };
132
+ };
133
+
134
+ /** Provide a buffering Logger + Console around an effect. */
135
+ const withCapture =
136
+ (logs: Array<LogEntry>) =>
137
+ <A, E>(effect: Effect.Effect<A, E>): Effect.Effect<A, E> =>
138
+ effect.pipe(
139
+ Effect.provide(
140
+ Logger.layer([
141
+ Logger.make((options) => {
142
+ // `Effect.log("a", "b")` delivers the message as an array —
143
+ // unwrap it so buffered output reads exactly like console output
144
+ // instead of `[ 'a', 'b' ]`.
145
+ const parts = Array.isArray(options.message) ? options.message : [options.message];
146
+ logs.push({
147
+ level: options.logLevel,
148
+ message:
149
+ parts.map(formatArg).join(" ") +
150
+ (options.cause.reasons.length === 0 ? "" : `\n${Cause.pretty(options.cause)}`),
151
+ time: options.date,
152
+ });
153
+ }),
154
+ ]),
155
+ ),
156
+ Effect.provideService(ConsoleModule.Console, bufferingConsole(logs)),
157
+ );
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Discovery
161
+ // ---------------------------------------------------------------------------
162
+
163
+ const isTestFile = (name: string): boolean =>
164
+ name.endsWith(".test.ts") || name.endsWith(".test.tsx");
165
+
166
+ export const discover = Effect.fn(function* (options: RunOptions) {
167
+ const fs = yield* FileSystem.FileSystem;
168
+ const path = yield* Path.Path;
169
+ const files: Array<string> = [];
170
+
171
+ const walk: (dir: string) => Effect.Effect<void, unknown, FileSystem.FileSystem> = Effect.fn(
172
+ function* (dir: string) {
173
+ const entries = yield* fs.readDirectory(dir);
174
+ entries.sort();
175
+ for (const entry of entries) {
176
+ if (entry === "node_modules" || entry.startsWith(".")) continue;
177
+ const full = path.join(dir, entry);
178
+ const stat = yield* fs.stat(full);
179
+ if (stat.type === "Directory") {
180
+ yield* walk(full);
181
+ } else if (isTestFile(entry)) {
182
+ files.push(full);
183
+ }
184
+ }
185
+ },
186
+ );
187
+
188
+ // Positional args that exist on disk are roots; anything else is a
189
+ // case-insensitive substring filter on discovered file paths (vitest-style:
190
+ // `alchemy-test Bucket` runs every *Bucket* test file).
191
+ const roots: Array<string> = [];
192
+ const nameFilters: Array<string> = [];
193
+ for (const p of options.paths) {
194
+ const abs = path.isAbsolute(p) ? p : path.resolve(options.root, p);
195
+ const exists = yield* fs.exists(abs).pipe(Effect.orElseSucceed(() => false));
196
+ if (exists) {
197
+ roots.push(abs);
198
+ } else {
199
+ nameFilters.push(p.toLowerCase());
200
+ }
201
+ }
202
+ // Roots the user asked for explicitly (before defaulting to `test`) are
203
+ // exempt from `--exclude` — a baked-in package-script exclusion must not
204
+ // make `alchemy-test test/Railway/Foo.test.ts` silently run nothing.
205
+ const explicitRoots = [...roots];
206
+ if (roots.length === 0) {
207
+ roots.push(path.resolve(options.root, "test"));
208
+ }
209
+
210
+ // Excludes mirror positional semantics: existing paths exclude by prefix,
211
+ // anything else is a case-insensitive substring exclusion.
212
+ const excludePrefixes: Array<string> = [];
213
+ const excludeSubstrings: Array<string> = [];
214
+ for (const e of options.exclude ?? []) {
215
+ const abs = path.isAbsolute(e) ? e : path.resolve(options.root, e);
216
+ const exists = yield* fs.exists(abs).pipe(Effect.orElseSucceed(() => false));
217
+ if (exists) {
218
+ excludePrefixes.push(abs);
219
+ } else {
220
+ excludeSubstrings.push(e.toLowerCase());
221
+ }
222
+ }
223
+ const isWithin = (file: string, dir: string): boolean =>
224
+ file === dir || file.startsWith(dir + path.sep);
225
+ const isExcluded = (file: string): boolean => {
226
+ if (excludePrefixes.some((prefix) => isWithin(file, prefix))) return true;
227
+ const rel = path.relative(options.root, file).toLowerCase();
228
+ return excludeSubstrings.some((substring) => rel.includes(substring));
229
+ };
230
+ const exemptRoots = explicitRoots.filter(isExcluded);
231
+
232
+ for (const abs of roots) {
233
+ const stat = yield* fs
234
+ .stat(abs)
235
+ .pipe(Effect.mapError(() => new Error(`alchemy-test: path not found: ${abs}`)));
236
+ if (stat.type === "Directory") {
237
+ yield* walk(abs);
238
+ } else {
239
+ files.push(abs);
240
+ }
241
+ }
242
+
243
+ let unique = [...new Set(files)];
244
+ if (nameFilters.length > 0) {
245
+ unique = unique.filter((file) => {
246
+ const rel = path.relative(options.root, file).toLowerCase();
247
+ return nameFilters.some((filter) => rel.includes(filter));
248
+ });
249
+ }
250
+ if (excludePrefixes.length > 0 || excludeSubstrings.length > 0) {
251
+ unique = unique.filter(
252
+ (file) => exemptRoots.some((root) => isWithin(file, root)) || !isExcluded(file),
253
+ );
254
+ }
255
+ return unique.sort();
256
+ });
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Collection
260
+ // ---------------------------------------------------------------------------
261
+
262
+ export interface CollectedFile {
263
+ readonly file: string;
264
+ readonly suite: FileSuite;
265
+ readonly error?: string | undefined;
266
+ }
267
+
268
+ const collectFile = (
269
+ absolute: string,
270
+ relative: string,
271
+ suite: FileSuite,
272
+ ): Effect.Effect<CollectedFile> =>
273
+ Effect.promise(async (): Promise<CollectedFile> => {
274
+ try {
275
+ await Registry.collect(
276
+ relative,
277
+ async () => {
278
+ await import(pathToFileURL(absolute).href);
279
+ // Flush microtasks + one macrotask so registrations deferred with
280
+ // queueMicrotask (e.g. Test.make's fallback afterAll) land in the
281
+ // tree while this file's collector is still the active one.
282
+ await new Promise<void>((resolve) => setImmediate(resolve));
283
+ },
284
+ suite,
285
+ );
286
+ return { file: relative, suite };
287
+ } catch (error) {
288
+ return {
289
+ file: relative,
290
+ suite,
291
+ error: error instanceof Error ? (error.stack ?? error.message) : String(error),
292
+ };
293
+ }
294
+ });
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Execution
298
+ // ---------------------------------------------------------------------------
299
+
300
+ /**
301
+ * Whole-process read/write lock. Normal tests hold a read permit; tests
302
+ * registered with `{ exclusive: true }` (they mutate process-global state
303
+ * like `process.env`) take every permit, so they never overlap with any
304
+ * other test in the run. A label (`{ exclusive: "hetzner" }`) instead takes
305
+ * a per-label lock plus a normal read permit, so it only serializes with
306
+ * holders of the same label.
307
+ */
308
+ const EXCLUSIVE_PERMITS = 100_000;
309
+
310
+ interface LockSpec {
311
+ readonly global: boolean;
312
+ /** Sorted, so holders of several labels acquire them in one order. */
313
+ readonly labels: ReadonlyArray<string>;
314
+ }
315
+
316
+ const lockSpec = (values: ReadonlyArray<Exclusive | undefined>): LockSpec => ({
317
+ global: values.some((value) => value === true),
318
+ labels: [
319
+ ...new Set(
320
+ values.filter((value): value is string => typeof value === "string" && value !== ""),
321
+ ),
322
+ ].sort(),
323
+ });
324
+
325
+ interface RunLock {
326
+ readonly withLock: (
327
+ spec: LockSpec,
328
+ ) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
329
+ }
330
+
331
+ /**
332
+ * Opportunistic exclusive lock. An exclusive acquirer asks for every permit
333
+ * and gets them only once nothing else holds one; everyone else keeps taking
334
+ * single permits meanwhile, because Effect's `Semaphore` grants whoever asks
335
+ * while enough are free. So an exclusive test never stalls the run: in a busy
336
+ * run it waits for a quiet moment, at worst the tail of its phase.
337
+ *
338
+ * Writer preference (holding a gate while waiting) would bound that wait, but
339
+ * at the cost of freezing every test started meanwhile behind the slowest one
340
+ * in flight: a 25-minute ElastiCache update held up the whole AWS suite.
341
+ */
342
+ const makeRunLock = Effect.gen(function* () {
343
+ const permits = yield* Semaphore.make(EXCLUSIVE_PERMITS);
344
+ const labels = new Map<string, Semaphore.Semaphore>();
345
+ const labelLock = (label: string): Semaphore.Semaphore => {
346
+ let lock = labels.get(label);
347
+ if (lock === undefined) {
348
+ lock = Semaphore.makeUnsafe(1);
349
+ labels.set(label, lock);
350
+ }
351
+ return lock;
352
+ };
353
+ const lock: RunLock = {
354
+ withLock:
355
+ (spec) =>
356
+ <A, E, R>(self: Effect.Effect<A, E, R>) => {
357
+ if (spec.global) return permits.withPermits(EXCLUSIVE_PERMITS)(self);
358
+ // Take labels before the read permit: a queued label holder must not
359
+ // sit on a read permit that a waiting global writer needs.
360
+ const shared = permits.withPermits(1)(self);
361
+ return spec.labels.reduceRight(
362
+ (inner, label) => labelLock(label).withPermits(1)(inner),
363
+ shared,
364
+ );
365
+ },
366
+ };
367
+ return lock;
368
+ });
369
+
370
+ const testLock = (test: TestCase): LockSpec => lockSpec([test.exclusive]);
371
+
372
+ const hookLock = (hooks: ReadonlyArray<Hook>): LockSpec =>
373
+ lockSpec(hooks.map((hook) => hook.exclusive));
374
+
375
+ interface ExecContext {
376
+ readonly options: Omit<RunOptions, "tagsFilter"> & {
377
+ readonly tagsFilter: TagsFilter;
378
+ readonly selectedTests?: ReadonlySet<TestCase>;
379
+ };
380
+ readonly suiteStates?: Map<Suite, Exit.Exit<void, unknown>>;
381
+ readonly onlyMode: boolean;
382
+ readonly emit: (event: TestEvent) => Effect.Effect<void>;
383
+ readonly fileLogs: Array<LogEntry>;
384
+ /** File-level hook failures, counted once for the file in the run summary. */
385
+ readonly fileErrors: Array<string>;
386
+ readonly results: Map<string, { meta: TestMeta; result: TestResult }>;
387
+ readonly interactive: boolean;
388
+ readonly capture: ReturnType<typeof makeCapture>;
389
+ readonly fileLog: FileLog;
390
+ readonly fileFinished: Deferred.Deferred<void>;
391
+ /** Files whose abandoned work may still own resources; their teardown is skipped. */
392
+ readonly abandoned: Set<string>;
393
+ readonly file: string;
394
+ readonly lock: RunLock;
395
+ /** Run-global registry of currently-executing test fibers (for kill). */
396
+ readonly running: Map<string, Fiber.Fiber<unknown, unknown>>;
397
+ /** Run-global set of tests that have finished at least once (for retry). */
398
+ readonly completed: Set<string>;
399
+ }
400
+
401
+ // Weak keys preserve retry identity without retaining finished tests or their suites.
402
+ const testIds = new WeakMap<TestCase, string>();
403
+ let nextTestId = 0;
404
+
405
+ const metaOf = (file: string, test: TestCase): TestMeta => {
406
+ let id = testIds.get(test);
407
+ if (id === undefined) {
408
+ id = `test-${++nextTestId}`;
409
+ testIds.set(test, id);
410
+ }
411
+ const parts = titlePath(test);
412
+ return {
413
+ id,
414
+ file,
415
+ titlePath: parts,
416
+ name: test.name,
417
+ tags: test.tags,
418
+ optInTags: test.optInTags,
419
+ };
420
+ };
421
+
422
+ /** Should this test run at all given only-mode and the -t filter? */
423
+ const included = (
424
+ test: TestCase,
425
+ ctx: Pick<ExecContext, "onlyMode" | "options" | "file">,
426
+ ): boolean => {
427
+ if (ctx.options.selectedTests !== undefined && !ctx.options.selectedTests.has(test)) return false;
428
+ if (ctx.options.tagsFilter !== undefined && !ctx.options.tagsFilter(test.tags, test.optInTags)) {
429
+ return false;
430
+ }
431
+ if (ctx.options.filter !== undefined) {
432
+ // Match against the full nested title, so `-t` finds a test by any
433
+ // fragment regardless of how it's nested in describe blocks.
434
+ const full = `${ctx.file} > ${titlePath(test).join(" > ")}`;
435
+ if (!ctx.options.filter(full)) {
436
+ return false;
437
+ }
438
+ }
439
+ if (ctx.onlyMode) {
440
+ let node: Suite | TestCase | undefined = test;
441
+ while (node !== undefined) {
442
+ if (node.mode === "only") return true;
443
+ node = node.parent;
444
+ }
445
+ return false;
446
+ }
447
+ return true;
448
+ };
449
+
450
+ const isSkipped = (test: TestCase): "skip" | "todo" | undefined => {
451
+ if (test.mode === "todo") return "todo";
452
+ let node: Suite | TestCase | undefined = test;
453
+ while (node !== undefined) {
454
+ if (node.mode === "skip") return "skip";
455
+ node = node.parent;
456
+ }
457
+ // Wrappers (e.g. alchemy's `test.skipIf`) drop the body of skipped tests,
458
+ // so a missing body only means "todo" once skip has been ruled out.
459
+ return test.body === undefined ? "todo" : undefined;
460
+ };
461
+
462
+ const hookChain = (test: TestCase, kind: "beforeEach" | "afterEach"): Array<Hook> => {
463
+ const chain: Array<Array<Hook>> = [];
464
+ let suite: Suite | undefined = test.parent;
465
+ while (suite !== undefined) {
466
+ chain.unshift(suite[kind]);
467
+ suite = suite.parent;
468
+ }
469
+ const flat = chain.flat();
470
+ return kind === "afterEach" ? flat.reverse() : flat;
471
+ };
472
+
473
+ /**
474
+ * Run hooks in order, each bounded by {@link runWithDeadline}: a hook that
475
+ * outlives its timeout (`hook.timeout`, else the test/file default) fails
476
+ * and is abandoned after the teardown grace, exactly like a test body.
477
+ */
478
+ const runHooks = (
479
+ hooks: ReadonlyArray<Hook>,
480
+ defaultTimeout: number,
481
+ ctx: ExecContext,
482
+ ): Effect.Effect<void, unknown> =>
483
+ Effect.forEach(
484
+ hooks,
485
+ (hook) =>
486
+ runWithDeadline(hook.body, hook.timeout ?? defaultTimeout, "hook", ctx).pipe(
487
+ Effect.flatMap((exit) =>
488
+ Exit.isFailure(exit) ? Effect.failCause(exit.cause) : Effect.void,
489
+ ),
490
+ ),
491
+ { discard: true },
492
+ );
493
+
494
+ const prettyCause = (cause: Cause.Cause<unknown>): string => {
495
+ const rendered = Cause.pretty(cause);
496
+ return stripAnsi(rendered.trim().length === 0 ? inspect(Cause.squash(cause)) : rendered);
497
+ };
498
+
499
+ const wasInterrupted = (exit: Exit.Exit<unknown, unknown>): boolean =>
500
+ Exit.isFailure(exit) && exit.cause.reasons.some((reason) => reason._tag === "Interrupt");
501
+
502
+ interface TestAttempt {
503
+ readonly beforeEach: Exit.Exit<void, unknown>;
504
+ readonly body: Exit.Exit<unknown, unknown> | undefined;
505
+ readonly afterEach: Exit.Exit<void, unknown>;
506
+ }
507
+
508
+ const attemptNeedsRetry = (
509
+ exit: Exit.Exit<TestAttempt, unknown>,
510
+ expectsFailure: boolean | undefined,
511
+ ): boolean => {
512
+ if (Exit.isFailure(exit)) return !wasInterrupted(exit);
513
+ if (Exit.isFailure(exit.value.beforeEach) || Exit.isFailure(exit.value.afterEach)) {
514
+ return true;
515
+ }
516
+ return !expectsFailure && exit.value.body !== undefined && Exit.isFailure(exit.value.body);
517
+ };
518
+
519
+ const hookError = (attempt: TestAttempt): string | undefined => {
520
+ const errors: Array<string> = [];
521
+ if (Exit.isFailure(attempt.beforeEach)) {
522
+ errors.push(`beforeEach hook failed:\n${prettyCause(attempt.beforeEach.cause)}`);
523
+ }
524
+ if (Exit.isFailure(attempt.afterEach)) {
525
+ errors.push(`afterEach hook failed:\n${prettyCause(attempt.afterEach.cause)}`);
526
+ }
527
+ return errors.length === 0 ? undefined : errors.join("\n\n");
528
+ };
529
+
530
+ /**
531
+ * How long a timed-out test body's or hook's interruption (finalizers
532
+ * included) may run before the runner abandons the fiber and reports the
533
+ * timeout anyway. Without this bound, a finalizer blocked on the same wedged
534
+ * machinery the timeout just interrupted (e.g. a `test.provider` scratch
535
+ * destroy against a hung dev deploy) — or simply an uninterruptible region —
536
+ * swallows the report entirely: the test/hook never finishes and the run
537
+ * (or the `--plan` phase) never advances.
538
+ *
539
+ * Overridable via `ALCHEMY_TEST_TEARDOWN_GRACE_MS` (used by the runner's own
540
+ * regression tests to keep them fast).
541
+ */
542
+ const INTERRUPT_GRACE_MS = (() => {
543
+ const raw = Number(process.env.ALCHEMY_TEST_TEARDOWN_GRACE_MS);
544
+ return Number.isFinite(raw) && raw >= 0 ? raw : 10_000;
545
+ })();
546
+
547
+ /**
548
+ * Run `body` with a deadline that can NEVER be outlived. Used for every test
549
+ * body and every hook (beforeAll / beforeEach / afterEach / afterAll / file
550
+ * cleanups).
551
+ *
552
+ * `Effect.timeout` is not enough: it interrupts the loser and AWAITS its
553
+ * interruption, which never completes when the work is in an uninterruptible
554
+ * region or a finalizer hangs. Instead the work runs in a detached fiber; on
555
+ * timeout (or when the caller is interrupted) it is interrupted, given
556
+ * {@link INTERRUPT_GRACE_MS} to settle, and then abandoned (it dies with the
557
+ * process) while the timeout is reported and the run continues. An abandoned
558
+ * fiber may still own its file's resources, so the file is marked abandoned
559
+ * and its remaining teardown (afterEach / afterAll / file cleanups) is skipped
560
+ * rather than racing it.
561
+ */
562
+ const runWithDeadline = Effect.fnUntraced(function* (
563
+ body: () => Effect.Effect<unknown, unknown>,
564
+ timeoutMs: number,
565
+ label: "test" | "hook",
566
+ ctx: Pick<ExecContext, "abandoned" | "file">,
567
+ ) {
568
+ // Detached: work whose teardown never settles must not block the caller
569
+ // fiber's own completion (a supervised child would).
570
+ const fiber = yield* Effect.forkDetach(Effect.interruptible(Effect.suspend(body)), {
571
+ startImmediately: true,
572
+ });
573
+ // Fire-and-forget interrupt: `Fiber.interrupt` AWAITS settlement, which a
574
+ // hung finalizer never provides. Fire it, then give teardown a bounded
575
+ // grace before abandoning the fiber. Succeeds with whether it settled.
576
+ const settle = Effect.gen(function* () {
577
+ fiber.interruptUnsafe();
578
+ const settled = yield* Fiber.await(fiber).pipe(
579
+ Effect.timeoutOption(Duration.millis(INTERRUPT_GRACE_MS)),
580
+ );
581
+ if (Option.isNone(settled)) ctx.abandoned.add(ctx.file);
582
+ return Option.isSome(settled);
583
+ });
584
+ return yield* Effect.gen(function* () {
585
+ const awaited = yield* Fiber.await(fiber).pipe(
586
+ Effect.timeoutOption(Duration.millis(timeoutMs)),
587
+ );
588
+ if (Option.isSome(awaited)) return awaited.value as Exit.Exit<unknown, unknown>;
589
+ const settled = yield* settle;
590
+ return Exit.fail(
591
+ new Error(
592
+ !settled
593
+ ? `${label} timed out after ${timeoutMs}ms (teardown did not settle within ${INTERRUPT_GRACE_MS}ms and was abandoned)`
594
+ : `${label} timed out after ${timeoutMs}ms`,
595
+ ),
596
+ ) as Exit.Exit<unknown, unknown>;
597
+ }).pipe(Effect.onExit((exit) => (wasInterrupted(exit) ? settle : Effect.void)));
598
+ });
599
+
600
+ const runTest = Effect.fn(function* (test: TestCase, ctx: ExecContext) {
601
+ const meta = metaOf(ctx.file, test);
602
+ const skipped = isSkipped(test);
603
+ if (skipped !== undefined) {
604
+ const result: TestResult = {
605
+ status: skipped,
606
+ durationMs: 0,
607
+ logs: [],
608
+ retries: 0,
609
+ };
610
+ ctx.results.set(meta.id, {
611
+ meta,
612
+ result: ctx.interactive || result.status === "fail" ? result : { ...result, logs: [] },
613
+ });
614
+ yield* ctx.emit({ _tag: "TestEnd", test: meta, result });
615
+ return;
616
+ }
617
+
618
+ // One stable buffer for the whole runTest call (cleared in place between
619
+ // retry attempts) — TestStart shares the LIVE reference so the TUI can
620
+ // tail a running test's output.
621
+ const logs = ctx.capture.create((entry) =>
622
+ ctx.fileLog.appendTestLine(`${meta.file} > ${meta.titlePath.join(" > ")}`, entry),
623
+ );
624
+ yield* ctx.emit({ _tag: "TestStart", test: meta, logs });
625
+
626
+ const timeoutMs = test.timeout ?? ctx.options.timeout;
627
+ const before = hookChain(test, "beforeEach");
628
+ const after = hookChain(test, "afterEach");
629
+
630
+ const attempt = (): Effect.Effect<TestAttempt> => {
631
+ // Store the finalizer's Exit separately so a teardown failure cannot be
632
+ // swallowed or mistaken for an expected (`test.fails`) body failure.
633
+ let afterExit: Exit.Exit<void, unknown> = Exit.succeed(undefined);
634
+ return Effect.gen(function* () {
635
+ const beforeExit = yield* runHooks(before, timeoutMs, ctx).pipe(Effect.exit);
636
+ const bodyExit = Exit.isSuccess(beforeExit)
637
+ ? yield* runWithDeadline(test.body!, timeoutMs, "test", ctx)
638
+ : undefined;
639
+ return { beforeEach: beforeExit, body: bodyExit };
640
+ }).pipe(
641
+ // afterEach must run on success, failure and interruption alike. Its
642
+ // own Exit is recorded without making the finalizer itself fail.
643
+ Effect.onExit(() =>
644
+ ctx.abandoned.has(ctx.file)
645
+ ? Effect.void
646
+ : runHooks(after, timeoutMs, ctx).pipe(
647
+ Effect.exit,
648
+ Effect.tap((exit) =>
649
+ Effect.sync(() => {
650
+ afterExit = exit;
651
+ }),
652
+ ),
653
+ Effect.asVoid,
654
+ ),
655
+ ),
656
+ Effect.map(({ beforeEach, body }) => ({
657
+ beforeEach,
658
+ body,
659
+ afterEach: afterExit,
660
+ })),
661
+ withCapture(logs),
662
+ );
663
+ };
664
+
665
+ let durationMs = 0;
666
+ let retries = 0;
667
+ const withLock = ctx.lock.withLock(testLock(test));
668
+
669
+ // Each attempt runs in its own fiber, registered run-globally so the TUI's
670
+ // kill command can interrupt it.
671
+ const runAttempt = Effect.fn(function* (): Generator<
672
+ Effect.Effect<any>,
673
+ Exit.Exit<TestAttempt, unknown>
674
+ > {
675
+ const fiber = yield* Effect.forkChild(
676
+ withLock(
677
+ Effect.suspend(() => {
678
+ // Queue time behind an exclusive test is not execution time. Sum only
679
+ // attempts (including their hooks), retaining time spent on retries.
680
+ const start = Date.now();
681
+ return ctx.emit({ _tag: "TestLocked", test: meta }).pipe(
682
+ Effect.andThen(attempt()),
683
+ Effect.ensuring(
684
+ Effect.sync(() => {
685
+ durationMs += Date.now() - start;
686
+ }),
687
+ ),
688
+ );
689
+ }),
690
+ ),
691
+ {
692
+ startImmediately: true,
693
+ },
694
+ );
695
+ ctx.running.set(meta.id, fiber);
696
+ return yield* Fiber.await(fiber).pipe(
697
+ Effect.ensuring(Fiber.interrupt(fiber)),
698
+ Effect.ensuring(Effect.sync(() => ctx.running.delete(meta.id))),
699
+ );
700
+ });
701
+
702
+ let exit = yield* runAttempt();
703
+ while (attemptNeedsRetry(exit, test.fails) && retries < (test.retry ?? ctx.options.retry)) {
704
+ retries++;
705
+ // Clear IN PLACE — TestStart handed this array's reference out.
706
+ ctx.capture.release(logs);
707
+ exit = yield* runAttempt();
708
+ }
709
+
710
+ let status: TestResult["status"];
711
+ let error: string | undefined;
712
+ if (wasInterrupted(exit)) {
713
+ status = "fail";
714
+ error = "killed (interrupted by user)";
715
+ } else if (Exit.isFailure(exit)) {
716
+ status = "fail";
717
+ error = prettyCause(exit.cause);
718
+ } else {
719
+ const failedHook = hookError(exit.value);
720
+ const bodyExit = exit.value.body;
721
+ if (failedHook !== undefined) {
722
+ status = "fail";
723
+ error = failedHook;
724
+ } else if (test.fails && bodyExit !== undefined) {
725
+ if (Exit.isFailure(bodyExit)) {
726
+ status = "pass";
727
+ } else {
728
+ status = "fail";
729
+ error = "expected test to fail, but it passed";
730
+ }
731
+ } else if (bodyExit !== undefined && Exit.isSuccess(bodyExit)) {
732
+ status = "pass";
733
+ } else {
734
+ status = "fail";
735
+ error = bodyExit === undefined ? "test body did not run" : prettyCause(bodyExit.cause);
736
+ }
737
+ }
738
+
739
+ if (ctx.interactive) ctx.completed.add(meta.id);
740
+ const result: TestResult = { status, durationMs, error, logs, retries };
741
+ ctx.results.set(meta.id, {
742
+ meta,
743
+ result: ctx.interactive || result.status === "fail" ? result : { ...result, logs: [] },
744
+ });
745
+ yield* ctx.emit({ _tag: "TestEnd", test: meta, result });
746
+ if (!ctx.interactive && status !== "fail") ctx.capture.release(logs);
747
+ });
748
+
749
+ /** Fail every (non-skipped) test in a subtree without running it. */
750
+ const failSubtree = Effect.fn(function* (suite: Suite, ctx: ExecContext, error: string) {
751
+ const tests: Array<TestCase> = [];
752
+ const collect = (s: Suite) => {
753
+ for (const child of s.children) {
754
+ if (child.type === "test") tests.push(child);
755
+ else collect(child);
756
+ }
757
+ };
758
+ collect(suite);
759
+ for (const test of tests) {
760
+ const meta = metaOf(ctx.file, test);
761
+ if (!included(test, ctx)) continue;
762
+ const skipped = isSkipped(test);
763
+ const result: TestResult =
764
+ skipped !== undefined
765
+ ? { status: skipped, durationMs: 0, logs: [], retries: 0 }
766
+ : {
767
+ status: "fail",
768
+ durationMs: 0,
769
+ error: `beforeAll hook failed:\n${error}`,
770
+ logs: [],
771
+ retries: 0,
772
+ };
773
+ ctx.results.set(meta.id, {
774
+ meta,
775
+ result: ctx.interactive || result.status === "fail" ? result : { ...result, logs: [] },
776
+ });
777
+ yield* ctx.emit({ _tag: "TestEnd", test: meta, result });
778
+ }
779
+ });
780
+
781
+ const runSuite: (suite: Suite, ctx: ExecContext) => Effect.Effect<void> = Effect.fn(function* (
782
+ suite: Suite,
783
+ ctx: ExecContext,
784
+ ) {
785
+ const runnable = suite.children.filter((child) =>
786
+ child.type === "test" ? included(child, ctx) : suiteHasIncludedTests(child, ctx),
787
+ );
788
+ if (runnable.length === 0) return;
789
+
790
+ // If every included test below is skipped (e.g. describe.skip), report
791
+ // them without running any hooks.
792
+ if (!suiteHasRunnableTests(suite, ctx)) {
793
+ yield* Effect.forEach(
794
+ runnable,
795
+ (child) => (child.type === "test" ? runTest(child, ctx) : runSuite(child, ctx)),
796
+ { discard: true },
797
+ );
798
+ return;
799
+ }
800
+
801
+ // A plan runs a suite's beforeAll once, in the first phase that reaches
802
+ // it; later phases reuse (or re-report the failure of) that setup.
803
+ const previousSetup = ctx.suiteStates?.get(suite);
804
+ if (previousSetup !== undefined && Exit.isFailure(previousSetup)) {
805
+ yield* failSubtree(suite, ctx, prettyCause(previousSetup.cause));
806
+ return;
807
+ }
808
+
809
+ const body = Effect.gen(function* () {
810
+ // beforeAll — captured into the file-level log buffer. Emits hook events
811
+ // so the TUI can show "setting up" instead of an unexplained queue.
812
+ if (previousSetup === undefined && suite.beforeAll.length > 0) {
813
+ yield* ctx.emit({
814
+ _tag: "HookStart",
815
+ file: ctx.file,
816
+ hook: "beforeAll",
817
+ });
818
+ // Honor `exclusive` on the hook (`true` for the whole run, a label
819
+ // like "hetzner" for a shared quota). Non-exclusive beforeAll keeps
820
+ // the default 1-permit slot so unrelated files can still run.
821
+ const exit = yield* ctx.lock
822
+ .withLock(hookLock(suite.beforeAll))(runHooks(suite.beforeAll, ctx.options.timeout, ctx))
823
+ .pipe(withCapture(ctx.fileLogs), Effect.exit);
824
+ yield* ctx.emit({ _tag: "HookEnd", file: ctx.file, hook: "beforeAll" });
825
+ ctx.suiteStates?.set(suite, exit);
826
+ if (Exit.isFailure(exit)) {
827
+ yield* failSubtree(suite, ctx, prettyCause(exit.cause));
828
+ return;
829
+ }
830
+ }
831
+
832
+ if (ctx.suiteStates !== undefined && !ctx.suiteStates.has(suite))
833
+ ctx.suiteStates.set(suite, Exit.succeed(undefined));
834
+
835
+ const sequential = suite.sequential || ctx.options.sequential;
836
+ yield* Effect.forEach(
837
+ runnable,
838
+ (child) => (child.type === "test" ? runTest(child, ctx) : runSuite(child, ctx)),
839
+ { concurrency: sequential ? 1 : "unbounded", discard: true },
840
+ );
841
+ });
842
+
843
+ if (ctx.suiteStates === undefined)
844
+ return yield* body.pipe(Effect.ensuring(runAfterAll(suite, ctx)));
845
+ // Planned runs defer afterAll to the file's last phase. Record a setup
846
+ // interrupted before it settled so that teardown still covers it.
847
+ return yield* body.pipe(
848
+ Effect.onInterrupt(() =>
849
+ Effect.sync(() => {
850
+ if (!ctx.suiteStates!.has(suite)) ctx.suiteStates!.set(suite, Exit.interrupt());
851
+ }),
852
+ ),
853
+ );
854
+ });
855
+
856
+ const runAfterAll = Effect.fn(function* (suite: Suite, ctx: ExecContext) {
857
+ if (suite.afterAll.length === 0 || ctx.abandoned.has(ctx.file)) return;
858
+ yield* ctx.emit({ _tag: "HookStart", file: ctx.file, hook: "afterAll" });
859
+ // Unlike beforeAll (where a failure invalidates everything after it),
860
+ // every teardown hook runs even when an earlier one fails: a failing
861
+ // teardown assertion must not drop later cleanup — in particular
862
+ // Test.make's fallback hook that closes the shared scope and local
863
+ // provider sidecar, which registers last and would otherwise leak the
864
+ // sidecar for the rest of the process. Failures aggregate.
865
+ const afterAllRun = Effect.forEach(suite.afterAll, (hook) =>
866
+ ctx.abandoned.has(ctx.file)
867
+ ? Effect.succeed(Exit.void)
868
+ : runWithDeadline(hook.body, hook.timeout ?? ctx.options.timeout, "hook", ctx),
869
+ );
870
+ const exits = yield* ctx.lock
871
+ .withLock(hookLock(suite.afterAll))(afterAllRun)
872
+ .pipe(withCapture(ctx.fileLogs));
873
+ yield* ctx.emit({ _tag: "HookEnd", file: ctx.file, hook: "afterAll" });
874
+ const failures = exits.filter(Exit.isFailure);
875
+ if (failures.length > 0) {
876
+ const error = `afterAll hook failed:\n${failures
877
+ .map((exit) => prettyCause(exit.cause))
878
+ .join("\n")}`;
879
+ ctx.fileErrors.push(error);
880
+ }
881
+ });
882
+
883
+ const suiteHasIncludedTests = (
884
+ suite: Suite,
885
+ ctx: Pick<ExecContext, "onlyMode" | "options" | "file">,
886
+ ): boolean => {
887
+ for (const child of suite.children) {
888
+ if (child.type === "test" && included(child, ctx)) return true;
889
+ if (child.type === "suite" && suiteHasIncludedTests(child, ctx)) return true;
890
+ }
891
+ return false;
892
+ };
893
+
894
+ /** True if the subtree has at least one included test that will actually run. */
895
+ const suiteHasRunnableTests = (
896
+ suite: Suite,
897
+ ctx: Pick<ExecContext, "onlyMode" | "options" | "file">,
898
+ ): boolean => {
899
+ for (const child of suite.children) {
900
+ if (child.type === "test" && included(child, ctx) && isSkipped(child) === undefined) {
901
+ return true;
902
+ }
903
+ if (child.type === "suite" && suiteHasRunnableTests(child, ctx)) return true;
904
+ }
905
+ return false;
906
+ };
907
+
908
+ // ---------------------------------------------------------------------------
909
+ // run
910
+ // ---------------------------------------------------------------------------
911
+
912
+ export const run = Effect.fn(function* (input: RunOptions) {
913
+ const options = {
914
+ ...input,
915
+ tagsFilter: compileTagsFilter(input.tagsFilter ?? []),
916
+ };
917
+ const reporter = yield* Reporter;
918
+ const path = yield* Path.Path;
919
+ const startedAt = Date.now();
920
+
921
+ // Every event is teed into the persistent run log (`.alchemy/log/test.log`)
922
+ // in addition to the active reporter.
923
+ const fileLog = yield* makeFileLog(options.logFile);
924
+ const capture = makeCapture();
925
+ const emit = (event: TestEvent): Effect.Effect<void> =>
926
+ reporter.emit(event).pipe(Effect.andThen(fileLog.append(event)));
927
+
928
+ const absoluteFiles = yield* discover(input).pipe(Effect.orDie);
929
+ const relative = absoluteFiles.map((f) => path.relative(options.root, f));
930
+ yield* emit({ _tag: "CollectStart", files: relative });
931
+ const roots = relative.map(makeFileSuite);
932
+ const lock = yield* makeRunLock;
933
+ const abandoned = new Set<string>();
934
+ const disposed = new Set<FileSuite>();
935
+ const cleanupErrors = new Map<string, string>();
936
+ const retryFibers = new Set<Fiber.Fiber<void, never>>();
937
+ const running = new Map<string, Fiber.Fiber<unknown, unknown>>();
938
+ const completed = new Set<string>();
939
+ const testIndex = new Map<string, { test: TestCase; ctx: ExecContext }>();
940
+ let acceptingRetries = true;
941
+
942
+ const clearSuite = (suite: Suite): void => {
943
+ for (const child of suite.children) {
944
+ if (child.type === "suite") clearSuite(child);
945
+ }
946
+ suite.children.length = 0;
947
+ suite.beforeAll.length = 0;
948
+ suite.afterAll.length = 0;
949
+ suite.beforeEach.length = 0;
950
+ suite.afterEach.length = 0;
951
+ };
952
+ const disposeFile = Effect.fn(function* (suite: FileSuite) {
953
+ if (disposed.has(suite) || abandoned.has(suite.file)) return;
954
+ disposed.add(suite);
955
+ const errors: Array<string> = [];
956
+ for (const hook of suite.cleanups.splice(0)) {
957
+ const exit = yield* lock.withLock(hookLock([hook]))(
958
+ runWithDeadline(hook.body, hook.timeout ?? options.timeout, "hook", {
959
+ file: suite.file,
960
+ abandoned,
961
+ }),
962
+ );
963
+ if (Exit.isFailure(exit)) errors.push(prettyCause(exit.cause));
964
+ if (abandoned.has(suite.file)) break;
965
+ }
966
+ if (errors.length > 0) {
967
+ cleanupErrors.set(suite.file, `file cleanup failed:\n${errors.join("\n")}`);
968
+ }
969
+ if (!abandoned.has(suite.file)) clearSuite(suite);
970
+ }, Effect.uninterruptible);
971
+ // Registered before imports: partial and never-executed files own resources too.
972
+ yield* Effect.addFinalizer(() =>
973
+ Effect.gen(function* () {
974
+ acceptingRetries = false;
975
+ for (const fiber of retryFibers) fiber.interruptUnsafe();
976
+ yield* Effect.forEach(retryFibers, (fiber) => Fiber.await(fiber), {
977
+ discard: true,
978
+ });
979
+ yield* Effect.forEach(roots, disposeFile, { discard: true });
980
+ testIndex.clear();
981
+ completed.clear();
982
+ running.clear();
983
+ yield* fileLog.close;
984
+ if (cleanupErrors.size > 0) {
985
+ return yield* Effect.die(
986
+ new Error(
987
+ [...cleanupErrors.entries()].map(([file, error]) => `${file}: ${error}`).join("\n\n"),
988
+ ),
989
+ );
990
+ }
991
+ }),
992
+ );
993
+
994
+ // Import every file before running anything so `.only` applies across
995
+ // the whole run. AsyncLocalStorage keeps registrations attached to their
996
+ // file while imports overlap. Bound collection to avoid loader saturation.
997
+ const collectConcurrency =
998
+ options.concurrency === "unbounded" ? 32 : Math.min(options.concurrency, 32);
999
+ // collectFile never fails — import errors are captured on the result.
1000
+ const collected = yield* Effect.forEach(
1001
+ absoluteFiles.map((absolute, i) => [absolute, relative[i]!, roots[i]!] as const),
1002
+ ([absolute, rel, suite]) =>
1003
+ collectFile(absolute, rel, suite).pipe(
1004
+ Effect.tap(() => emit({ _tag: "FileCollected", file: rel })),
1005
+ Effect.uninterruptible,
1006
+ ),
1007
+ { concurrency: collectConcurrency },
1008
+ );
1009
+
1010
+ const onlyMode = collected.some((c) => c.error === undefined && containsOnly(c.suite));
1011
+
1012
+ // Assign each case once, in phase/branch declaration order, before execution.
1013
+ const assigned = new Set<TestCase>();
1014
+ const remainingFileBranches = new Map<string, number>();
1015
+ const candidates: Array<{ file: string; test: TestCase }> = [];
1016
+ const candidateOptions = {
1017
+ ...options,
1018
+ tagsFilter: (tags: ReadonlyArray<string>, optInTags: ReadonlyArray<string> = []) =>
1019
+ options.tagsFilter([...tags, ...optInTags], []),
1020
+ };
1021
+ for (const c of collected) {
1022
+ // Import-failed files keep their partial suite (for cleanup) but run nothing.
1023
+ if (c.error === undefined)
1024
+ forEachTest(c.suite, (test) => {
1025
+ if (included(test, { onlyMode, options: candidateOptions, file: c.file }))
1026
+ candidates.push({ file: c.file, test });
1027
+ });
1028
+ }
1029
+ const phases = (input.plan ?? [{ tags: [] }]).map((phase) =>
1030
+ (Array.isArray(phase) ? phase : [phase]).map((branch) => {
1031
+ const tagsFilter = compileTagsFilter([...(input.tagsFilter ?? []), ...branch.tags]);
1032
+ const selectedTests = new Set<TestCase>();
1033
+ const selectedFiles = new Set<string>();
1034
+ for (const { file, test } of candidates) {
1035
+ if (!assigned.has(test) && tagsFilter(test.tags, test.optInTags)) {
1036
+ assigned.add(test);
1037
+ selectedFiles.add(file);
1038
+ selectedTests.add(test);
1039
+ }
1040
+ }
1041
+ for (const file of selectedFiles) {
1042
+ remainingFileBranches.set(file, (remainingFileBranches.get(file) ?? 0) + 1);
1043
+ }
1044
+ return {
1045
+ ...options,
1046
+ tagsFilter,
1047
+ expressions: [...(input.tagsFilter ?? []), ...branch.tags],
1048
+ selectedTests,
1049
+ concurrency: branch.concurrency ?? options.concurrency,
1050
+ };
1051
+ }),
1052
+ );
1053
+ const allMetas = candidates
1054
+ .filter(({ test }) => assigned.has(test))
1055
+ .map(({ file, test }) => metaOf(file, test));
1056
+ if (input.dryRun) {
1057
+ yield* emit({
1058
+ _tag: "PlanPreview",
1059
+ phases: phases.map((branches) =>
1060
+ branches.map((branch) => {
1061
+ const matched = candidates.filter(({ test }) => branch.selectedTests.has(test));
1062
+ return {
1063
+ tags: branch.expressions,
1064
+ concurrency: branch.concurrency,
1065
+ tests: matched.length,
1066
+ files: new Set(matched.map(({ file }) => file)).size,
1067
+ skipped: matched.filter(({ test }) => isSkipped(test) !== undefined).length,
1068
+ };
1069
+ }),
1070
+ ),
1071
+ });
1072
+ const fileFailures = collected.flatMap((c) =>
1073
+ c.error === undefined ? [] : [{ file: c.file, error: c.error }],
1074
+ );
1075
+ for (const failure of fileFailures) yield* emit({ _tag: "FileEnd", ...failure, logs: [] });
1076
+ const summary: RunSummary = {
1077
+ dryRun: true,
1078
+ files: collected.length,
1079
+ plan: {
1080
+ found: candidates.length,
1081
+ selected: assigned.size,
1082
+ excluded: candidates.length - assigned.size,
1083
+ },
1084
+ passed: 0,
1085
+ failed: fileFailures.length,
1086
+ skipped: 0,
1087
+ todo: 0,
1088
+ durationMs: Date.now() - startedAt,
1089
+ failures: [],
1090
+ fileFailures,
1091
+ };
1092
+ yield* emit({ _tag: "RunEnd", summary });
1093
+ // The run finalizer closes the log after disposing partially imported files.
1094
+ return summary;
1095
+ }
1096
+ yield* emit({
1097
+ _tag: "RunStart",
1098
+ files: collected.length,
1099
+ tests: allMetas,
1100
+ });
1101
+
1102
+ // Phase 2 — run files concurrently.
1103
+ const allResults = new Map<string, { meta: TestMeta; result: TestResult }>();
1104
+ const fileFailures: Array<{ file: string; error: string }> = [];
1105
+ const interactive = reporter.attachController !== undefined;
1106
+
1107
+ // Interactive control (TUI `r` retry / `x` kill). Retried tests re-run as
1108
+ // standalone forked fibers and re-emit TestStart/TestEnd through the same
1109
+ // reporter; the header counters simply update in place.
1110
+ const controller: RunController = {
1111
+ retryTest: (id) => {
1112
+ const entry = testIndex.get(id);
1113
+ if (
1114
+ !acceptingRetries ||
1115
+ entry === undefined ||
1116
+ abandoned.has(entry.ctx.file) ||
1117
+ running.has(id) ||
1118
+ !completed.has(id)
1119
+ )
1120
+ return;
1121
+ completed.delete(id);
1122
+ const fiber = Effect.runFork(
1123
+ Deferred.await(entry.ctx.fileFinished).pipe(Effect.andThen(runTest(entry.test, entry.ctx))),
1124
+ );
1125
+ retryFibers.add(fiber);
1126
+ fiber.addObserver(() => retryFibers.delete(fiber));
1127
+ },
1128
+ retryFile: (file) => {
1129
+ for (const [id, entry] of testIndex) {
1130
+ if (entry.ctx.file === file) controller.retryTest(id);
1131
+ }
1132
+ },
1133
+ killTest: (id) => {
1134
+ const fiber = running.get(id);
1135
+ if (fiber !== undefined) Effect.runFork(Fiber.interrupt(fiber));
1136
+ },
1137
+ };
1138
+ if (reporter.attachController !== undefined) {
1139
+ yield* reporter.attachController(controller);
1140
+ }
1141
+
1142
+ interface FileRun {
1143
+ readonly suite: FileSuite;
1144
+ /** Context of the latest branch; buffers and suite states are shared. */
1145
+ ctx: ExecContext;
1146
+ /** Import and runner errors, reported once when the file finishes. */
1147
+ readonly errors: Array<string>;
1148
+ retryable: boolean;
1149
+ tornDown: boolean;
1150
+ finished: boolean;
1151
+ }
1152
+ const fileRuns = new Map<string, FileRun>();
1153
+ const fileLocks = new Map<string, Semaphore.Semaphore>();
1154
+ for (const c of collected) fileLocks.set(c.file, yield* Semaphore.make(1));
1155
+
1156
+ // Planned runs close cached suites child-first once, after the file's last branch.
1157
+ const teardownSuites = Effect.fn(function* (run: FileRun) {
1158
+ if (run.tornDown || run.ctx.suiteStates === undefined) return;
1159
+ run.tornDown = true;
1160
+ for (const suite of [...run.ctx.suiteStates.keys()].reverse())
1161
+ yield* runAfterAll(suite, run.ctx);
1162
+ });
1163
+
1164
+ const finishFile = Effect.fn(function* (run: FileRun) {
1165
+ if (run.finished) return;
1166
+ run.finished = true;
1167
+ const { ctx } = run;
1168
+ yield* teardownSuites(run);
1169
+ if (!run.retryable) yield* disposeFile(run.suite).pipe(withCapture(ctx.fileLogs));
1170
+ const errors = [...run.errors];
1171
+ if (ctx.fileErrors.length > 0) errors.push(ctx.fileErrors.join("\n\n"));
1172
+ const cleanupError = cleanupErrors.get(ctx.file);
1173
+ cleanupErrors.delete(ctx.file);
1174
+ if (cleanupError !== undefined) errors.push(cleanupError);
1175
+ const fileError = errors.length === 0 ? undefined : errors.join("\n\n");
1176
+ if (fileError !== undefined) {
1177
+ fileFailures.push({ file: ctx.file, error: fileError });
1178
+ }
1179
+ yield* emit({
1180
+ _tag: "FileEnd",
1181
+ file: ctx.file,
1182
+ logs: ctx.fileLogs,
1183
+ error: fileError,
1184
+ });
1185
+ yield* Deferred.succeed(ctx.fileFinished, undefined);
1186
+ });
1187
+
1188
+ const runFile = Effect.fn(function* (c: CollectedFile, branchOptions: ExecContext["options"]) {
1189
+ const previous = fileRuns.get(c.file);
1190
+ if (previous?.finished) return;
1191
+ const fileLogs =
1192
+ previous?.ctx.fileLogs ?? capture.create((entry) => fileLog.appendHookLine(c.file, entry));
1193
+ // Shares the LIVE hook-log buffer so the TUI can tail deploys.
1194
+ if (previous === undefined) yield* emit({ _tag: "FileStart", file: c.file, logs: fileLogs });
1195
+ const ctx: ExecContext = {
1196
+ options: branchOptions,
1197
+ suiteStates: input.plan === undefined ? undefined : (previous?.ctx.suiteStates ?? new Map()),
1198
+ onlyMode,
1199
+ emit,
1200
+ fileLogs,
1201
+ fileErrors: previous?.ctx.fileErrors ?? [],
1202
+ results: allResults,
1203
+ file: c.file,
1204
+ lock,
1205
+ running,
1206
+ completed,
1207
+ interactive,
1208
+ abandoned,
1209
+ capture,
1210
+ fileLog,
1211
+ fileFinished: previous?.ctx.fileFinished ?? (yield* Deferred.make<void>()),
1212
+ };
1213
+ const run: FileRun = previous ?? {
1214
+ suite: c.suite,
1215
+ ctx,
1216
+ errors: [],
1217
+ retryable: false,
1218
+ tornDown: false,
1219
+ finished: false,
1220
+ };
1221
+ run.ctx = ctx;
1222
+ fileRuns.set(c.file, run);
1223
+ if (c.error !== undefined) {
1224
+ if (!run.errors.includes(c.error)) run.errors.push(c.error);
1225
+ } else {
1226
+ if (interactive && suiteHasRunnableTests(c.suite, ctx)) {
1227
+ run.retryable = true;
1228
+ forEachTest(c.suite, (test) => {
1229
+ if (included(test, ctx) && isSkipped(test) === undefined) {
1230
+ testIndex.set(metaOf(c.file, test).id, { test, ctx });
1231
+ }
1232
+ });
1233
+ }
1234
+ const exit = yield* runSuite(c.suite, ctx).pipe(Effect.exit);
1235
+ if (Exit.isFailure(exit)) run.errors.push(prettyCause(exit.cause));
1236
+ }
1237
+ if (input.plan !== undefined && c.error === undefined) {
1238
+ // Count completed visits rather than declaration order: branches in a
1239
+ // phase run concurrently, and any of them may finish this file last.
1240
+ const remaining = remainingFileBranches.get(c.file)! - 1;
1241
+ remainingFileBranches.set(c.file, remaining);
1242
+ // Keep scopes only while another selected branch still needs the file.
1243
+ if (remaining > 0) return;
1244
+ }
1245
+ // Teardown stays inside the file's concurrency slot and per-file lock so
1246
+ // new files cannot accumulate resources retained until the end of a phase.
1247
+ yield* finishFile(run);
1248
+ });
1249
+
1250
+ if (input.plan === undefined) {
1251
+ // Unplanned runs visit every file in order so each finishes (and disposes
1252
+ // its cleanups) as soon as it completes, even when nothing in it runs.
1253
+ const branch = phases[0]![0]!;
1254
+ yield* Effect.forEach(collected, (c) => runFile(c, branch), {
1255
+ concurrency: options.concurrency,
1256
+ discard: true,
1257
+ });
1258
+ } else {
1259
+ // Import errors are reported once even if no branch selects that file.
1260
+ yield* Effect.forEach(
1261
+ collected.filter((c) => c.error !== undefined),
1262
+ (c) => runFile(c, options),
1263
+ { discard: true },
1264
+ );
1265
+ yield* Effect.gen(function* () {
1266
+ for (const [index, branches] of phases.entries()) {
1267
+ yield* emit({
1268
+ _tag: "PlanPhaseStart",
1269
+ phase: index + 1,
1270
+ phases: phases.length,
1271
+ tests: branches.reduce((count, branch) => count + branch.selectedTests.size, 0),
1272
+ });
1273
+ yield* Effect.forEach(
1274
+ branches,
1275
+ (branch) =>
1276
+ Effect.forEach(
1277
+ collected.filter(
1278
+ (c) =>
1279
+ c.error === undefined &&
1280
+ suiteHasIncludedTests(c.suite, {
1281
+ file: c.file,
1282
+ onlyMode,
1283
+ options: branch,
1284
+ }),
1285
+ ),
1286
+ (c) => fileLocks.get(c.file)!.withPermits(1)(runFile(c, branch)),
1287
+ { concurrency: branch.concurrency, discard: true },
1288
+ ),
1289
+ { concurrency: "unbounded", discard: true },
1290
+ );
1291
+ }
1292
+ }).pipe(
1293
+ // An interrupted plan still owes every started suite its afterAll.
1294
+ Effect.ensuring(
1295
+ Effect.suspend(() =>
1296
+ Effect.forEach([...fileRuns.values()], teardownSuites, {
1297
+ concurrency: options.concurrency,
1298
+ discard: true,
1299
+ }),
1300
+ ),
1301
+ ),
1302
+ );
1303
+ }
1304
+
1305
+ const count = (status: TestResult["status"]) => {
1306
+ let total = 0;
1307
+ for (const { result } of allResults.values()) if (result.status === status) total++;
1308
+ return total;
1309
+ };
1310
+ const summary: RunSummary = {
1311
+ files: collected.length,
1312
+ plan:
1313
+ input.plan === undefined
1314
+ ? undefined
1315
+ : {
1316
+ found: candidates.length,
1317
+ selected: assigned.size,
1318
+ excluded: candidates.length - assigned.size,
1319
+ },
1320
+ get passed() {
1321
+ return count("pass");
1322
+ },
1323
+ get failed() {
1324
+ return count("fail") + fileFailures.length;
1325
+ },
1326
+ get skipped() {
1327
+ return count("skip");
1328
+ },
1329
+ get todo() {
1330
+ return count("todo");
1331
+ },
1332
+ durationMs: Date.now() - startedAt,
1333
+ get failures() {
1334
+ return [...allResults.values()].filter(({ result }) => result.status === "fail");
1335
+ },
1336
+ fileFailures,
1337
+ };
1338
+ yield* emit({ _tag: "RunEnd", summary });
1339
+ // The log and retry-owned resources stay open until the reporter exits.
1340
+ return summary;
1341
+ });