@alchemy.run/test 0.0.0 → 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 +73 -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 -9
package/src/Vitest.ts ADDED
@@ -0,0 +1,239 @@
1
+ /** @effect-diagnostics anyUnknownInErrorContext:off */
2
+ import { it } from "@effect/vitest";
3
+ import type { AlchemyContext } from "alchemy/AlchemyContext";
4
+ import type { CompiledStack, StackDefinition } from "alchemy/Stack";
5
+ import type { Stage } from "alchemy/Stage";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Exit from "effect/Exit";
8
+ import * as Scope from "effect/Scope";
9
+ import {
10
+ afterAll as vitestAfterAll,
11
+ afterEach as vitestAfterEach,
12
+ beforeAll as vitestBeforeAll,
13
+ beforeEach as vitestBeforeEach,
14
+ } from "vitest";
15
+ import * as Core from "./Core.ts";
16
+
17
+ export {
18
+ executeWhenReady,
19
+ getWhenReady,
20
+ guardContentType,
21
+ guardedFetchLayer,
22
+ rpcClientLayer,
23
+ WorkerNotReady,
24
+ type EdgeGuardOptions,
25
+ type WhenReadyOptions,
26
+ } from "./Http.ts";
27
+
28
+ export type MakeOptions<ROut = any> = Core.MakeOptions<ROut>;
29
+ export type ScratchStack = Core.ScratchStack;
30
+ export type TestEffect<A, R = never> = Core.TestEffect<A, R>;
31
+ export const defaultStage = Core.defaultStage;
32
+ export const resolveStage = Core.resolveStage;
33
+
34
+ type TestOptions = number | { timeout?: number };
35
+
36
+ const timeoutOf = (opts: TestOptions | undefined): number | undefined =>
37
+ typeof opts === "number" ? opts : opts?.timeout;
38
+
39
+ interface TestFn {
40
+ (name: string, eff: TestEffect<void>, options?: TestOptions): void;
41
+ skip: (name: string, eff: TestEffect<void>, options?: TestOptions) => void;
42
+ skipIf: (
43
+ condition: boolean,
44
+ ) => (name: string, eff: TestEffect<void>, options?: TestOptions) => void;
45
+ only: (name: string, eff: TestEffect<void>, options?: TestOptions) => void;
46
+ todo: (name: string, eff: TestEffect<void>, options?: TestOptions) => void;
47
+ provider: ProviderFn;
48
+ }
49
+
50
+ interface ProviderFn {
51
+ (
52
+ name: string,
53
+ fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
54
+ options?: TestOptions,
55
+ ): void;
56
+ skip: (
57
+ name: string,
58
+ fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
59
+ options?: TestOptions,
60
+ ) => void;
61
+ skipIf: (
62
+ condition: boolean,
63
+ ) => (
64
+ name: string,
65
+ fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
66
+ options?: TestOptions,
67
+ ) => void;
68
+ }
69
+
70
+ interface BeforeAllFn {
71
+ <A>(eff: TestEffect<A>, options?: TestOptions): Effect.Effect<A>;
72
+ }
73
+
74
+ interface BeforeEachFn {
75
+ (eff: TestEffect<void>, options?: TestOptions): void;
76
+ }
77
+
78
+ interface AfterAllFn {
79
+ (eff: TestEffect<any>, options?: TestOptions): void;
80
+ skipIf: (predicate: boolean) => (eff: TestEffect<any>, options?: TestOptions) => void;
81
+ }
82
+
83
+ interface AfterEachFn {
84
+ (eff: TestEffect<void>, options?: TestOptions): void;
85
+ }
86
+
87
+ export interface TestApi {
88
+ test: TestFn;
89
+ beforeAll: BeforeAllFn;
90
+ beforeEach: BeforeEachFn;
91
+ afterAll: AfterAllFn;
92
+ afterEach: AfterEachFn;
93
+ deploy: Core.Deploy;
94
+ destroy: <ROut>(
95
+ stack: StackDefinition<ROut>,
96
+ options?: { stage?: string; include?: never; exclude?: never },
97
+ ) => ReturnType<typeof Core.destroy>;
98
+ }
99
+
100
+ const DEFAULT_TIMEOUT = 120_000;
101
+
102
+ /**
103
+ * Build the per-file test API. See {@link "./Bun.ts"} for the same shape
104
+ * over `bun:test`. Vitest variant uses `@effect/vitest`'s `it.live` so
105
+ * Effect-aware tests stay first-class.
106
+ */
107
+ export const make = <ROut = any>(options: MakeOptions<ROut>): TestApi => {
108
+ // See `Test/Bun.ts` for the rationale: the dev sidecar must outlive a
109
+ // single `runPromise` boundary, so all hooks share one scope that's only
110
+ // closed after `destroy(...)` runs.
111
+ const sharedScope = Scope.makeUnsafe("sequential");
112
+ // In dev mode, run local providers behind one file-scoped RPC sidecar
113
+ // (the `alchemy dev` topology). Lives in its own scope — NOT sharedScope,
114
+ // which `destroy(Stack)` closes mid-file in self-contained tests — and is
115
+ // closed by the fallback afterAll below.
116
+ const sidecar = Core.makeSidecarHandle(options);
117
+ const wrap = <A>(eff: TestEffect<A>) => Core.toEffect(eff, options, sharedScope, sidecar);
118
+ const runEff = <A>(eff: TestEffect<A>) => Core.run(eff, options, sharedScope, sidecar);
119
+
120
+ const test = ((name, eff, opts) => {
121
+ it.live(name, () => wrap(eff), timeoutOf(opts));
122
+ }) as TestFn;
123
+
124
+ test.skip = (name, _eff, opts) => {
125
+ it.skip(name, () => {}, timeoutOf(opts));
126
+ };
127
+ test.skipIf = (condition) => (name, eff, opts) => {
128
+ if (condition) {
129
+ it.skip(name, () => {}, timeoutOf(opts));
130
+ } else {
131
+ it.live(name, () => wrap(eff), timeoutOf(opts));
132
+ }
133
+ };
134
+ test.only = (name, eff, opts) => {
135
+ it.live.only(name, () => wrap(eff), timeoutOf(opts));
136
+ };
137
+ test.todo = (name, _eff, _opts) => {
138
+ it.todo(name);
139
+ };
140
+
141
+ const wrapProvider = (
142
+ name: string,
143
+ fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
144
+ ) => {
145
+ const scratch = Core.scratchStack(options, name);
146
+ // Guarantee teardown. `test.provider` has no built-in cleanup, so a body
147
+ // that fails (assertion, API error like a 409/Unauthorized) or is
148
+ // interrupted (vitest timeout) BEFORE its trailing `stack.destroy()` would
149
+ // otherwise leak every cloud resource it deployed: the scratch's in-memory
150
+ // state is discarded with the process, so no later run can reclaim the
151
+ // orphan (only an account-wide `nuke` can). `scratch.destroy()` is
152
+ // idempotent (empty-plan apply against the shared scratch state) — a no-op
153
+ // when the body already destroyed, and it reclaims the orphans otherwise.
154
+ // `Effect.ensuring` runs the finalizer on success, failure, AND interruption.
155
+ const body = Core.withProviders(fn(scratch), options, scratch.name).pipe(
156
+ Effect.ensuring(scratch.destroy().pipe(Effect.ignore)),
157
+ );
158
+ return Core.toEffect(body, { ...options, state: scratch.state }, sharedScope, sidecar);
159
+ };
160
+
161
+ const provider = ((name, fn, opts) => {
162
+ it.live(name, () => wrapProvider(name, fn), timeoutOf(opts));
163
+ }) as ProviderFn;
164
+ provider.skip = (name, _fn, opts) => {
165
+ it.skip(name, () => {}, timeoutOf(opts));
166
+ };
167
+ provider.skipIf = (condition) => (name, fn, opts) => {
168
+ if (condition) {
169
+ it.skip(name, () => {}, timeoutOf(opts));
170
+ } else {
171
+ it.live(name, () => wrapProvider(name, fn), timeoutOf(opts));
172
+ }
173
+ };
174
+ test.provider = provider;
175
+
176
+ const beforeAll: BeforeAllFn = <A>(eff: TestEffect<A>, hookOptions?: TestOptions) => {
177
+ let result: A;
178
+ vitestBeforeAll(
179
+ () => runEff(eff).then((v) => (result = v)),
180
+ timeoutOf(hookOptions) ?? DEFAULT_TIMEOUT,
181
+ );
182
+ return Effect.sync(() => result);
183
+ };
184
+
185
+ const beforeEach: BeforeEachFn = (eff, hookOptions) => {
186
+ vitestBeforeEach(() => runEff(eff), timeoutOf(hookOptions));
187
+ };
188
+
189
+ const afterAll = ((eff, hookOptions) => {
190
+ vitestAfterAll(() => runEff(eff), timeoutOf(hookOptions) ?? DEFAULT_TIMEOUT);
191
+ }) as AfterAllFn;
192
+ afterAll.skipIf = (predicate) => (eff, hookOptions) => {
193
+ if (predicate) return;
194
+ vitestAfterAll(() => runEff(eff), timeoutOf(hookOptions) ?? DEFAULT_TIMEOUT);
195
+ };
196
+
197
+ const afterEach: AfterEachFn = (eff, hookOptions) => {
198
+ vitestAfterEach(() => runEff(eff), timeoutOf(hookOptions));
199
+ };
200
+
201
+ // `destroy(Stack)` needs the dev sidecar alive so it can call `sidecar.stop`.
202
+ // `Scope.close` on an already-closed scope is a no-op, so the destroy
203
+ // wrapper AND the fallback cleanup hook below can both call it safely.
204
+ const closeScope = Effect.suspend(() => Scope.close(sharedScope, Exit.void)).pipe(Effect.ignore);
205
+
206
+ // Fallback cleanup: if the user never calls `destroy(Stack)` (e.g.
207
+ // `NO_DESTROY=1`), nothing else closes the shared scope and the sidecar
208
+ // child process leaks past the test process. The RPC sidecar lives in its
209
+ // own scope so that mid-file `destroy(Stack)` calls can't kill it for later
210
+ // tests.
211
+ //
212
+ // The cleanup must run after every user `afterAll` (including
213
+ // `destroy(Stack)`, which needs the sidecar). Vitest's default
214
+ // `sequence.hooks: "stack"` runs `afterAll` hooks in reverse registration
215
+ // order and `"list"` runs them in registration order, so register the
216
+ // cleanup both before (now) and after (next microtask) the file's
217
+ // `afterAll` calls, and only close on the second invocation: whichever
218
+ // order is configured, that one runs last.
219
+ const closeAll = sidecar ? Effect.andThen(closeScope, sidecar.close) : closeScope;
220
+ let cleanupCalls = 0;
221
+ const cleanup = () => (++cleanupCalls === 2 ? Effect.runPromise(closeAll) : undefined);
222
+ vitestAfterAll(cleanup, DEFAULT_TIMEOUT);
223
+ queueMicrotask(() => {
224
+ vitestAfterAll(cleanup, DEFAULT_TIMEOUT);
225
+ });
226
+
227
+ return {
228
+ test,
229
+ beforeAll,
230
+ beforeEach,
231
+ afterAll,
232
+ afterEach,
233
+ deploy: Core.makeDeploy(options, sharedScope),
234
+ destroy: (stack, callOpts) =>
235
+ Core.destroy(options, stack, { ...callOpts, scope: sharedScope }).pipe(
236
+ Effect.ensuring(closeScope),
237
+ ),
238
+ };
239
+ };
@@ -0,0 +1,45 @@
1
+ import { userStage } from "alchemy/Cli/commands/flags";
2
+ import * as Effect from "effect/Effect";
3
+ import * as FileSystem from "effect/FileSystem";
4
+ import * as Path from "effect/Path";
5
+
6
+ /**
7
+ * The name of the linked git worktree containing `cwd`, or `undefined` in a
8
+ * main checkout (or outside git). A linked worktree's `.git` is a file
9
+ * pointing at `<common>/.git/worktrees/<name>`; a submodule's points at
10
+ * `.git/modules/<name>` and doesn't count.
11
+ */
12
+ export const worktreeName = Effect.fn(function* (cwd: string) {
13
+ const fs = yield* FileSystem.FileSystem;
14
+ const path = yield* Path.Path;
15
+ let dir = cwd;
16
+ while (true) {
17
+ const dotGit = path.join(dir, ".git");
18
+ if (yield* fs.exists(dotGit)) {
19
+ const info = yield* fs.stat(dotGit);
20
+ if (info.type !== "File") return undefined;
21
+ const gitdir = /^gitdir:\s*(.+)$/m.exec(yield* fs.readFileString(dotGit))?.[1]?.trim();
22
+ const parts = gitdir?.split(/[\\/]+/).filter(Boolean) ?? [];
23
+ return parts.at(-2) === "worktrees" ? parts.at(-1) : undefined;
24
+ }
25
+ const parent = path.dirname(dir);
26
+ if (parent === dir) return undefined;
27
+ dir = parent;
28
+ }
29
+ });
30
+
31
+ /**
32
+ * The default test stage for a linked worktree: `test_$USER_<worktree>`, so
33
+ * concurrent runs from two worktrees of one developer never share
34
+ * deployments. `undefined` in a main checkout, which keeps `test_$USER`.
35
+ */
36
+ export const worktreeStage = Effect.fn(function* (cwd: string) {
37
+ const name = yield* worktreeName(cwd).pipe(Effect.orElseSucceed(() => undefined));
38
+ if (name === undefined) return undefined;
39
+ const safe = name
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9_-]+/g, "-")
42
+ .replace(/^-+|-+$/g, "");
43
+ if (safe === "") return undefined;
44
+ return `${yield* userStage("test")}_${safe}`;
45
+ });
package/src/cli.ts ADDED
@@ -0,0 +1,330 @@
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] [--stage 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 Argument from "effect/cli/Argument";
26
+ import * as Command from "effect/cli/Command";
27
+ import * as Flag from "effect/cli/Flag";
28
+ import * as Data from "effect/Data";
29
+ import * as Effect from "effect/Effect";
30
+ import * as FileSystem from "effect/FileSystem";
31
+ import * as Layer from "effect/Layer";
32
+ import * as Option from "effect/Option";
33
+ import * as Path from "effect/Path";
34
+ import * as Runtime from "effect/Runtime";
35
+ import * as Schema from "effect/Schema";
36
+ import packageJson from "../package.json" with { type: "json" };
37
+ import { PlainReporterLive, printSummary } from "./PlainReporter.ts";
38
+ import { parsePlan } from "./Plan.ts";
39
+ import { Reporter } from "./Reporter.ts";
40
+ import { run, type RunOptions } from "./Runner.ts";
41
+ import { captureStrayOutput } from "./StrayOutput.ts";
42
+ import { worktreeStage } from "./WorktreeStage.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("Collect tests and show the execution plan without running tests or hooks"),
78
+ Flag.withDefault(false),
79
+ );
80
+
81
+ const timeout = Flag.Int("timeout").pipe(
82
+ Flag.withDescription("Default per-test timeout in milliseconds"),
83
+ Flag.withDefault(120_000),
84
+ );
85
+
86
+ const retry = Flag.Int("retry").pipe(
87
+ Flag.withDescription("Times a failing test is retried before failing the run"),
88
+ Flag.withDefault(2),
89
+ );
90
+
91
+ const concurrency = Flag.String("concurrency").pipe(
92
+ Flag.withAlias("c"),
93
+ Flag.withDescription(
94
+ '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.',
95
+ ),
96
+ Flag.withDefault("32"),
97
+ );
98
+
99
+ const toConcurrency = (value: string): number | "unbounded" => {
100
+ if (value === "unbounded") return "unbounded";
101
+ const parsed = Number.parseInt(value, 10);
102
+ if (!Number.isFinite(parsed) || parsed < 1) {
103
+ throw new Error(`--concurrency must be a positive integer or "unbounded", got: ${value}`);
104
+ }
105
+ return parsed;
106
+ };
107
+
108
+ /** `--tui` was requested but the optional `@opentui/core` peer isn't installed. */
109
+ class TuiUnavailable extends Data.TaggedError("TuiUnavailable")<{
110
+ readonly message: string;
111
+ readonly cause: unknown;
112
+ }> {}
113
+
114
+ // The TUI's renderer (`@opentui/core`) is an optional peer, so it loads only
115
+ // when `--tui` asks for it.
116
+ const tuiReporter = (logFile: string) =>
117
+ Layer.unwrap(
118
+ Effect.tryPromise({
119
+ try: () => import("./Tui.ts"),
120
+ catch: (cause) =>
121
+ new TuiUnavailable({
122
+ message: "--tui needs @opentui/core. Install it with `bun add -d @opentui/core`.",
123
+ cause,
124
+ }),
125
+ }).pipe(Effect.map(({ TuiReporter }) => TuiReporter(logFile))),
126
+ );
127
+
128
+ const sequential = Flag.Boolean("sequential").pipe(
129
+ Flag.withDescription("Run tests within each file sequentially"),
130
+ Flag.withDefault(false),
131
+ );
132
+
133
+ const tui = Flag.Boolean("tui").pipe(
134
+ Flag.withDescription("Opt in to the interactive TUI (default is plain line output)"),
135
+ Flag.withDefault(false),
136
+ );
137
+
138
+ const profile = Flag.String("profile").pipe(
139
+ Flag.withDescription(
140
+ 'Set ALCHEMY_PROFILE for the run (e.g. "testing") before any test module is imported',
141
+ ),
142
+ Flag.optional,
143
+ );
144
+
145
+ const stage = Flag.String("stage").pipe(
146
+ Flag.withSchema(Schema.String.check(Schema.isPattern(/^[a-z0-9]+([-_a-z0-9]+)*$/i))),
147
+ Flag.withDescription(
148
+ 'Set ALCHEMY_TEST_STAGE for the run (e.g. "test_sam_feat-x") before any test module is imported',
149
+ ),
150
+ Flag.optional,
151
+ );
152
+
153
+ const fast = Flag.Boolean("fast").pipe(
154
+ Flag.withDescription(
155
+ "Set FAST=1 — suites skip their slow tests (long-provisioning resources, smoke tests)",
156
+ ),
157
+ Flag.withDefault(false),
158
+ );
159
+
160
+ /**
161
+ * Build the `-t` matcher — bun/vitest semantics: the pattern is a regex
162
+ * tested against the full nested title (`file > describe chain > name`).
163
+ * A syntactically INVALID regex (e.g. `-t "[worker"`) degrades to a literal
164
+ * substring match instead of erroring.
165
+ */
166
+ const toFilter = (pattern: Option.Option<string>): ((fullTitle: string) => boolean) | undefined =>
167
+ Option.match(pattern, {
168
+ onNone: () => undefined,
169
+ onSome: (source) => {
170
+ let regex: RegExp;
171
+ try {
172
+ regex = new RegExp(source);
173
+ } catch {
174
+ // Not a valid regex — treat it as a literal substring.
175
+ regex = new RegExp(source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
176
+ }
177
+ return (fullTitle) => regex.test(fullTitle);
178
+ },
179
+ });
180
+
181
+ const rootCommand = Command.make(
182
+ "alchemy-test",
183
+ {
184
+ paths,
185
+ testNamePattern,
186
+ tagsFilter,
187
+ plan,
188
+ dryRun,
189
+ exclude,
190
+ timeout,
191
+ retry,
192
+ concurrency,
193
+ sequential,
194
+ tui,
195
+ profile,
196
+ stage,
197
+ fast,
198
+ },
199
+ Effect.fn(function* (args) {
200
+ // Environment knobs — set BEFORE any test module is imported (imports
201
+ // happen inside `run` during collection), so `skipIf(process.env.FAST)`
202
+ // gates and profile-dependent layers see the final values.
203
+ // Inherit CI from the caller: setting it here also disables local auth
204
+ // profiles, even when the caller explicitly selected one.
205
+ yield* Effect.sync(() => {
206
+ if (Option.isSome(args.profile)) {
207
+ process.env.ALCHEMY_PROFILE = args.profile.value;
208
+ }
209
+ if (Option.isSome(args.stage)) {
210
+ process.env.ALCHEMY_TEST_STAGE = args.stage.value;
211
+ }
212
+ if (args.fast) {
213
+ process.env.FAST = "1";
214
+ }
215
+ // The runner owns the terminal: stdout belongs to the reporter (or
216
+ // the TUI) and stdin carries TUI keystrokes. Code under test must see
217
+ // the same non-interactive, colorless process CI gives it, regardless
218
+ // of the terminal this run was launched from — otherwise assertions
219
+ // on CLI output and on interactive-vs-plain copy depend on whether a
220
+ // human or a pipeline started the run. Sigil's detection honors
221
+ // FORCE_COLOR over everything else, so pin it rather than NO_COLOR.
222
+ process.env.ALCHEMY_NO_TUI = "1";
223
+ process.env.NO_COLOR = "1";
224
+ process.env.FORCE_COLOR = "0";
225
+ });
226
+ // A linked git worktree defaults to its own stage, so two worktrees of
227
+ // one developer running the same suite never share deployments. An
228
+ // explicit `--stage` or ALCHEMY_TEST_STAGE wins.
229
+ if (Option.isNone(args.stage) && !process.env.ALCHEMY_TEST_STAGE) {
230
+ const stage = yield* worktreeStage(yield* Effect.sync(() => process.cwd()));
231
+ if (stage !== undefined) {
232
+ yield* Effect.sync(() => {
233
+ process.env.ALCHEMY_TEST_STAGE = stage;
234
+ });
235
+ }
236
+ }
237
+
238
+ // Plain line output by default; the TUI is opt-in (`--tui`) and requires
239
+ // an interactive terminal.
240
+ const interactive = !args.dryRun && args.tui && process.stdout.isTTY === true;
241
+ const path = yield* Path.Path;
242
+ const root = process.cwd();
243
+ // Per-run log file (timestamp + pid) so concurrent runs in different
244
+ // terminals never trample each other's logs.
245
+ const timestamp = new Date().toISOString().slice(0, 19).replaceAll(":", "-");
246
+ const logFile = path.resolve(
247
+ root,
248
+ ".alchemy",
249
+ "log",
250
+ "test",
251
+ `${timestamp}-pid${process.pid}.log`,
252
+ );
253
+
254
+ const options: RunOptions = {
255
+ root,
256
+ paths: args.paths,
257
+ exclude: args.exclude,
258
+ filter: toFilter(args.testNamePattern),
259
+ tagsFilter: args.tagsFilter,
260
+ dryRun: args.dryRun,
261
+ plan: Option.isSome(args.plan) ? parsePlan(args.plan.value) : undefined,
262
+ timeout: args.timeout,
263
+ retry: args.retry,
264
+ concurrency: toConcurrency(args.concurrency),
265
+ sequential: args.sequential,
266
+ logFile,
267
+ };
268
+
269
+ // In plain mode, divert stray JS-level stdout/stderr writes into the
270
+ // run log for the duration of the run (the reporter writes through the
271
+ // real stream); the TUI installs its own diversion after the renderer
272
+ // is created.
273
+ const restoreStrayCapture = interactive ? undefined : captureStrayOutput(logFile);
274
+
275
+ const summary = yield* Effect.gen(function* () {
276
+ const reporter = yield* Reporter;
277
+ const summary = yield* run(options);
278
+ yield* reporter.waitForExit(summary);
279
+ return summary;
280
+ }).pipe(
281
+ Effect.provide(interactive ? tuiReporter(logFile) : PlainReporterLive),
282
+ Effect.scoped,
283
+ Effect.onExit(() => Effect.sync(() => restoreStrayCapture?.())),
284
+ );
285
+
286
+ // After the TUI tears down, leave a plain record in the terminal.
287
+ if (interactive) {
288
+ yield* printSummary(summary);
289
+ }
290
+
291
+ // Point agents/tooling at the full run log, with its size so the reader
292
+ // knows what they're getting into before opening it.
293
+ yield* Effect.gen(function* () {
294
+ const fs = yield* FileSystem.FileSystem;
295
+ const stats = yield* fs.stat(logFile).pipe(Effect.orElseSucceed(() => undefined));
296
+ const kb = stats === undefined ? 0 : Number(stats.size) / 1024;
297
+ yield* Effect.sync(() => {
298
+ process.stdout.write(`\nFull log: ${logFile} (${kb.toFixed(1)} KB)\n`);
299
+ });
300
+ });
301
+
302
+ if (summary.failed > 0) {
303
+ yield* Effect.sync(() => {
304
+ process.exitCode = 1;
305
+ });
306
+ }
307
+ }),
308
+ );
309
+
310
+ const cli = Command.run(rootCommand, {
311
+ version: packageJson.version,
312
+ });
313
+
314
+ cli.pipe(Effect.provide(Layer.mergeAll(BunServices.layer)), Effect.scoped, (effect) =>
315
+ BunRuntime.runMain(effect as Effect.Effect<void>, {
316
+ // ALWAYS exit once the main effect completes. runMain's default only
317
+ // force-exits on failure/signal — but tests can leak live handles
318
+ // (vite watchers, workerd sidecar sockets, keep-alive agents) that
319
+ // keep bun's event loop alive forever after a green run. Everything
320
+ // is already flushed by now (summary + log written in the main
321
+ // effect); the macrotask hop lets any buffered stdout drain.
322
+ teardown: (exit, onExit) => {
323
+ Runtime.defaultTeardown(exit, (code) => {
324
+ const finalCode = code !== 0 ? code : Number(process.exitCode ?? 0) || 0;
325
+ setTimeout(() => process.exit(finalCode), 0);
326
+ onExit(finalCode);
327
+ });
328
+ },
329
+ }),
330
+ );
package/src/index.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * alchemy-test — a minimal, Effect-native, vitest-compatible test harness.
3
+ *
4
+ * This entrypoint is safe to import from anywhere (including code that gets
5
+ * bundled into Workers): it only contains the registration API and the
6
+ * assertion library. The runner, CLI and TUI live in separate modules that
7
+ * are only loaded by the `alchemy-test` binary.
8
+ */
9
+ export {
10
+ afterAll,
11
+ afterEach,
12
+ beforeAll,
13
+ beforeEach,
14
+ describe,
15
+ exclusiveOf,
16
+ it,
17
+ layer,
18
+ registerFileCleanup,
19
+ registerHook,
20
+ registerTest,
21
+ retryOf,
22
+ test,
23
+ tagsOf,
24
+ optInTagsOf,
25
+ timeoutOf,
26
+ type DescribeFn,
27
+ type DescribeOptions,
28
+ type HookKind,
29
+ type LayerMethods,
30
+ type RegisterTestOptions,
31
+ type TestContext,
32
+ type Tester,
33
+ type TestFn,
34
+ type TestOptions,
35
+ } from "./Api.ts";
36
+ export {
37
+ assert,
38
+ AssertionError,
39
+ equals,
40
+ expect,
41
+ stringify,
42
+ type Assert,
43
+ type Expect,
44
+ type Matchers,
45
+ } from "./Expect.ts";
46
+ export { currentFile } from "./Registry.ts";
47
+ export type { Tags, TestTag, TestTags } from "./Tags.ts";
48
+ export type {
49
+ Exclusive,
50
+ FileSuite,
51
+ Hook,
52
+ LogEntry,
53
+ Mode,
54
+ Suite,
55
+ TestBody,
56
+ TestCase,
57
+ } from "./Model.ts";
package/README.md DELETED
@@ -1,9 +0,0 @@
1
- # @alchemy.run/test
2
-
3
- 🐣 This package name has been claimed with [fledgling](https://github.com/dmno-dev/fledgling) for [https://alchemy.run](https://alchemy.run), and a real release will land here soon.
4
-
5
- This is a placeholder — there is nothing to install yet.
6
-
7
- ---
8
-
9
- Want to claim a name of your own? Run `npx fledgling add <name> --new --yes` — it reserves the name on npm and can set up token-less (OIDC trusted) publishing for when you are ready.