| 1 | import * as Command from "@/Command"; |
| 2 | import * as Provider from "@/Provider"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import { expect } from "alchemy-test"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as pathe from "pathe"; |
| 8 | |
| 9 | const { test } = Test.make({ providers: Command.providers() }); |
| 10 | |
| 11 | const 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`). |
| 16 | const 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 | |
| 23 | test.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 | |
| 36 | test.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 | ); |