1import * as Command from "@/Command";
2import { Stack } from "@/Stack";
3import { type ResourceState, State } from "@/State";
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(), dev: true });
11
12const FIXTURE_DIR = pathe.resolve(import.meta.dirname, "fixture");
13
14const makeTemporaryFixture = Effect.fn(function* () {
15 const fs = yield* FileSystem.FileSystem;
16 const tempDir = yield* fs.makeTempDirectoryScoped();
17 yield* fs.copy(FIXTURE_DIR, tempDir);
18 return { cwd: tempDir, outdir: pathe.join(tempDir, "dist") };
19});
20
21// Seed a resource row directly into the scratch stack's shared in-memory state,
22// simulating state written by an older provider version. Both the test body and
23// the deploys driven through `stack` read/write the same store keyed by
24// (stack, stage, fqn).
25const seedLegacyState = Effect.fn(function* (
26 fqn: string,
27 value: ResourceState,
28) {
29 const state = yield* yield* State;
30 const stk = yield* Stack;
31 yield* state.set({ stack: stk.name, stage: stk.stage, fqn, value });
32});
33
34const getState = Effect.fn(function* (fqn: string) {
35 const state = yield* yield* State;
36 const stk = yield* Stack;
37 return (yield* state.get({ stack: stk.name, stage: stk.stage, fqn })) as
38 | ResourceState
39 | undefined;
40});
41
42// ─────────────────────────────────────────────────────────────────────
43// Build.Command → Command.Build
44//
45// `Build.Command` was renamed to `Command.Build` and its `attr.hash` shape
46// changed from a bare `string` to `{ input, output }`. Deploying the renamed
47// resource over the old state must not error — it may re-run the build (the
48// memoization logic changed), but it must converge.
49// ─────────────────────────────────────────────────────────────────────
50test.provider(
51 "Command.Build deploys over legacy Build.Command state without error",
52 (stack) =>
53 Effect.gen(function* () {
54 yield* stack.destroy();
55
56 const fixture = yield* makeTemporaryFixture();
57
58 // Mirror `examples/.../Website__Build.json`: old resourceType and the
59 // legacy `attr.hash` string (pre-`{ input, output }`).
60 yield* seedLegacyState("test-build", {
61 status: "created",
62 fqn: "test-build",
63 logicalId: "test-build",
64 instanceId: "606bcaf4c0a931156f5a314ba7b57fc3",
65 resourceType: "Build.Command",
66 props: { command: "bash build.sh", outdir: "dist", env: {} },
67 attr: {
68 outdir: "dist",
69 hash: "a41cbcecfdeb355f6f98130ca4ff8269d0ad5cc3a628f1918d956c71f859648c",
70 },
71 bindings: [],
72 providerVersion: 0,
73 downstream: [],
74 removalPolicy: "destroy",
75 namespace: undefined,
76 });
77
78 const build = yield* stack.deploy(
79 Command.Build("test-build", {
80 command: "bash build.sh",
81 shell: true,
82 cwd: fixture.cwd,
83 outdir: "dist",
84 }),
85 );
86
87 // The build re-ran and produced the new attribute shape.
88 expect(pathe.resolve(build.outdir)).toBe(fixture.outdir);
89 expect(build.hash).toMatchObject({
90 input: expect.any(String),
91 output: expect.any(String),
92 });
93
94 // State is migrated to the new resource type.
95 const migrated = yield* getState("test-build");
96 expect(migrated?.resourceType).toBe("Command.Build");
97
98 yield* stack.destroy();
99 }),
100 { tags: ["local"], timeout: 60000 },
101);
102
103// ─────────────────────────────────────────────────────────────────────
104// Build.DevServer → Command.Dev
105//
106// `Build.DevServer` was renamed to `Command.Dev`. Deploying the renamed
107// resource (in dev mode) over the old state must spawn the new dev process and
108// converge — re-extracting a fresh URL rather than carrying the stale one.
109// ─────────────────────────────────────────────────────────────────────
110test.provider(
111 "Command.Dev deploys over legacy Build.DevServer state without error",
112 (stack) =>
113 Effect.gen(function* () {
114 yield* stack.destroy();
115
116 // Mirror `examples/.../Website__Dev.json`, including the stale URL.
117 yield* seedLegacyState("test-dev", {
118 status: "created",
119 fqn: "test-dev",
120 logicalId: "test-dev",
121 instanceId: "b4ca740550ef6e2e409481cedf9314c7",
122 resourceType: "Build.DevServer",
123 props: { command: "zola serve", env: {} },
124 attr: { url: "http://127.0.0.1:1024" },
125 bindings: [],
126 providerVersion: 0,
127 downstream: [],
128 removalPolicy: "destroy",
129 namespace: undefined,
130 });
131
132 // A dev server must stay alive (the provider treats an early exit as an
133 // error), so print a URL and then block. `stack.destroy()` interrupts it.
134 const dev = yield* stack.deploy(
135 Command.Dev("test-dev", {
136 command: "echo http://127.0.0.1:4321 && sleep 600",
137 shell: true,
138 }),
139 );
140
141 // The new provider ran: it re-extracted the URL the spawned process
142 // printed rather than echoing back the stale seeded attribute.
143 expect(dev.url).toBe("http://127.0.0.1:4321");
144
145 const migrated = yield* getState("test-dev");
146 expect(migrated?.resourceType).toBe("Command.Dev");
147
148 yield* stack.destroy();
149 }),
150 { tags: ["local"], timeout: 60000 },
151);
152
153// ─────────────────────────────────────────────────────────────────────
154// Orphaned legacy resource cleanup
155//
156// When a `Build.Command` / `Build.DevServer` row lingers in state but is no
157// longer declared, the engine resolves the old type to the `Renamed` no-op
158// provider and deletes the row. This must not error.
159// ─────────────────────────────────────────────────────────────────────
160test.provider(
161 "destroy removes an orphaned legacy Build.Command via the renamed provider",
162 (stack) =>
163 Effect.gen(function* () {
164 yield* stack.destroy();
165
166 yield* seedLegacyState("orphan-build", {
167 status: "created",
168 fqn: "orphan-build",
169 logicalId: "orphan-build",
170 instanceId: "606bcaf4c0a931156f5a314ba7b57fc3",
171 resourceType: "Build.Command",
172 props: { command: "zola build", outdir: "public", env: {} },
173 attr: {
174 outdir: "public",
175 hash: "a41cbcecfdeb355f6f98130ca4ff8269d0ad5cc3a628f1918d956c71f859648c",
176 },
177 bindings: [],
178 providerVersion: 0,
179 downstream: [],
180 removalPolicy: "destroy",
181 namespace: undefined,
182 });
183
184 expect(yield* getState("orphan-build")).toBeDefined();
185
186 // An empty-plan apply (destroy) reaps the orphan through the renamed
187 // provider's no-op delete.
188 yield* stack.destroy();
189
190 expect(yield* getState("orphan-build")).toBeUndefined();
191 }),
192 { tags: ["local"], timeout: 60000 },
193);