@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/Bun.ts ADDED
@@ -0,0 +1,256 @@
1
+ /** @effect-diagnostics anyUnknownInErrorContext:off */
2
+
3
+ import bun from "bun:test";
4
+ import type { HookOptions } from "node:test";
5
+ import type { AlchemyContext } from "alchemy/AlchemyContext";
6
+ import type { CompiledStack, StackDefinition } from "alchemy/Stack";
7
+ import type { Stage } from "alchemy/Stage";
8
+ import * as Cause from "effect/Cause";
9
+ import * as Duration from "effect/Duration";
10
+ import * as Effect from "effect/Effect";
11
+ import * as Exit from "effect/Exit";
12
+ import * as Scope from "effect/Scope";
13
+ import * as Core from "./Core.ts";
14
+
15
+ export {
16
+ executeWhenReady,
17
+ getWhenReady,
18
+ guardContentType,
19
+ guardedFetchLayer,
20
+ rpcClientLayer,
21
+ WorkerNotReady,
22
+ type EdgeGuardOptions,
23
+ type WhenReadyOptions,
24
+ } from "./Http.ts";
25
+
26
+ export type MakeOptions<ROut = any> = Core.MakeOptions<ROut>;
27
+ export type ScratchStack = Core.ScratchStack;
28
+ export type TestEffect<A, R = never> = Core.TestEffect<A, R>;
29
+ export const defaultStage = Core.defaultStage;
30
+ export const resolveStage = Core.resolveStage;
31
+
32
+ export interface TestApi {
33
+ test: TestFn;
34
+ beforeAll: BeforeAllFn;
35
+ beforeEach: BeforeEachFn;
36
+ afterAll: AfterAllFn;
37
+ afterEach: AfterEachFn;
38
+ deploy: Core.Deploy;
39
+ destroy: <ROut>(
40
+ stack: StackDefinition<ROut>,
41
+ options?: { stage?: string; include?: never; exclude?: never },
42
+ ) => ReturnType<typeof Core.destroy>;
43
+ }
44
+
45
+ interface TestFn {
46
+ (name: string, eff: TestEffect<void>, options?: bun.TestOptions): void;
47
+ skip: (name: string, eff: TestEffect<void>, options?: bun.TestOptions) => void;
48
+ skipIf: (
49
+ condition: boolean,
50
+ ) => (name: string, eff: TestEffect<void>, options?: bun.TestOptions) => void;
51
+ only: (name: string, eff: TestEffect<void>, options?: bun.TestOptions) => void;
52
+ todo: (name: string, eff: TestEffect<void>, options?: bun.TestOptions) => void;
53
+ provider: ProviderFn;
54
+ }
55
+
56
+ interface ProviderFn {
57
+ (
58
+ name: string,
59
+ fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
60
+ options?: bun.TestOptions,
61
+ ): void;
62
+ skip: (
63
+ name: string,
64
+ fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
65
+ options?: bun.TestOptions,
66
+ ) => void;
67
+ skipIf: (
68
+ condition: boolean,
69
+ ) => (
70
+ name: string,
71
+ fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
72
+ options?: bun.TestOptions,
73
+ ) => void;
74
+ }
75
+
76
+ interface BeforeAllFn {
77
+ <A>(eff: TestEffect<A>, options?: HookOptions): Effect.Effect<A>;
78
+ }
79
+
80
+ interface BeforeEachFn {
81
+ (eff: TestEffect<void>, options?: HookOptions): void;
82
+ }
83
+
84
+ interface AfterAllFn {
85
+ (eff: TestEffect<any>, options?: HookOptions): void;
86
+ skipIf: (predicate: boolean) => (eff: TestEffect<any>, options?: HookOptions) => void;
87
+ }
88
+
89
+ interface AfterEachFn {
90
+ (eff: TestEffect<void>, options?: HookOptions): void;
91
+ }
92
+
93
+ const DEFAULT_HOOK_TIMEOUT: HookOptions = { timeout: 120_000 };
94
+
95
+ /** The timeout bun applies to a test, from its options. */
96
+ const timeoutOf = (options: bun.TestOptions | undefined): number | undefined =>
97
+ typeof options === "number" ? options : options?.timeout;
98
+
99
+ /**
100
+ * Interrupt `eff` shortly before bun's own timeout. When bun times a test or
101
+ * hook out it only stops waiting: the effect keeps running, so the file's
102
+ * `afterAll` destroy races a deploy still creating resources, and whatever
103
+ * that deploy creates afterwards is never cleaned up. Interrupting it first
104
+ * lets the engine record what it created, so the destroy that follows finds
105
+ * it.
106
+ */
107
+ const DEADLINE_MARGIN_MS = 5_000;
108
+ const withDeadline = <A, E, R>(
109
+ eff: Effect.Effect<A, E, R>,
110
+ timeout: number | undefined,
111
+ ): Effect.Effect<A, E | Cause.TimeoutError, R> =>
112
+ timeout === undefined || timeout <= DEADLINE_MARGIN_MS * 2
113
+ ? eff
114
+ : eff.pipe(Effect.timeout(Duration.millis(timeout - DEADLINE_MARGIN_MS)));
115
+
116
+ /**
117
+ * Build the per-file test API. Configure providers / state once at the top of
118
+ * the test file:
119
+ *
120
+ * ```ts
121
+ * import * as Test from "@alchemy.run/test/Bun";
122
+ * import * as Cloudflare from "@alchemy.run/cloudflare";
123
+ *
124
+ * const { test, deploy, destroy, beforeAll, afterAll } = Test.make({
125
+ * providers: Cloudflare.providers(),
126
+ * state: Cloudflare.state(),
127
+ * });
128
+ * ```
129
+ */
130
+ export const make = <ROut = any>(options: MakeOptions<ROut>): TestApi => {
131
+ // Single scope shared across `beforeAll`, every `test`, and `afterAll`.
132
+ // Scoped resources in dev mode (the Cloudflare sidecar process and its
133
+ // workerd children) must outlive a single `Effect.runPromise` boundary,
134
+ // otherwise the proxy is killed the moment `beforeAll(deploy(Stack))`
135
+ // resolves and every later `HttpClient.get(workerUrl)` hits a dead port.
136
+ // The scope is closed by `destroy(...)` (or never — the next test run
137
+ // reclaims any leaked sidecar via the lock file).
138
+ const sharedScope = Scope.makeUnsafe("sequential");
139
+ // In dev mode, run local providers behind one file-scoped RPC sidecar
140
+ // (the `alchemy dev` topology). Lives in its own scope — NOT sharedScope,
141
+ // which `destroy(Stack)` closes mid-file in self-contained tests — and is
142
+ // closed by the fallback afterAll below.
143
+ const sidecar = Core.makeSidecarHandle(options);
144
+ const runEff = <A>(eff: TestEffect<A>, timeout?: number) =>
145
+ Core.run(withDeadline(eff, timeout), options, sharedScope, sidecar);
146
+
147
+ const test = ((name, eff, opts) => {
148
+ bun.test(name, () => runEff(eff, timeoutOf(opts)), opts);
149
+ }) as TestFn;
150
+
151
+ test.skip = (name, eff, opts) => {
152
+ bun.test.skip(name, () => runEff(eff, timeoutOf(opts)), opts);
153
+ };
154
+ test.skipIf = (condition) => (name, eff, opts) => {
155
+ bun.test.skipIf(condition)(name, () => runEff(eff, timeoutOf(opts)), opts);
156
+ };
157
+ test.only = (name, eff, opts) => {
158
+ bun.test.only(name, () => runEff(eff, timeoutOf(opts)), opts);
159
+ };
160
+ test.todo = (name, eff, opts) => {
161
+ bun.test.todo(name, () => runEff(eff, timeoutOf(opts)), opts);
162
+ };
163
+
164
+ const runProvider = (
165
+ name: string,
166
+ fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
167
+ timeout?: number,
168
+ ) => {
169
+ const scratch = Core.scratchStack(options, name);
170
+ return Core.run(
171
+ withDeadline(Core.withProviders(fn(scratch), options, scratch.name), timeout),
172
+ { ...options, state: scratch.state },
173
+ sharedScope,
174
+ sidecar,
175
+ );
176
+ };
177
+
178
+ const provider = ((name, fn, opts) => {
179
+ bun.test(name, () => runProvider(name, fn, timeoutOf(opts)), opts);
180
+ }) as ProviderFn;
181
+ provider.skip = (name, fn, opts) => {
182
+ bun.test.skip(name, () => runProvider(name, fn, timeoutOf(opts)), opts);
183
+ };
184
+ provider.skipIf = (condition) => (name, fn, opts) => {
185
+ bun.test.skipIf(condition)(name, () => runProvider(name, fn, timeoutOf(opts)), opts);
186
+ };
187
+ test.provider = provider;
188
+
189
+ const beforeAll: BeforeAllFn = <A>(eff: TestEffect<A>, hookOptions?: HookOptions) => {
190
+ let result: A;
191
+ const options = hookOptions ?? DEFAULT_HOOK_TIMEOUT;
192
+ bun.beforeAll(() => runEff(eff, options.timeout).then((v) => (result = v)), options);
193
+ return Effect.sync(() => result);
194
+ };
195
+
196
+ const beforeEach: BeforeEachFn = (eff, hookOptions) => {
197
+ bun.beforeEach(() => runEff(eff), hookOptions);
198
+ };
199
+
200
+ // bun:test stops running later `afterAll` hooks once one throws, which
201
+ // would skip the fallback cleanup hook below — leaking the shared scope
202
+ // and the sidecar for the rest of the process whenever a teardown
203
+ // assertion fails. Guard every user teardown: on failure, run the
204
+ // (idempotent) cleanup before rethrowing so the failure still fails the
205
+ // suite. (`closeAll` is initialized below; hooks only run after `make`
206
+ // returns.)
207
+ const guardTeardown = (eff: TestEffect<any>) => () =>
208
+ runEff(eff).catch(async (error) => {
209
+ await Effect.runPromise(closeAll);
210
+ throw error;
211
+ });
212
+
213
+ const afterAll = ((eff, hookOptions) => {
214
+ bun.afterAll(guardTeardown(eff), hookOptions ?? DEFAULT_HOOK_TIMEOUT);
215
+ }) as AfterAllFn;
216
+ afterAll.skipIf = (predicate) => (eff, hookOptions) => {
217
+ if (predicate) return;
218
+ bun.afterAll(guardTeardown(eff), hookOptions ?? DEFAULT_HOOK_TIMEOUT);
219
+ };
220
+
221
+ const afterEach: AfterEachFn = (eff, hookOptions) => {
222
+ bun.afterEach(() => runEff(eff), hookOptions);
223
+ };
224
+
225
+ // `destroy(Stack)` needs the dev sidecar alive so it can call `sidecar.stop`
226
+ // for each worker. We close the shared scope only AFTER destroy completes.
227
+ // `Scope.close` on an already-closed scope is a no-op, so it's safe for both
228
+ // the destroy wrapper AND the fallback cleanup hook below to call it.
229
+ const closeScope = Effect.suspend(() => Scope.close(sharedScope, Exit.void)).pipe(Effect.ignore);
230
+
231
+ // Fallback cleanup: if the user never calls `destroy(Stack)` (e.g.
232
+ // `NO_DESTROY=1`), nothing else closes the shared scope and the sidecar
233
+ // child process leaks past the test process. Register an `afterAll` that
234
+ // closes it (and the RPC sidecar, which lives in its own scope so that
235
+ // mid-file `destroy(Stack)` calls can't kill it for later tests). We defer
236
+ // registration to a microtask so it runs AFTER any user-registered
237
+ // `afterAll` (including `destroy(Stack)`); bun runs afterAll hooks in
238
+ // registration order.
239
+ const closeAll = sidecar ? Effect.andThen(closeScope, sidecar.close) : closeScope;
240
+ queueMicrotask(() => {
241
+ bun.afterAll(() => Effect.runPromise(closeAll), DEFAULT_HOOK_TIMEOUT);
242
+ });
243
+
244
+ return {
245
+ test,
246
+ beforeAll,
247
+ beforeEach,
248
+ afterAll,
249
+ afterEach,
250
+ deploy: Core.makeDeploy(options, sharedScope),
251
+ destroy: (stack, callOpts) =>
252
+ Core.destroy(options, stack, { ...callOpts, scope: sharedScope }).pipe(
253
+ Effect.ensuring(closeScope),
254
+ ),
255
+ };
256
+ };
package/src/Capture.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { LogEntry } from "./Model.ts";
2
+
3
+ const CAPTURE_BYTES = 256 * 1024;
4
+ const TOTAL_BYTES = 16 * 1024 * 1024;
5
+ const TRUNCATED = "[Earlier output omitted from preview; full output is in the run log.]";
6
+
7
+ /** Bounded live previews; every original entry is persisted before trimming. */
8
+ export const makeCapture = (limits = { capture: CAPTURE_BYTES, total: TOTAL_BYTES }) => {
9
+ const buffers = new Map<Array<LogEntry>, number>();
10
+ let total = 0;
11
+ const size = (entry: LogEntry) => Buffer.byteLength(entry.message, "utf8") + 64;
12
+ const release = (buffer: Array<LogEntry>) => {
13
+ total -= buffers.get(buffer) ?? 0;
14
+ buffers.delete(buffer);
15
+ buffer.length = 0;
16
+ };
17
+ const trim = (buffer: Array<LogEntry>, limit: number) => {
18
+ let bytes = buffers.get(buffer) ?? 0;
19
+ let removed = false;
20
+ while (bytes > limit && buffer.length > 0) {
21
+ const entry = buffer.shift()!;
22
+ const cost = size(entry);
23
+ bytes -= cost;
24
+ total -= cost;
25
+ removed = true;
26
+ }
27
+ if (removed && buffer.length > 0 && buffer[0]!.message !== TRUNCATED) {
28
+ const entry = {
29
+ level: "info",
30
+ message: TRUNCATED,
31
+ time: buffer[0]!.time,
32
+ };
33
+ buffer.unshift(entry);
34
+ bytes += size(entry);
35
+ total += size(entry);
36
+ }
37
+ if (bytes === 0) buffers.delete(buffer);
38
+ else buffers.set(buffer, bytes);
39
+ };
40
+ const create = (persist: (entry: LogEntry) => void): Array<LogEntry> => {
41
+ const buffer: Array<LogEntry> = [];
42
+ buffer.push = (...entries) => {
43
+ for (const entry of entries) {
44
+ persist(entry);
45
+ // A single oversized message must not defeat either preview limit.
46
+ const maxChars = Math.max(
47
+ 0,
48
+ Math.floor((Math.min(limits.capture, limits.total) - 256) / 4),
49
+ );
50
+ const preview =
51
+ entry.message.length > maxChars
52
+ ? {
53
+ ...entry,
54
+ message: `${TRUNCATED}\n${maxChars === 0 ? "" : entry.message.slice(-maxChars)}`,
55
+ }
56
+ : entry;
57
+ Array.prototype.push.call(buffer, preview);
58
+ const cost = size(preview);
59
+ buffers.set(buffer, (buffers.get(buffer) ?? 0) + cost);
60
+ total += cost;
61
+ trim(buffer, Math.max(0, limits.capture - 256));
62
+ while (total > limits.total && buffers.size > 0) {
63
+ const oldest = buffers.keys().next().value!;
64
+ release(oldest);
65
+ }
66
+ }
67
+ return buffer.length;
68
+ };
69
+ return buffer;
70
+ };
71
+ return { create, release };
72
+ };