@alchemy.run/test 0.0.0 → 2.0.0-beta.86

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