1import * as Command from "@/Command";
2import * as Provider from "@/Provider";
3import * as Test from "@/Test/Alchemy";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as pathe from "pathe";
8
9const { test } = Test.make({ providers: Command.providers() });
10
11const FIXTURE_DIR = pathe.resolve(import.meta.dirname, "exec-fixture");
12
13// Copy the fixture into a scoped temp directory so each run starts clean and
14// the suite never mutates the committed source tree (the command appends to
15// `runs.log` and the test rewrites `src/input.txt`).
16const makeTemporaryFixture = Effect.fn(function* () {
17 const fs = yield* FileSystem.FileSystem;
18 const tempDir = yield* fs.makeTempDirectoryScoped();
19 yield* fs.copy(FIXTURE_DIR, tempDir);
20 return { cwd: tempDir };
21});
22
23test.provider(
24 "list returns [] for non-listable Command.Exec",
25 () =>
26 Effect.gen(function* () {
27 // Command.Exec is a local side-effect step with no remote enumeration
28 // API, so list() is the non-listable pattern: always returns [].
29 const provider = yield* Provider.findProvider(Command.Exec);
30 const all = yield* provider.list();
31 expect(all).toEqual([]);
32 }),
33 { tags: ["unit", "local"], timeout: 30000 },
34);
35
36test.provider(
37 "runs on file, env, and command changes; delete leaves side effects alone",
38 (stack) =>
39 Effect.gen(function* () {
40 const fs = yield* FileSystem.FileSystem;
41
42 yield* stack.destroy();
43
44 const fixture = yield* makeTemporaryFixture();
45 const runsLog = pathe.join(fixture.cwd, "runs.log");
46 const inputFile = pathe.join(fixture.cwd, "src", "input.txt");
47
48 // The command appends a line per run; counting lines tells us exactly
49 // how many times the command actually executed.
50 const countRuns = Effect.gen(function* () {
51 if (!(yield* fs.exists(runsLog))) return 0;
52 const content = yield* fs.readFileString(runsLog);
53 return content.split("\n").filter((line) => line.length > 0).length;
54 });
55
56 const deploy = (props: { command?: string; env?: { MARKER: string } }) =>
57 stack.deploy(
58 Command.Exec("test-exec", {
59 command: props.command ?? "bash run.sh",
60 shell: true,
61 cwd: fixture.cwd,
62 env: props.env ?? { MARKER: "first" },
63 memo: { include: ["src/**"] },
64 }),
65 );
66
67 const exec1 = yield* deploy({});
68
69 // Memoization is enabled, so the input-file hash is recorded.
70 expect(exec1.hash.input).toEqual(expect.any(String));
71 expect(yield* countRuns).toBe(1);
72
73 // Unchanged inputs — the run is skipped.
74 const exec2 = yield* deploy({});
75 expect(exec2.hash.input).toBe(exec1.hash.input);
76 expect(yield* countRuns).toBe(1);
77
78 // An env-only change re-runs (e.g. a recreated database's connection URL
79 // with identical files). The hash tracks input files only, so it is
80 // unchanged — the re-run is driven by the prop change, not the hash.
81 const exec3 = yield* deploy({ env: { MARKER: "second" } });
82 expect(yield* countRuns).toBe(2);
83 expect(exec3.hash.input).toBe(exec1.hash.input);
84
85 // A command-only change re-runs.
86 const exec4 = yield* deploy({
87 command: "bash run.sh second-run",
88 env: { MARKER: "second" },
89 });
90 expect(yield* countRuns).toBe(3);
91 expect(exec4.hash.input).toBe(exec3.hash.input);
92
93 // A memoized input file change re-runs and changes the input hash.
94 yield* fs.writeFileString(inputFile, "two\n");
95 const exec5 = yield* deploy({
96 command: "bash run.sh second-run",
97 env: { MARKER: "second" },
98 });
99 expect(yield* countRuns).toBe(4);
100 expect(exec5.hash.input).not.toBe(exec4.hash.input);
101
102 // Destroy never reverses the command's side effects…
103 yield* stack.destroy();
104 expect(yield* countRuns).toBe(4);
105
106 // …and forgets the run key, so an unchanged redeploy runs again.
107 const exec6 = yield* deploy({
108 command: "bash run.sh second-run",
109 env: { MARKER: "second" },
110 });
111 expect(yield* countRuns).toBe(5);
112 expect(exec6.hash.input).toBe(exec5.hash.input);
113
114 yield* stack.destroy();
115 }),
116 { tags: ["unit", "local"], timeout: 60000 },
117);