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 FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as Schedule from "effect/Schedule";
9import * as pathe from "pathe";
10import { cloneFixture } from "../../Cloudflare/Utils/Fixture.ts";
11import { expectUrlContains } from "../../Cloudflare/Utils/Http.ts";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// Gated with the rest of the AWS.Website suites: the CloudFront lifecycle
16// dominates the runtime (create ~5-15 min, destroy ~5-15 min).
17const runLive = !process.env.FAST;
18
19const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures", "octane-app");
20
21// Clone under the alchemy package so `octane`/`@octanejs/*`/`vite` resolve
22// from the workspace's hoisted node_modules (the fixture has no
23// node_modules).
24const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
25
26const fixtureEntries = [
27 ".gitignore",
28 "package.json",
29 "octane.config.ts",
30 "vite.config.ts",
31 "index.html",
32 "src",
33 "public",
34];
35
36// Skipped under the floci runner: in dev the composite deploys only the
37// framework dev server (no Lambda/S3/CloudFront), so this test's live
38// topology assertions are meaningless there. Dev behavior is covered by
39// the co-located Octane.local.test.ts suite.
40const runEmulated = process.env.ALCHEMY_TEST_DEV === "1";
41
42describe.skipIf(!runLive || runEmulated)(
43 "AWS.Website.Octane",
44 {
45 tags: [
46 "provider:aws",
47 "provider:aws:cloudfront",
48 "provider:aws:website",
49 "live",
50 ],
51 },
52 () => {
53 test.provider(
54 "deploys SSR on a streaming Lambda URL with S3 assets behind CloudFront",
55 (stack) =>
56 Effect.gen(function* () {
57 yield* stack.destroy();
58
59 const rootDir = yield* cloneFixture(fixtureDir, {
60 prefix: "alchemy-octane-aws-",
61 tempRoot,
62 entries: fixtureEntries,
63 });
64
65 const fs = yield* FileSystem.FileSystem;
66 const path = yield* Path.Path;
67 const configPath = path.join(rootDir, "octane.config.ts");
68 const config = yield* fs.readFileString(configPath);
69 expect(config).not.toContain("adapter:");
70 expect(config).not.toContain("@alchemy.run/frontend-frameworks");
71
72 const deployed = yield* stack.deploy(
73 Effect.gen(function* () {
74 const site = yield* AWS.Website.Octane("OctaneSite", {
75 rootDir,
76 forceDestroy: true,
77 invalidation: { paths: "all", wait: true },
78 });
79 return { site };
80 }),
81 );
82
83 expect(yield* fs.readFileString(configPath)).toBe(config);
84 const url = deployed.site.url! as string;
85 expect(url).toMatch(/^https:\/\//);
86 expect(deployed.site.serverUrl).toBeDefined();
87 yield* Effect.log(
88 `site url: ${url} | server url: ${deployed.site.serverUrl}`,
89 );
90
91 // The Lambda Function URL serves the SSR page directly — isolates
92 // server-function health from the CloudFront edge routing.
93 yield* expectUrlContains(
94 `${deployed.site.serverUrl!}`,
95 "OCTANE_AWS_PAGE_MARKER",
96 {
97 timeout: "120 seconds",
98 label: "SSR direct from Lambda URL",
99 },
100 );
101
102 // SSR page rendered by the Lambda through CloudFront.
103 yield* expectUrlContains(`${url}/`, "OCTANE_AWS_PAGE_MARKER", {
104 timeout: "180 seconds",
105 label: "SSR home page",
106 });
107 // Server API route through the streaming Function URL origin.
108 yield* expectUrlContains(
109 `${url}/api/hello?echo=roundtrip`,
110 "OCTANE_AWS_API_MARKER",
111 { label: "API route" },
112 );
113 yield* expectUrlContains(
114 `${url}/api/hello?echo=roundtrip`,
115 "roundtrip",
116 { label: "API route query echo" },
117 );
118 // Public file served from S3 via the KV file manifest.
119 yield* expectUrlContains(
120 `${url}/robots.txt`,
121 "octane-aws-robots-marker",
122 {
123 label: "public asset from S3",
124 },
125 );
126
127 const distributionId = deployed.site.distribution!.distributionId;
128
129 if (!process.env.NO_DESTROY) {
130 yield* stack.destroy();
131 yield* assertDistributionDeleted(distributionId);
132 }
133 }),
134 { timeout: 2_400_000 },
135 );
136 },
137);
138
139const assertDistributionDeleted = (distributionId: string) =>
140 cloudfront.getDistribution({ Id: distributionId }).pipe(
141 Effect.flatMap(() => Effect.fail(new Error("DistributionStillExists"))),
142 Effect.catchTag("NoSuchDistribution", () => Effect.void),
143 Effect.retry({
144 while: (error): boolean =>
145 error instanceof Error && error.message === "DistributionStillExists",
146 schedule: Schedule.max([
147 Schedule.fixed("10 seconds"),
148 Schedule.recurs(60),
149 ]),
150 }),
151 );