1import { expect, it } from "alchemy-test";
2import { mkdtemp, rm, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import { dirname, resolve } from "node:path";
5import { fileURLToPath, pathToFileURL } from "node:url";
6
7const here = dirname(fileURLToPath(import.meta.url));
8const cli = resolve(here, "../bin/alchemy-test.ts");
9const apiUrl = pathToFileURL(resolve(here, "../src/index.ts")).href;
10const effectUrl = pathToFileURL(
11 resolve(here, "../../../node_modules/effect/dist/Effect.js"),
12).href;
13
14it(
15 "excludes exclusive-lock queue time from test durations",
16 async () => {
17 const root = await mkdtemp(resolve(tmpdir(), "alchemy-test-timing-"));
18 try {
19 await writeFile(
20 resolve(root, "timing.test.ts"),
21 `
22 import { describe, it, expect } from ${JSON.stringify(apiUrl)};
23 describe.concurrent("timing", () => {
24 let finished = false;
25 it("holder", async () => {
26 await new Promise(resolve => setTimeout(resolve, 500));
27 finished = true;
28 });
29 it("queued exclusive", () => {
30 expect(finished).toBe(true);
31 }, { exclusive: true });
32 });
33 `,
34 );
35 const child = Bun.spawn([process.execPath, cli, root, "--retry", "0"], {
36 cwd: root,
37 stdout: "pipe",
38 stderr: "pipe",
39 env: { ...process.env, NO_COLOR: "1" },
40 });
41 const [exitCode, stdout, stderr] = await Promise.all([
42 child.exited,
43 new Response(child.stdout).text(),
44 new Response(child.stderr).text(),
45 ]);
46 expect(exitCode).toBe(0);
47 const duration = `${stdout}\n${stderr}`.match(
48 /queued exclusive \((\d+)ms\)/,
49 );
50 expect(duration).not.toBeNull();
51 expect(Number(duration![1])).toBeLessThan(250);
52 } finally {
53 await rm(root, { recursive: true, force: true });
54 }
55 },
56 { tags: ["unit", "local"] },
57);
58
59it(
60 "tag filters compose with names and skip excluded setup and teardown",
61 async () => {
62 const root = await mkdtemp(resolve(tmpdir(), "alchemy-test-tags-"));
63 try {
64 await writeFile(
65 resolve(root, "tags.test.ts"),
66 `
67 import { describe, it, beforeAll, afterAll, expect } from ${JSON.stringify(apiUrl)};
68 let ready = false;
69 let ran = false;
70 describe("selected", { tags: ["e2e", "provider:aws"] }, () => {
71 beforeAll(() => { ready = true; });
72 afterAll(() => { expect(ran).toBe(true); });
73 describe("nested", { tags: ["live"] }, () => {
74 it("chosen", () => { expect(ready).toBe(true); ran = true; });
75 it("slow", () => { throw new Error("slow body ran"); }, { tags: "slow" });
76 it("other-name", () => { throw new Error("name filter ignored"); });
77 });
78 });
79 describe("excluded", { tags: ["e2e", "dev", "provider:cloudflare"] }, () => {
80 beforeAll(() => { throw new Error("excluded setup ran"); });
81 afterAll(() => { throw new Error("excluded teardown ran"); });
82 it("chosen", () => { throw new Error("excluded body ran"); });
83 });
84 it("untagged", () => { throw new Error("untagged body ran"); });
85 `,
86 );
87 const run = async (args: string[]) => {
88 const child = Bun.spawn(
89 [process.execPath, cli, root, "--retry", "0", ...args],
90 {
91 cwd: root,
92 stdout: "pipe",
93 stderr: "pipe",
94 env: { ...process.env, NO_COLOR: "1" },
95 },
96 );
97 const [exitCode, stdout, stderr] = await Promise.all([
98 child.exited,
99 new Response(child.stdout).text(),
100 new Response(child.stderr).text(),
101 ]);
102 return { exitCode, output: `${stdout}\n${stderr}` };
103 };
104 await writeFile(
105 resolve(root, "excluded.test.ts"),
106 `
107 import { it, beforeAll, afterAll } from ${JSON.stringify(apiUrl)};
108 beforeAll(() => { throw new Error("excluded file setup ran"); });
109 afterAll(() => { throw new Error("excluded file teardown ran"); });
110 it("excluded", () => { throw new Error("excluded file body ran"); }, { tags: "unit" });
111 `,
112 );
113 const selected = await run([
114 "--tags",
115 "e2e && live && provider:*",
116 "--tags",
117 "!slow",
118 "-t",
119 "chosen",
120 ]);
121 expect(selected.exitCode).toBe(0);
122 expect(selected.output).toContain("1 passed");
123 expect(selected.output).toContain("selected > nested > chosen");
124 const empty = await run(["--tags", "missing"]);
125 expect(empty.exitCode).toBe(0);
126 expect(empty.output).toContain("0 passed");
127
128 await writeFile(
129 resolve(root, "only.test.ts"),
130 `
131 import { it } from ${JSON.stringify(apiUrl)};
132 it.only("only", () => { throw new Error("only bypassed tags"); }, { tags: "other" });
133 `,
134 );
135 const only = await run(["--tags", "e2e"]);
136 expect(only.exitCode).toBe(0);
137 expect(only.output).toContain("0 passed");
138
139 // Even an import that throws must not be reached with malformed syntax.
140 await writeFile(
141 resolve(root, "bad.test.ts"),
142 'throw new Error("IMPORTED_SENTINEL");',
143 );
144 const invalid = await run(["--tags", "unit &&"]);
145 expect(invalid.exitCode).not.toBe(0);
146 expect(invalid.output).toContain("Invalid --tags");
147 expect(invalid.output).not.toContain("IMPORTED_SENTINEL");
148 } finally {
149 await rm(root, { recursive: true, force: true });
150 }
151 },
152 { tags: ["unit", "local"] },
153);
154
155const fixture = (hook: string, body: string): string => `
156 import { it, registerHook } from ${JSON.stringify(apiUrl)};
157 import * as Effect from ${JSON.stringify(effectUrl)};
158 registerHook(${JSON.stringify(hook)}, { body: () => Effect.gen(function* () {
159 yield* Effect.log(${JSON.stringify(`${hook}-captured-output`)});
160 return yield* Effect.fail(new Error(${JSON.stringify(`${hook}-sentinel`)}));
161 })
162 });
163 ${body}
164`;
165
166it(
167 "streams file-hook output to the run log while the hook is still running",
168 async () => {
169 // Regression: file-level hook output (deploy/destroy) used to be buffered
170 // until FileEnd, so a long-running beforeAll produced a run log that
171 // stopped growing entirely — a multi-minute cloud deploy read as a
172 // deadlocked run (0% CPU, silent log). Hook log entries must reach the
173 // per-run log file WHILE the hook is still executing.
174 const root = await mkdtemp(resolve(tmpdir(), "alchemy-test-livehook-"));
175 try {
176 await writeFile(
177 resolve(root, "live-hook.test.ts"),
178 `
179 import { it, registerHook } from ${JSON.stringify(apiUrl)};
180 import * as Effect from ${JSON.stringify(effectUrl)};
181 registerHook("beforeAll", { body: () => Effect.gen(function* () {
182 yield* Effect.log("hook-live-sentinel");
183 yield* Effect.sleep("8 seconds");
184 }) });
185 it("body", () => {});
186 `,
187 );
188
189 const child = Bun.spawn(
190 [process.execPath, cli, root, "--retry", "0", "--concurrency", "1"],
191 {
192 cwd: root,
193 stdout: "pipe",
194 stderr: "pipe",
195 env: { ...process.env, NO_COLOR: "1" },
196 },
197 );
198 try {
199 // Poll the run log (created under the child's cwd) for the sentinel.
200 // The hook sleeps 8s after logging; seeing the sentinel within ~6s
201 // proves it was streamed mid-hook, not flushed at FileEnd.
202 const logDir = resolve(root, ".alchemy", "log", "test");
203 const deadline = Date.now() + 6_000;
204 let streamed = false;
205 while (Date.now() < deadline) {
206 const { readdir, readFile } = await import("node:fs/promises");
207 const entries = await readdir(logDir).catch(() => [] as string[]);
208 for (const entry of entries) {
209 const content = await readFile(
210 resolve(logDir, entry),
211 "utf8",
212 ).catch(() => "");
213 if (content.includes("hook-live-sentinel")) {
214 streamed = true;
215 break;
216 }
217 }
218 if (streamed) break;
219 await new Promise((r) => setTimeout(r, 200));
220 }
221 expect(streamed).toBe(true);
222
223 const exitCode = await child.exited;
224 expect(exitCode).toBe(0);
225 } finally {
226 child.kill();
227 }
228 } finally {
229 await rm(root, { recursive: true, force: true });
230 }
231 },
232 { tags: ["unit", "local"] },
233);
234
235it(
236 "--exclude skips folders unless they are passed explicitly",
237 async () => {
238 const root = await mkdtemp(resolve(tmpdir(), "alchemy-test-exclude-"));
239 try {
240 const { mkdir } = await import("node:fs/promises");
241 await mkdir(resolve(root, "test", "Railway"), { recursive: true });
242 await mkdir(resolve(root, "test", "Other"), { recursive: true });
243 const testFile = (name: string) => `
244 import { it } from ${JSON.stringify(apiUrl)};
245 it(${JSON.stringify(name)}, () => {});
246 `;
247 await Promise.all([
248 writeFile(
249 resolve(root, "test", "Railway", "Excluded.test.ts"),
250 testFile("excluded-test"),
251 ),
252 writeFile(
253 resolve(root, "test", "Other", "Included.test.ts"),
254 testFile("included-test"),
255 ),
256 ]);
257
258 const runCli = async (args: ReadonlyArray<string>) => {
259 const child = Bun.spawn(
260 [
261 process.execPath,
262 cli,
263 ...args,
264 "--retry",
265 "0",
266 "--concurrency",
267 "1",
268 ],
269 {
270 cwd: root,
271 stdout: "pipe",
272 stderr: "pipe",
273 env: { ...process.env, NO_COLOR: "1" },
274 },
275 );
276 const [exitCode, stdout, stderr] = await Promise.all([
277 child.exited,
278 new Response(child.stdout).text(),
279 new Response(child.stderr).text(),
280 ]);
281 return { exitCode, output: `${stdout}\n${stderr}` };
282 };
283
284 // Default discovery with the exclusion: only the non-excluded file runs.
285 const excluded = await runCli(["--exclude", "test/Railway"]);
286 expect(excluded.exitCode).toBe(0);
287 expect(excluded.output).toContain("included-test");
288 expect(excluded.output).not.toContain("excluded-test");
289
290 // An explicit positional root inside the excluded path overrides it.
291 const explicit = await runCli([
292 "test/Railway/Excluded.test.ts",
293 "--exclude",
294 "test/Railway",
295 ]);
296 expect(explicit.exitCode).toBe(0);
297 expect(explicit.output).toContain("excluded-test");
298
299 // Non-path excludes degrade to case-insensitive substring filters.
300 const substring = await runCli(["--exclude", "railway"]);
301 expect(substring.exitCode).toBe(0);
302 expect(substring.output).toContain("included-test");
303 expect(substring.output).not.toContain("excluded-test");
304 } finally {
305 await rm(root, { recursive: true, force: true });
306 }
307 },
308 { tags: ["unit", "local"] },
309);
310
311it(
312 "fails the process for every hook kind and preserves hook output",
313 async () => {
314 const root = await mkdtemp(resolve(tmpdir(), "alchemy-test-hooks-"));
315 try {
316 await Promise.all([
317 writeFile(
318 resolve(root, "before-all.test.ts"),
319 fixture("beforeAll", 'it("body", () => {});'),
320 ),
321 writeFile(
322 resolve(root, "before-each.test.ts"),
323 fixture("beforeEach", 'it("body", () => {});'),
324 ),
325 writeFile(
326 resolve(root, "after-each.test.ts"),
327 fixture(
328 "afterEach",
329 'it.fails("expected body failure", () => { throw new Error("expected-body-failure"); });',
330 ),
331 ),
332 writeFile(
333 resolve(root, "after-all.test.ts"),
334 fixture("afterAll", 'it("body", () => {});'),
335 ),
336 ]);
337
338 const child = Bun.spawn(
339 [process.execPath, cli, root, "--retry", "0", "--concurrency", "1"],
340 {
341 cwd: root,
342 stdout: "pipe",
343 stderr: "pipe",
344 env: { ...process.env, NO_COLOR: "1" },
345 },
346 );
347 const [exitCode, stdout, stderr] = await Promise.all([
348 child.exited,
349 new Response(child.stdout).text(),
350 new Response(child.stderr).text(),
351 ]);
352 const output = `${stdout}\n${stderr}`;
353
354 expect(exitCode).toBe(1);
355 expect(output).toContain("beforeAll hook failed:");
356 expect(output).toContain("beforeEach hook failed:");
357 expect(output).toContain("afterEach hook failed:");
358 expect(output).toContain("afterAll hook failed:");
359 expect(output).toContain("Tests: 4 failed | 1 passed");
360 for (const hook of ["beforeAll", "beforeEach", "afterEach", "afterAll"]) {
361 expect(output).toContain(`${hook}-captured-output`);
362 expect(output).toContain(`${hook}-sentinel`);
363 }
364 } finally {
365 await rm(root, { recursive: true, force: true });
366 }
367 },
368 { tags: ["unit", "local"] },
369);
370
371it(
372 "runs every afterAll hook even when an earlier one fails",
373 async () => {
374 // Regression: `runAfterAll` used to short-circuit on the first failing
375 // hook, so a failing teardown assertion silently dropped every later
376 // afterAll — in particular Test.make's fallback hook that closes the
377 // shared scope and local provider sidecar, leaking the sidecar for the
378 // rest of the process. All teardown hooks must run; failures aggregate.
379 const root = await mkdtemp(resolve(tmpdir(), "alchemy-test-afterall-"));
380 try {
381 await writeFile(
382 resolve(root, "teardown-chain.test.ts"),
383 `
384 import { it, registerHook } from ${JSON.stringify(apiUrl)};
385 import * as Effect from ${JSON.stringify(effectUrl)};
386 registerHook("afterAll", { body: () => Effect.gen(function* () {
387 return yield* Effect.fail(new Error("first-teardown-failed"));
388 }) });
389 registerHook("afterAll", { body: () => Effect.gen(function* () {
390 yield* Effect.log("second-teardown-ran");
391 }) });
392 it("body", () => {});
393 `,
394 );
395
396 const child = Bun.spawn(
397 [process.execPath, cli, root, "--retry", "0", "--concurrency", "1"],
398 {
399 cwd: root,
400 stdout: "pipe",
401 stderr: "pipe",
402 env: { ...process.env, NO_COLOR: "1" },
403 },
404 );
405 const [exitCode, stdout, stderr] = await Promise.all([
406 child.exited,
407 new Response(child.stdout).text(),
408 new Response(child.stderr).text(),
409 ]);
410 const output = `${stdout}\n${stderr}`;
411
412 // The failure is reported and fails the run…
413 expect(exitCode).toBe(1);
414 expect(output).toContain("afterAll hook failed:");
415 expect(output).toContain("first-teardown-failed");
416 // …and the later teardown hook still ran.
417 expect(output).toContain("second-teardown-ran");
418 } finally {
419 await rm(root, { recursive: true, force: true });
420 }
421 },
422 { tags: ["unit", "local"] },
423);
424
425it(
426 "attributes imported tests and deferred hooks to their own files",
427 async () => {
428 const root = await mkdtemp(resolve(tmpdir(), "alchemy-test-collection-"));
429 try {
430 for (const name of ["first", "second"]) {
431 const file = `${name}.test.ts`;
432 await writeFile(
433 resolve(root, file),
434 `
435 import { beforeAll, afterAll, currentFile, describe, expect, it } from ${JSON.stringify(apiUrl)};
436 expect(currentFile()?.split("/").pop()).toBe(${JSON.stringify(file)});
437 await new Promise(resolve => setTimeout(resolve, 10));
438 expect(currentFile()?.split("/").pop()).toBe(${JSON.stringify(file)});
439 let ready = false;
440 let ran = false;
441 queueMicrotask(() => {
442 expect(currentFile()?.split("/").pop()).toBe(${JSON.stringify(file)});
443 beforeAll(() => { ready = true; });
444 afterAll(() => { expect(ran).toBe(true); });
445 });
446 describe("nested", () => {
447 it(${JSON.stringify(name)}, () => {
448 expect(currentFile()).toBeUndefined();
449 expect(ready).toBe(true);
450 ran = true;
451 });
452 });
453 `,
454 );
455 }
456 const child = Bun.spawn(
457 [process.execPath, cli, root, "--retry", "0", "--concurrency", "2"],
458 {
459 cwd: root,
460 stdout: "pipe",
461 stderr: "pipe",
462 env: { ...process.env, NO_COLOR: "1" },
463 },
464 );
465 const [exitCode, stdout, stderr] = await Promise.all([
466 child.exited,
467 new Response(child.stdout).text(),
468 new Response(child.stderr).text(),
469 ]);
470 expect(`${stdout}\n${stderr}`).toContain(
471 "first.test.ts > nested > first",
472 );
473 expect(`${stdout}\n${stderr}`).toContain(
474 "second.test.ts > nested > second",
475 );
476 expect(exitCode).toBe(0);
477 } finally {
478 await rm(root, { recursive: true, force: true });
479 }
480 },
481 { tags: ["unit", "local"] },
482);
483
484it(
485 "opt-in tags gate execution and hooks until explicitly selected",
486 async () => {
487 const root = await mkdtemp(resolve(tmpdir(), "alchemy-test-opt-in-"));
488 const file = resolve(root, "opt-in.test.ts");
489 try {
490 await writeFile(
491 file,
492 `
493 import { describe, it, beforeAll, afterAll, expect } from ${JSON.stringify(apiUrl)};
494 let ran = false;
495 describe("gated", { tags: ["e2e", "provider:aws"], optInTags: ["slow"] }, () => {
496 beforeAll(() => { if (!process.env.EXPECT_ENABLED) throw new Error("excluded setup ran"); });
497 afterAll(() => { expect(ran).toBe(true); });
498 it("single", () => { ran = true; });
499 it("double", () => { ran = true; }, { optInTags: ["enterprise"] });
500 });
501 it("ordinary", () => {}, { tags: ["unit", "slow"] });
502 `,
503 );
504 const run = async (args: string[], enabled = false) => {
505 const child = Bun.spawn(
506 [process.execPath, cli, file, "--retry", "0", ...args],
507 {
508 cwd: root,
509 stdout: "pipe",
510 stderr: "pipe",
511 env: {
512 ...process.env,
513 NO_COLOR: "1",
514 EXPECT_ENABLED: enabled ? "1" : "",
515 },
516 },
517 );
518 const [code, stdout, stderr] = await Promise.all([
519 child.exited,
520 new Response(child.stdout).text(),
521 new Response(child.stderr).text(),
522 ]);
523 expect(code).toBe(0);
524 return `${stdout}\n${stderr}`;
525 };
526 expect(await run([])).toContain("1 passed");
527 expect(await run(["--tags", "*"])).toContain("1 passed");
528 for (const filter of ["provider:aws", "!unit", "sl*", "!!slow"]) {
529 expect(await run(["--tags", filter, "-t", "gated"])).toContain(
530 "0 passed",
531 );
532 }
533 expect(await run(["-t", "gated"])).toContain("0 passed");
534 expect(await run(["--tags", "slow && provider:aws"], true)).toContain(
535 "1 passed",
536 );
537 expect(
538 await run(["--tags", "slow", "--tags", "enterprise"], true),
539 ).toContain("1 passed");
540 await writeFile(
541 file,
542 `
543 import { it } from ${JSON.stringify(apiUrl)};
544 it.only("gated", () => { throw new Error("only bypassed opt-in"); }, { optInTags: ["slow"] });
545 `,
546 );
547 expect(await run([])).toContain("0 passed");
548 } finally {
549 await rm(root, { recursive: true, force: true });
550 }
551 },
552 { tags: ["unit", "local"], timeout: 90_000 },
553);