1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as cloudfront from "@distilled.cloud/aws/cloudfront";
4import { describe, expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import { fileURLToPath } from "node:url";
8import {
9 expectDirectStatus,
10 expectUrlContains,
11} from "../../Cloudflare/Utils/Http.ts";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// Gated: CloudFront Distribution create blocks on Status === "Deployed"
16// (~5-15 min) and destroy requires disable -> wait -> delete (another
17// ~5-15 min). Skipped under --fast (FAST=1) (same gate
18// as the AWS.CloudFront suites).
19const runLive = !process.env.FAST;
20
21// Under the floci runner the full pipeline (bucket + files + KVS +
22// distribution) deploys against the emulator, which serves the
23// distribution's edge on a local plain-HTTP port — so the whole test runs
24// in both modes; only the URL-shape assertions differ.
25const runEmulated = process.env.ALCHEMY_TEST_DEV === "1";
26
27const fixtureDir = fileURLToPath(
28 new URL("./fixtures/static-site", import.meta.url),
29);
30
31const INDEX_MARKER = "alchemy-aws-staticsite-index-marker";
32const ABOUT_MARKER = "alchemy-aws-staticsite-about-marker";
33const CSS_MARKER = "alchemy-aws-staticsite-css-marker";
34
35describe.skipIf(!runLive)(
36 "AWS.Website.StaticSite",
37 {
38 tags: [
39 "provider:aws",
40 "provider:aws:cloudfront",
41 "provider:aws:website",
42 "live",
43 ],
44 },
45 () => {
46 test.provider(
47 "standalone SPA serves index, pretty URLs, and falls back on misses; errorPage update returns real 404s",
48 (stack) =>
49 Effect.gen(function* () {
50 yield* stack.destroy();
51
52 // Phase 1: standalone SPA. Misses fall back to index.html with 200.
53 const spa = yield* stack.deploy(
54 Effect.gen(function* () {
55 const site = yield* AWS.Website.StaticSite("Site", {
56 path: fixtureDir,
57 spa: true,
58 forceDestroy: true,
59 invalidation: { paths: "all", wait: true },
60 });
61 return { site };
62 }),
63 );
64
65 const url = spa.site.url! as string;
66 // `https://{id}.cloudfront.net` live; the emulator serves the
67 // distribution's edge on a local plain-HTTP port.
68 expect(url).toMatch(
69 runEmulated ? /^http:\/\/localhost:\d+/ : /^https:\/\//,
70 );
71
72 // urls contract (cloudfront-default arm): a domain-less site
73 // serves only at the distribution's own URL, and `url` is
74 // always `urls[0]`.
75 expect(spa.site.urls).toEqual([spa.site.distribution!.url]);
76 expect(url).toBe(spa.site.urls[0]);
77
78 // Root serves the index page. Generous first-request budget: the
79 // distribution just reached Deployed but edge propagation can lag.
80 yield* expectUrlContains(`${url}/`, INDEX_MARKER, {
81 timeout: "180 seconds",
82 label: "index",
83 });
84 // Exact asset paths hit S3 (proves the OAC bucket policy works).
85 yield* expectUrlContains(`${url}/styles.css`, CSS_MARKER, {
86 label: "css asset",
87 });
88 // Pretty URL: /about -> about.html via the edge KV lookup.
89 yield* expectUrlContains(`${url}/about`, ABOUT_MARKER, {
90 label: "pretty url",
91 });
92 // SPA fallback: unknown path serves the app shell with a 200.
93 yield* expectUrlContains(
94 `${url}/missing/client/route`,
95 INDEX_MARKER,
96 {
97 label: "spa fallback",
98 },
99 );
100
101 // Phase 2: switch to a real-404 static site with an error page.
102 const site404 = yield* stack.deploy(
103 Effect.gen(function* () {
104 const site = yield* AWS.Website.StaticSite("Site", {
105 path: fixtureDir,
106 errorPage: "404.html",
107 forceDestroy: true,
108 invalidation: { paths: "all", wait: true },
109 });
110 return { site };
111 }),
112 );
113
114 const url404 = site404.site.url! as string;
115 // Content still serves.
116 yield* expectUrlContains(`${url404}/about`, ABOUT_MARKER, {
117 label: "pretty url after update",
118 });
119 // Misses now return a real 404 status.
120 yield* expectDirectStatus(`${url404}/definitely/not/here`, 404, {
121 timeout: "120 seconds",
122 label: "404 status",
123 });
124
125 const distributionId = site404.site.distribution!.distributionId;
126
127 yield* stack.destroy();
128 yield* assertDistributionDeleted(distributionId);
129 }),
130 { timeout: 2_400_000 },
131 );
132 },
133);
134
135const assertDistributionDeleted = (distributionId: string) =>
136 cloudfront.getDistribution({ Id: distributionId }).pipe(
137 Effect.flatMap(() => Effect.fail(new Error("DistributionStillExists"))),
138 Effect.catchTag("NoSuchDistribution", () => Effect.void),
139 Effect.retry({
140 while: (error): boolean =>
141 error instanceof Error && error.message === "DistributionStillExists",
142 schedule: Schedule.max([
143 Schedule.fixed("10 seconds"),
144 Schedule.recurs(60),
145 ]),
146 }),
147 );