| 1 | import * as Cloudflare from "@/Cloudflare/index.ts"; |
| 2 | import * as Test from "@/Test/Alchemy"; |
| 3 | import { describe, expect } from "alchemy-test"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Path from "effect/Path"; |
| 7 | import * as pathe from "pathe"; |
| 8 | import { cloneFixture } from "../Utils/Fixture.ts"; |
| 9 | |
| 10 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 11 | |
| 12 | const main = pathe.resolve(import.meta.dirname, "fixtures/worker.ts"); |
| 13 | const assetsFixture = pathe.resolve( |
| 14 | import.meta.dirname, |
| 15 | "fixtures/assets-only", |
| 16 | ); |
| 17 | |
| 18 | const actionOf = (plan: any, logicalId: string) => |
| 19 | (Object.values(plan.resources) as any[]).find( |
| 20 | (node: any) => node.resource.LogicalId === logicalId, |
| 21 | )?.action; |
| 22 | |
| 23 | describe.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 | ); |