1import * as AWS from "@/AWS";
2import { Bucket } from "@/AWS/S3";
3import { Canary, Group } from "@/AWS/Synthetics";
4import * as Output from "@/Output";
5import * as Test from "@/Test/Alchemy";
6import * as synthetics from "@distilled.cloud/aws/synthetics";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13// Trivial heartbeat script — no page load, just a successful step.
14const HEARTBEAT_SCRIPT = `
15const synthetics = require("Synthetics");
16const log = require("SyntheticsLogger");
17
18exports.handler = async function () {
19 return await synthetics.executeStep("heartbeat", async function () {
20 log.info("heartbeat ok");
21 });
22};
23`;
24
25// The provider's delete waits until the canary is gone, so this should
26// resolve quickly; the bounded retry covers read-after-delete lag. It also
27// pins the distilled patch: getCanary's typed ResourceNotFoundException.
28const assertCanaryGone = (canaryName: string) =>
29 synthetics.getCanary({ Name: canaryName }).pipe(
30 Effect.flatMap(() =>
31 Effect.fail(new Error(`canary ${canaryName} still exists`)),
32 ),
33 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
34 Effect.retry({
35 while: (e) => e instanceof Error,
36 schedule: Schedule.max([
37 Schedule.fixed("3 seconds"),
38 Schedule.recurs(20),
39 ]),
40 }),
41 );
42
43const assertGroupGone = (groupName: string) =>
44 synthetics.getGroup({ GroupIdentifier: groupName }).pipe(
45 Effect.flatMap(() =>
46 Effect.fail(new Error(`group ${groupName} still exists`)),
47 ),
48 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
49 Effect.retry({
50 while: (e) => e instanceof Error,
51 schedule: Schedule.max([
52 Schedule.fixed("3 seconds"),
53 Schedule.recurs(20),
54 ]),
55 }),
56 );
57
58describe.sequential(
59 "AWS.Synthetics.Canary",
60 {
61 tags: [
62 "provider:aws",
63 "provider:aws:s3",
64 "provider:aws:synthetics",
65 "live",
66 ],
67 },
68 () => {
69 test.provider(
70 "creates a stopped canary + group, updates the schedule, and deletes",
71 (stack) =>
72 Effect.gen(function* () {
73 yield* stack.destroy();
74
75 const deployCanary = (scheduleExpression: string, groupTag: string) =>
76 stack.deploy(
77 Effect.gen(function* () {
78 const bucket = yield* Bucket("CanaryArtifacts", {
79 forceDestroy: true,
80 });
81 const canary = yield* Canary("Heartbeat", {
82 script: HEARTBEAT_SCRIPT,
83 artifactS3Location: Output.interpolate`s3://${bucket.bucketName}/heartbeat`,
84 schedule: { expression: scheduleExpression },
85 });
86 const group = yield* Group("HeartbeatGroup", {
87 members: [canary.canaryArn],
88 tags: { alchemyTest: groupTag },
89 });
90 return {
91 canaryName: canary.canaryName,
92 canaryArn: canary.canaryArn,
93 executionRoleArn: canary.executionRoleArn,
94 groupName: group.groupName,
95 groupArn: group.groupArn,
96 };
97 }),
98 );
99
100 const created = yield* deployCanary("rate(5 minutes)", "one");
101
102 // Out-of-band verification via distilled.
103 const observed = yield* synthetics.getCanary({
104 Name: created.canaryName,
105 });
106 // Never started → READY (a stopped-after-running canary is STOPPED).
107 expect(["READY", "STOPPED"]).toContain(
108 observed.Canary?.Status?.State,
109 );
110 expect(observed.Canary?.RuntimeVersion).toBe(
111 "syn-nodejs-puppeteer-16.1",
112 );
113 expect(observed.Canary?.Schedule?.Expression).toBe("rate(5 minutes)");
114 expect(observed.Canary?.ExecutionRoleArn).toBe(
115 created.executionRoleArn,
116 );
117 expect(observed.Canary?.Tags?.["alchemy::id"]).toBe("Heartbeat");
118
119 // The group exists, is tagged, and holds the canary as its member.
120 const observedGroup = yield* synthetics.getGroup({
121 GroupIdentifier: created.groupName,
122 });
123 expect(observedGroup.Group?.Name).toBe(created.groupName);
124 expect(observedGroup.Group?.Arn).toBe(created.groupArn);
125 expect(observedGroup.Group?.Tags?.["alchemy::id"]).toBe(
126 "HeartbeatGroup",
127 );
128 expect(observedGroup.Group?.Tags?.alchemyTest).toBe("one");
129 const members = yield* synthetics.listGroupResources({
130 GroupIdentifier: created.groupName,
131 });
132 expect(members.Resources).toContain(created.canaryArn);
133
134 // Update the schedule + group tags in place (same physical names).
135 const updated = yield* deployCanary("rate(10 minutes)", "two");
136 expect(updated.canaryName).toBe(created.canaryName);
137 expect(updated.groupName).toBe(created.groupName);
138 const observedUpdated = yield* synthetics.getCanary({
139 Name: created.canaryName,
140 });
141 expect(observedUpdated.Canary?.Schedule?.Expression).toBe(
142 "rate(10 minutes)",
143 );
144 const updatedGroup = yield* synthetics.getGroup({
145 GroupIdentifier: created.groupName,
146 });
147 expect(updatedGroup.Group?.Tags?.alchemyTest).toBe("two");
148
149 yield* stack.destroy();
150 yield* assertCanaryGone(created.canaryName);
151 yield* assertGroupGone(created.groupName);
152 }),
153 { timeout: 420_000 },
154 );
155
156 // A live canary run takes ~1-2 minutes end to end — gated behind
157 // AWS_TEST_SLOW=1.
158 test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
159 "starts the canary and records a successful run",
160 (stack) =>
161 Effect.gen(function* () {
162 yield* stack.destroy();
163
164 const { canaryName } = yield* stack.deploy(
165 Effect.gen(function* () {
166 const bucket = yield* Bucket("RunArtifacts", {
167 forceDestroy: true,
168 });
169 const canary = yield* Canary("HeartbeatRun", {
170 script: HEARTBEAT_SCRIPT,
171 artifactS3Location: Output.interpolate`s3://${bucket.bucketName}/run`,
172 schedule: { expression: "rate(1 minute)" },
173 start: true,
174 });
175 return { canaryName: canary.canaryName };
176 }),
177 );
178
179 const running = yield* synthetics.getCanary({ Name: canaryName });
180 expect(running.Canary?.Status?.State).toBe("RUNNING");
181
182 // Poll for the first completed successful run.
183 const passedRun = yield* synthetics
184 .getCanaryRuns({ Name: canaryName })
185 .pipe(
186 Effect.map((r) =>
187 (r.CanaryRuns ?? []).find(
188 (run) => run.Status?.State === "PASSED",
189 ),
190 ),
191 Effect.repeat({
192 schedule: Schedule.spaced("10 seconds"),
193 until: (run) => run !== undefined,
194 times: 24,
195 }),
196 );
197 expect(passedRun?.Status?.State).toBe("PASSED");
198
199 yield* stack.destroy();
200 yield* assertCanaryGone(canaryName);
201 }),
202 { timeout: 600_000 },
203 );
204 },
205);