@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.
- package/LICENSE +201 -0
- package/bin/alchemy-test.ts +2 -0
- package/lib/Alchemy.d.ts +78 -0
- package/lib/Alchemy.d.ts.map +1 -0
- package/lib/Alchemy.js +194 -0
- package/lib/Alchemy.js.map +1 -0
- package/lib/Api.d.ts +134 -0
- package/lib/Api.d.ts.map +1 -0
- package/lib/Api.js +245 -0
- package/lib/Api.js.map +1 -0
- package/lib/Bun.d.ts +67 -0
- package/lib/Bun.d.ts.map +1 -0
- package/lib/Bun.js +143 -0
- package/lib/Bun.js.map +1 -0
- package/lib/Capture.d.ts +10 -0
- package/lib/Capture.d.ts.map +1 -0
- package/lib/Capture.js +68 -0
- package/lib/Capture.js.map +1 -0
- package/lib/Core.d.ts +287 -0
- package/lib/Core.d.ts.map +1 -0
- package/lib/Core.js +429 -0
- package/lib/Core.js.map +1 -0
- package/lib/DevCli.d.ts +47 -0
- package/lib/DevCli.d.ts.map +1 -0
- package/lib/DevCli.js +181 -0
- package/lib/DevCli.js.map +1 -0
- package/lib/Expect.d.ts +84 -0
- package/lib/Expect.d.ts.map +1 -0
- package/lib/Expect.js +518 -0
- package/lib/Expect.js.map +1 -0
- package/lib/FileLog.d.ts +27 -0
- package/lib/FileLog.d.ts.map +1 -0
- package/lib/FileLog.js +136 -0
- package/lib/FileLog.js.map +1 -0
- package/lib/Http.d.ts +109 -0
- package/lib/Http.d.ts.map +1 -0
- package/lib/Http.js +123 -0
- package/lib/Http.js.map +1 -0
- package/lib/Model.d.ts +98 -0
- package/lib/Model.d.ts.map +1 -0
- package/lib/Model.js +59 -0
- package/lib/Model.js.map +1 -0
- package/lib/PlainReporter.d.ts +19 -0
- package/lib/PlainReporter.d.ts.map +1 -0
- package/lib/PlainReporter.js +348 -0
- package/lib/PlainReporter.js.map +1 -0
- package/lib/Plan.d.ts +19 -0
- package/lib/Plan.d.ts.map +1 -0
- package/lib/Plan.js +32 -0
- package/lib/Plan.js.map +1 -0
- package/lib/Registry.d.ts +34 -0
- package/lib/Registry.d.ts.map +1 -0
- package/lib/Registry.js +80 -0
- package/lib/Registry.js.map +1 -0
- package/lib/Reporter.d.ts +160 -0
- package/lib/Reporter.d.ts.map +1 -0
- package/lib/Reporter.js +9 -0
- package/lib/Reporter.js.map +1 -0
- package/lib/Runner.d.ts +51 -0
- package/lib/Runner.d.ts.map +1 -0
- package/lib/Runner.js +1052 -0
- package/lib/Runner.js.map +1 -0
- package/lib/StrayOutput.d.ts +8 -0
- package/lib/StrayOutput.d.ts.map +1 -0
- package/lib/StrayOutput.js +259 -0
- package/lib/StrayOutput.js.map +1 -0
- package/lib/Tags.d.ts +17 -0
- package/lib/Tags.d.ts.map +1 -0
- package/lib/Tags.js +84 -0
- package/lib/Tags.js.map +1 -0
- package/lib/TestState.d.ts +12 -0
- package/lib/TestState.d.ts.map +1 -0
- package/lib/TestState.js +15 -0
- package/lib/TestState.js.map +1 -0
- package/lib/Tui.d.ts +4 -0
- package/lib/Tui.d.ts.map +1 -0
- package/lib/Tui.js +1137 -0
- package/lib/Tui.js.map +1 -0
- package/lib/Vitest.d.ts +58 -0
- package/lib/Vitest.d.ts.map +1 -0
- package/lib/Vitest.js +132 -0
- package/lib/Vitest.js.map +1 -0
- package/lib/WorktreeStage.d.ts +17 -0
- package/lib/WorktreeStage.d.ts.map +1 -0
- package/lib/WorktreeStage.js +48 -0
- package/lib/WorktreeStage.js.map +1 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.d.ts.map +1 -0
- package/lib/cli.js +222 -0
- package/lib/cli.js.map +1 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +12 -0
- package/lib/index.js.map +1 -0
- package/package.json +88 -3
- package/src/Alchemy.ts +335 -0
- package/src/Api.ts +521 -0
- package/src/Bun.ts +256 -0
- package/src/Capture.ts +72 -0
- package/src/Core.ts +872 -0
- package/src/DevCli.ts +229 -0
- package/src/Expect.ts +763 -0
- package/src/FileLog.ts +158 -0
- package/src/Http.ts +201 -0
- package/src/Model.ts +160 -0
- package/src/PlainReporter.ts +426 -0
- package/src/Plan.ts +45 -0
- package/src/Registry.ts +101 -0
- package/src/Reporter.ts +171 -0
- package/src/Runner.ts +1341 -0
- package/src/StrayOutput.ts +283 -0
- package/src/Tags.ts +100 -0
- package/src/TestState.ts +35 -0
- package/src/Tui.ts +1272 -0
- package/src/Vitest.ts +239 -0
- package/src/WorktreeStage.ts +45 -0
- package/src/cli.ts +330 -0
- package/src/index.ts +57 -0
- package/README.md +0 -3
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