1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as s3 from "@distilled.cloud/aws/s3";
4import { describe, expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as pathe from "pathe";
7import { cloneFixture } from "../../Cloudflare/Utils/Fixture.ts";
8import { expectUrlContains } from "../../Cloudflare/Utils/Http.ts";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12// Gated: CloudFront Distribution create blocks on Status === "Deployed"
13// (~5-15 min) and destroy requires disable -> wait -> delete (another
14// ~5-15 min). Skipped under --fast (FAST=1) (same gate
15// as the AWS.CloudFront suites).
16const runLive = !process.env.FAST;
17
18// The emulated pipeline test runs under the floci runner
19// (`pnpm test:aws:floci`), where every pipeline provider resolves its local
20// (emulator-backed) variant.
21const runEmulated = process.env.ALCHEMY_TEST_DEV === "1";
22
23const viteFixtureDir = pathe.resolve(
24 import.meta.dirname,
25 "fixtures",
26 "vite-app",
27);
28const staticFixtureDir = pathe.resolve(
29 import.meta.dirname,
30 "fixtures",
31 "static-site",
32);
33
34// Clone under the alchemy package so `vite` resolves from the workspace's
35// hoisted node_modules (the fixture has no node_modules).
36const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
37
38// Also skipped under the floci runner (`runEmulated`): in dev the composite
39// deploys nothing but the vite dev server, so the live CloudFront topology
40// this test asserts never exists. The emulated pipeline test below and
41// Vite.local.test.ts cover the dev-mode behavior.
42describe.skipIf(!runLive || runEmulated)(
43 "AWS.Website.Vite",
44 { tags: ["provider:aws", "provider:aws:website", "live"] },
45 () => {
46 test.provider(
47 "deploys the vite build to S3 behind CloudFront with SPA fallback",
48 (stack) =>
49 Effect.gen(function* () {
50 yield* stack.destroy();
51
52 const rootDir = yield* cloneFixture(viteFixtureDir, {
53 prefix: "alchemy-vite-aws-live-",
54 tempRoot,
55 entries: [
56 ".gitignore",
57 "package.json",
58 "index.html",
59 "src",
60 "public",
61 ],
62 });
63
64 const deployed = yield* stack.deploy(
65 Effect.gen(function* () {
66 const site = yield* AWS.Website.Vite("ViteSite", {
67 rootDir,
68 forceDestroy: true,
69 invalidation: { paths: "all", wait: true },
70 });
71 return { site };
72 }),
73 );
74
75 const url = deployed.site.url! as string;
76 expect(url).toMatch(/^https:\/\//);
77 // Assets-only: the composite never creates a server function.
78 expect(deployed.site.server).toBeUndefined();
79 expect(deployed.site.serverUrl).toBeUndefined();
80
81 // The built index page serves from the edge.
82 yield* expectUrlContains(`${url}/`, "VITE_AWS_PAGE_MARKER", {
83 timeout: "180 seconds",
84 label: "index",
85 });
86 // publicDir passthrough landed in the bucket.
87 yield* expectUrlContains(`${url}/robots.txt`, "User-agent", {
88 label: "public asset",
89 });
90 // SPA fallback (the composite's default): misses serve the shell.
91 yield* expectUrlContains(
92 `${url}/missing/client/route`,
93 "VITE_AWS_PAGE_MARKER",
94 { label: "spa fallback" },
95 );
96
97 yield* stack.destroy();
98 }),
99 { timeout: 2_400_000 },
100 );
101 },
102);
103
104// The Vite composite intentionally never deploys the static pipeline under
105// `alchemy dev` (dev IS the vite dev server — see Vite.local.test.ts), so
106// its deploy path cannot be exercised in a dev-mode test. This test pins the
107// SAME underlying pipeline (`makeKvSite`: bucket + AssetDeployment +
108// KvEntries + KvRoutesUpdate + distribution + invalidation) against the
109// emulator via StaticSite, which deploys the full pipeline in dev when no
110// `dev` prop is set. This is the regression net for non-dualized pipeline
111// providers — the class of bug that previously only surfaced in
112// `alchemy dev` of the examples (Files/KvEntries/RoutesUpdate hitting real
113// AWS from an emulated stack).
114describe.skipIf(!runEmulated)(
115 "AWS.Website static pipeline (emulated)",
116 { tags: ["provider:aws", "provider:aws:s3", "provider:aws:website", "live"] },
117 () => {
118 test.provider(
119 "deploys the full S3 + CloudFront + KVS pipeline against the emulator",
120 (stack) =>
121 Effect.gen(function* () {
122 yield* stack.destroy();
123
124 const deployed = yield* stack.deploy(
125 Effect.gen(function* () {
126 const site = yield* AWS.Website.StaticSite("PipelineSite", {
127 path: staticFixtureDir,
128 spa: true,
129 forceDestroy: true,
130 invalidation: { paths: "all" },
131 });
132 return { site };
133 }),
134 );
135
136 // Every pipeline resource reconciled against the emulator: the
137 // distribution identity has AWS shape but was minted locally.
138 const bucketName = deployed.site.bucket!.bucketName as string;
139 expect(bucketName).toBeTruthy();
140 expect(deployed.site.distribution!.distributionId).toBeTruthy();
141 expect(deployed.site.files!.version).toBeTruthy();
142
143 // Out-of-band proof the assets landed in the emulator's S3 (the
144 // harness pins distilled to the emulator in dev runs).
145 const listed = yield* s3.listObjectsV2({ Bucket: bucketName });
146 const keys = (listed.Contents ?? []).flatMap((object) =>
147 object.Key !== undefined ? [object.Key] : [],
148 );
149 expect(keys.some((key) => key.endsWith("index.html"))).toBe(true);
150 expect(keys.some((key) => key.endsWith("styles.css"))).toBe(true);
151 expect(keys.some((key) => key.endsWith("about.html"))).toBe(true);
152
153 yield* stack.destroy();
154
155 // The bucket is gone from the emulator after destroy.
156 const gone = yield* s3.listObjectsV2({ Bucket: bucketName }).pipe(
157 Effect.as(false),
158 Effect.catchTag("NoSuchBucket", () => Effect.succeed(true)),
159 );
160 expect(gone).toBe(true);
161 }),
162 { timeout: 300_000 },
163 );
164 },
165);