1import type { ScopedPlanStatusSession } from "@/Report.ts";
2import * as Command from "@/Command";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as pathe from "pathe";
9
10const { test } = Test.make({ providers: Command.providers() });
11
12// A dedicated fixture (not shared with Build.test.ts): the suite mutates
13// `src/main.ts` and rebuilds `dist`, and the runner executes files in parallel
14// forks, so sharing a fixture across files would race on the same directory.
15const fixtureDir = pathe.resolve(import.meta.dirname, "command-fixture");
16const distDir = pathe.join(fixtureDir, "dist");
17const mainFile = pathe.join(fixtureDir, "src", "main.ts");
18
19// `reconcile`/`delete` only ever touch `session.note`; everything else on the
20// session is unused by Build.Command, so a no-op note is a faithful stub.
21const stubSession = {
22 note: () => Effect.void,
23} as unknown as ScopedPlanStatusSession;
24
25test.provider(
26 "list returns [] for non-listable Build.Command",
27 () =>
28 Effect.gen(function* () {
29 // Build.Command is a local build/exec step with no remote enumeration
30 // API, so list() is the non-listable pattern: always returns [].
31 const provider = yield* Provider.findProvider(Command.Build);
32 const all = yield* provider.list();
33 expect(all).toEqual([]);
34 }),
35 { tags: ["unit", "local"], timeout: 30000 },
36);
37
38// All filesystem-mutating path scenarios live in one test: tests within a file
39// run concurrently (vitest `sequence.concurrent`), and they all touch the same
40// fixture, so they must be driven sequentially from a single body.
41test.provider(
42 "outdir is always persisted relative and resolves correctly across cwd shapes, rebuilds, and legacy state",
43 (stack) =>
44 Effect.gen(function* () {
45 const fs = yield* FileSystem.FileSystem;
46 const provider = yield* Provider.findProvider(Command.Build);
47 const original = yield* fs.readFileString(mainFile);
48
49 const cleanDist = fs
50 .remove(distDir, { recursive: true })
51 .pipe(Effect.catch(() => Effect.void));
52
53 const deployWith = (cwd: string) =>
54 stack.deploy(
55 Effect.gen(function* () {
56 return yield* Command.Build("test-build", {
57 command: "bash build.sh",
58 shell: true,
59 cwd,
60 outdir: "dist",
61 });
62 }),
63 );
64
65 yield* stack.destroy();
66 yield* cleanDist;
67
68 // ── 1) Absolute cwd ──────────────────────────────────────────────────
69 const fromAbsolute = yield* deployWith(fixtureDir);
70
71 // The stored value must never be absolute — an absolute path is what made
72 // state non-portable across machines (CI runner vs. local laptop) and fed
73 // a stale, foreign-machine path into downstream consumers.
74 expect(pathe.isAbsolute(fromAbsolute.outdir)).toBe(false);
75 // ...but it must still resolve to the real output directory.
76 expect(pathe.resolve(fromAbsolute.outdir)).toBe(distDir);
77
78 yield* stack.destroy();
79 yield* cleanDist;
80
81 // ── 2) Relative cwd pointing at the same physical directory ───────────
82 const relativeCwd = pathe.relative(process.cwd(), fixtureDir);
83 const fromRelative = yield* deployWith(relativeCwd);
84
85 expect(pathe.isAbsolute(fromRelative.outdir)).toBe(false);
86 expect(pathe.resolve(fromRelative.outdir)).toBe(distDir);
87 // However cwd is expressed, the persisted outdir is identical: it's
88 // anchored to process.cwd(), not to the (variably-expressed) build cwd.
89 expect(fromRelative.outdir).toBe(fromAbsolute.outdir);
90
91 // ── 3) Rebuild on source change keeps outdir relative and put ─────────
92 yield* Effect.gen(function* () {
93 yield* fs.writeFileString(
94 mainFile,
95 'export const message = "Updated!";\n',
96 );
97 const rebuilt = yield* deployWith(fixtureDir);
98
99 // Content changed -> hash changed -> a rebuild ran...
100 expect(rebuilt.hash).not.toBe(fromRelative.hash);
101 // ...but the output path didn't move and is still relative.
102 expect(pathe.isAbsolute(rebuilt.outdir)).toBe(false);
103 expect(rebuilt.outdir).toBe(fromRelative.outdir);
104 expect(pathe.resolve(rebuilt.outdir)).toBe(distDir);
105 }).pipe(
106 // Always restore the fixture so a failed assertion can't leave the
107 // working tree dirty for the next run.
108 Effect.ensuring(
109 fs.writeFileString(mainFile, original).pipe(Effect.ignore),
110 ),
111 );
112
113 // ── 4) Legacy absolute outdir persisted in state ──────────────────────
114 // Re-deploy from a clean baseline so the live dist matches `news`.
115 const baseline = yield* deployWith(fixtureDir);
116 const news: Command.BuildProps = {
117 command: "bash build.sh",
118 shell: true,
119 cwd: fixtureDir,
120 outdir: "dist",
121 };
122 // Simulate state written by an older provider version (or another
123 // machine): outdir baked as an absolute path.
124 const legacyOutput = {
125 outdir: pathe.resolve(baseline.outdir),
126 hash: baseline.hash,
127 };
128 expect(pathe.isAbsolute(legacyOutput.outdir)).toBe(true);
129
130 // diff() must return "update" because the output is stale.
131 const refreshed = yield* provider.diff!({
132 id: "test-build",
133 fqn: "test-build",
134 instanceId: "legacy",
135 olds: news,
136 news,
137 oldBindings: [],
138 newBindings: [],
139 output: legacyOutput,
140 });
141 expect(refreshed).toStrictEqual({ action: "update" });
142
143 // reconcile() must rebuild and return a new output.
144 const reconciled = yield* provider.reconcile({
145 id: "test-build",
146 fqn: "test-build",
147 instanceId: "legacy",
148 news,
149 olds: news,
150 output: legacyOutput,
151 session: stubSession,
152 bindings: [],
153 });
154 // Inputs didn't change, so the input hash must match. The output hash
155 // can't be compared: build.sh embeds `$(date)` in dist/output.txt (the
156 // rebuild marker Build.test.ts relies on), so two builds only hash
157 // equal when they land in the same wall-clock second.
158 expect(reconciled.hash.input).toStrictEqual(baseline.hash.input);
159 expect(reconciled.hash.output).toEqual(expect.any(String));
160 expect(pathe.isAbsolute(reconciled.outdir)).toBe(false);
161 expect(pathe.resolve(reconciled.outdir)).toBe(distDir);
162
163 // delete() must resolve the absolute outdir and remove the directory.
164 yield* provider.delete({
165 id: "test-build",
166 fqn: "test-build",
167 instanceId: "legacy",
168 olds: news,
169 output: legacyOutput,
170 session: stubSession,
171 bindings: [],
172 });
173 expect(yield* fs.exists(distDir)).toBe(false);
174
175 yield* stack.destroy();
176 }),
177 { tags: ["unit", "local"], timeout: 120000 },
178);