1import * as S3 from "@distilled.cloud/aws/s3";
2import * as Alchemy from "alchemy";
3import * as AWS from "alchemy/AWS";
4import * as Test from "alchemy/Test/Bun";
5import { expect } from "bun:test";
6import * as Console from "effect/Console";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import Stack from "../alchemy.run.ts";
11
12const { getWhenReady } = Test;
13
14class AssetNotReady extends Data.TaggedError("AssetNotReady")<{
15 body: string;
16}> {}
17
18const getBodyWhenReady = (url: string, expected: string) =>
19 Effect.gen(function* () {
20 const res = yield* getWhenReady(url);
21 expect(res.status).toBe(200);
22 const body = yield* res.text;
23 if (!body.includes(expected)) {
24 return yield* Effect.fail(new AssetNotReady({ body }));
25 }
26 return body;
27 }).pipe(
28 Effect.retry({
29 while: (error) => error instanceof AssetNotReady,
30 schedule: Schedule.min([
31 Schedule.exponential("500 millis"),
32 Schedule.spaced("3 seconds"),
33 ]),
34 times: 10,
35 }),
36 );
37
38const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
39 profile: process.env.ALCHEMY_PROFILE,
40 providers: AWS.providers(),
41 state: AWS.state(),
42});
43
44// Pre-deploy cleanup must not close the suite's shared runtime scope.
45const stack = beforeAll(
46 Effect.sync(Test.defaultStage).pipe(
47 Effect.flatMap((stage) => Alchemy.destroy({ stack: Stack, stage })),
48 Effect.andThen(deploy(Stack)),
49 Effect.tap(Console.log),
50 ),
51 {
52 timeout: 1_200_000,
53 },
54);
55afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
56 timeout: 1_200_000,
57});
58
59const base = Effect.map(stack, ({ url }) => {
60 if (!url) throw new Error("expected the site to expose a CloudFront url");
61 return String(url).replace(/\/+$/, "");
62});
63
64test(
65 "deploys and exposes a url",
66 Effect.gen(function* () {
67 const { url } = yield* stack;
68 expect(url).toBeString();
69 }),
70 { timeout: 180_000 },
71);
72
73test(
74 "serves the home page",
75 Effect.gen(function* () {
76 const url = yield* base;
77 const html = yield* getBodyWhenReady(url, "Hello from vinext on AWS!");
78 expect(html).toContain("vinext on AWS");
79 }),
80 { timeout: 180_000 },
81);
82
83test(
84 "serves the ISR page",
85 Effect.gen(function* () {
86 const url = yield* base;
87 const html = yield* getBodyWhenReady(`${url}/isr`, "ISR");
88 expect(html).toContain("revalidate 60s");
89 }),
90 { timeout: 180_000 },
91);
92
93test.provider(
94 "persists ISR entries in the S3 cache",
95 () =>
96 Effect.gen(function* () {
97 const { cacheBucketName } = yield* stack;
98 if (!cacheBucketName) {
99 throw new Error("expected the site to expose its ISR cache bucket");
100 }
101 const url = yield* base;
102 yield* getBodyWhenReady(`${url}/isr`, "ISR");
103 const objects = yield* S3.listObjectsV2({
104 Bucket: cacheBucketName,
105 Prefix: "vinext-cache/",
106 }).pipe(
107 Effect.repeat({
108 schedule: Schedule.spaced("1 second"),
109 times: 10,
110 until: (result) => (result.Contents?.length ?? 0) > 0,
111 }),
112 );
113 expect(
114 objects.Contents?.some((object) => object.Key?.includes("isr")),
115 ).toBe(true);
116 }),
117 { timeout: 60_000 },
118);
119
120test(
121 "serves the dynamic API route",
122 Effect.gen(function* () {
123 const url = yield* base;
124 const res = yield* getWhenReady(`${url}/api/hello?name=Alchemy`);
125 expect(res.status).toBe(200);
126 const body = yield* res.json;
127 expect(body).toEqual({
128 name: "Alchemy",
129 greeting: "Hello from vinext on AWS!",
130 });
131 }),
132 { timeout: 180_000 },
133);
134
135test(
136 "serves a static asset from public/",
137 Effect.gen(function* () {
138 const url = yield* base;
139 const body = yield* getBodyWhenReady(`${url}/robots.txt`, "User-agent: *");
140 expect(body).toContain("User-agent: *");
141 }),
142 { timeout: 180_000 },
143);