1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Test from "@/Test/Alchemy";
3import { describe, expect } from "alchemy-test";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as pathe from "pathe";
8import { cloneFixture } from "../Utils/Fixture.ts";
9
10const { test } = Test.make({ providers: Cloudflare.providers() });
11
12const main = pathe.resolve(import.meta.dirname, "fixtures/worker.ts");
13const assetsFixture = pathe.resolve(
14 import.meta.dirname,
15 "fixtures/assets-only",
16);
17
18const actionOf = (plan: any, logicalId: string) =>
19 (Object.values(plan.resources) as any[]).find(
20 (node: any) => node.resource.LogicalId === logicalId,
21 )?.action;
22
23describe.concurrent(
24 "Cloudflare.Worker assets plan convergence",
25 { tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"] },
26 () => {
27 // A Worker whose `assets` is a plain `{ directory }` (no precomputed
28 // `hash` from an upstream build) must still converge to a noop plan when
29 // the directory contents are unchanged. The diff hashes the tree the same
30 // way the apply does, so users don't have to hand-roll their own
31 // directory hashing to get convergent plans.
32 test.provider(
33 "unchanged assets directory converges to a noop plan",
34 (stack) =>
35 Effect.gen(function* () {
36 const fs = yield* FileSystem.FileSystem;
37 const path = yield* Path.Path;
38
39 yield* stack.destroy();
40
41 // Two independent mutable copies of the assets fixture: `dirA`
42 // feeds the main+assets worker, `dirB` feeds the assets-only and
43 // script+assets workers, so editing `dirA` must dirty only the
44 // first worker.
45 const dirA = yield* cloneFixture(assetsFixture, {
46 prefix: "alchemy-assets-plan-a-",
47 });
48 const dirB = yield* cloneFixture(assetsFixture, {
49 prefix: "alchemy-assets-plan-b-",
50 });
51
52 // One worker per `hasChanged` branch that previously returned a
53 // conservative "changed" whenever `assets` carried no precomputed
54 // hash: bundled main + assets, assets-only (no entry), and inline
55 // script + assets.
56 const program = (a: string, b: string) =>
57 Effect.gen(function* () {
58 const withMain = yield* Cloudflare.Worker("AssetsPlanWithMain", {
59 main,
60 assets: { directory: a },
61 compatibility: { date: "2024-01-01" },
62 });
63 const assetsOnly = yield* Cloudflare.Worker(
64 "AssetsPlanAssetsOnly",
65 {
66 assets: { directory: b, notFoundHandling: "404-page" },
67 compatibility: { date: "2024-01-01" },
68 },
69 );
70 const withScript = yield* Cloudflare.Worker("AssetsPlanScript", {
71 script: `export default { fetch: () => new Response("assets-plan-script") };`,
72 assets: { directory: b },
73 compatibility: { date: "2024-01-01" },
74 });
75 return { withMain, assetsOnly, withScript };
76 });
77
78 yield* stack.deploy(program(dirA, dirB));
79
80 // Nothing changed → every worker must plan as a noop. This is the
81 // regression: the diff used to refuse to read the assets directory
82 // and conservatively reported "update" on every plan.
83 const settled = yield* stack.plan(program(dirA, dirB));
84 expect(actionOf(settled, "AssetsPlanWithMain")).toBe("noop");
85 expect(actionOf(settled, "AssetsPlanAssetsOnly")).toBe("noop");
86 expect(actionOf(settled, "AssetsPlanScript")).toBe("noop");
87
88 // Editing an asset in dirA must dirty exactly the worker that
89 // serves it — content changes still surface as updates.
90 yield* fs.writeFileString(
91 path.join(dirA, "index.html"),
92 "<html><body>alchemy-assets-plan-index-v2</body></html>",
93 );
94 const changed = yield* stack.plan(program(dirA, dirB));
95 expect(actionOf(changed, "AssetsPlanWithMain")).toBe("update");
96 expect(actionOf(changed, "AssetsPlanAssetsOnly")).toBe("noop");
97 expect(actionOf(changed, "AssetsPlanScript")).toBe("noop");
98
99 // Deploying the change re-settles the plan.
100 yield* stack.deploy(program(dirA, dirB));
101 const resettled = yield* stack.plan(program(dirA, dirB));
102 expect(actionOf(resettled, "AssetsPlanWithMain")).toBe("noop");
103 expect(actionOf(resettled, "AssetsPlanAssetsOnly")).toBe("noop");
104 expect(actionOf(resettled, "AssetsPlanScript")).toBe("noop");
105
106 // Path invariance: identical bytes at a different absolute path
107 // must hash identically — a plan made from a different checkout
108 // (CI runner → laptop, monorepo root → workspace root) converges
109 // without spurious updates. The directory path is deliberately
110 // excluded from the content hash.
111 const dirB2 = yield* cloneFixture(dirB, {
112 prefix: "alchemy-assets-plan-b2-",
113 });
114 const moved = yield* stack.plan(program(dirA, dirB2));
115 expect(actionOf(moved, "AssetsPlanAssetsOnly")).toBe("noop");
116 expect(actionOf(moved, "AssetsPlanScript")).toBe("noop");
117
118 // `.assetsignore` and the files it excludes participate in neither
119 // the manifest nor the hash — adding them must stay a noop.
120 yield* fs.writeFileString(
121 path.join(dirB, ".assetsignore"),
122 "junk.txt",
123 );
124 yield* fs.writeFileString(
125 path.join(dirB, "junk.txt"),
126 "not-an-asset",
127 );
128 const ignored = yield* stack.plan(program(dirA, dirB));
129 expect(actionOf(ignored, "AssetsPlanAssetsOnly")).toBe("noop");
130 expect(actionOf(ignored, "AssetsPlanScript")).toBe("noop");
131
132 // `_headers` is excluded from the manifest but shipped via the
133 // asset config, so editing it must dirty every worker serving the
134 // directory.
135 yield* fs.writeFileString(
136 path.join(dirB, "_headers"),
137 "/*\n X-Assets-Plan: v1\n",
138 );
139 const headers = yield* stack.plan(program(dirA, dirB));
140 expect(actionOf(headers, "AssetsPlanWithMain")).toBe("noop");
141 expect(actionOf(headers, "AssetsPlanAssetsOnly")).toBe("update");
142 expect(actionOf(headers, "AssetsPlanScript")).toBe("update");
143 yield* fs.remove(path.join(dirB, "_headers"));
144 const headersReverted = yield* stack.plan(program(dirA, dirB));
145 expect(actionOf(headersReverted, "AssetsPlanAssetsOnly")).toBe(
146 "noop",
147 );
148 expect(actionOf(headersReverted, "AssetsPlanScript")).toBe("noop");
149
150 // A directory that's missing at plan time (e.g. produced by an
151 // upstream build step that only runs during apply) degrades to
152 // "update" — the plan must not crash.
153 yield* fs.remove(dirA, { recursive: true });
154 const missing = yield* stack.plan(program(dirA, dirB));
155 expect(actionOf(missing, "AssetsPlanWithMain")).toBe("update");
156 expect(actionOf(missing, "AssetsPlanAssetsOnly")).toBe("noop");
157 expect(actionOf(missing, "AssetsPlanScript")).toBe("noop");
158
159 yield* stack.destroy();
160 }),
161 { timeout: 360_000 },
162 );
163
164 // The migration path off a hand-rolled hash: a worker deployed with a
165 // user-supplied `assets.hash` whose props then drop the hash must plan
166 // exactly one update (the stored custom hash can't match the content
167 // hash), converge after the deploy, and dirty again if a different
168 // supplied hash is introduced.
169 test.provider(
170 "removing a supplied assets hash converges after one deploy",
171 (stack) =>
172 Effect.gen(function* () {
173 yield* stack.destroy();
174
175 const dir = yield* cloneFixture(assetsFixture, {
176 prefix: "alchemy-assets-hash-migration-",
177 });
178
179 const program = (hash?: string) =>
180 Effect.gen(function* () {
181 return yield* Cloudflare.Worker("AssetsHashMigration", {
182 main,
183 assets: hash ? { directory: dir, hash } : { directory: dir },
184 compatibility: { date: "2024-01-01" },
185 });
186 });
187
188 // Deployed with a hand-rolled hash: the supplied hash is
189 // authoritative, so an identical plan is a noop without reading
190 // the directory.
191 yield* stack.deploy(program("hand-rolled-hash-v1"));
192 const settled = yield* stack.plan(program("hand-rolled-hash-v1"));
193 expect(actionOf(settled, "AssetsHashMigration")).toBe("noop");
194
195 // Dropping the hash falls back to content hashing — the stored
196 // custom hash can't match, so exactly one update is planned...
197 const dropped = yield* stack.plan(program());
198 expect(actionOf(dropped, "AssetsHashMigration")).toBe("update");
199
200 // ...and after deploying, the stored hash is the content hash and
201 // the plan converges.
202 yield* stack.deploy(program());
203 const converged = yield* stack.plan(program());
204 expect(actionOf(converged, "AssetsHashMigration")).toBe("noop");
205
206 // Re-introducing a different supplied hash dirties the plan again.
207 const reintroduced = yield* stack.plan(
208 program("hand-rolled-hash-v2"),
209 );
210 expect(actionOf(reintroduced, "AssetsHashMigration")).toBe("update");
211
212 yield* stack.destroy();
213 }),
214 { timeout: 360_000 },
215 );
216 },
217);