1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientRequest from "effect/http/HttpClientRequest";
9import RumBindingsFunctionLive, { RumBindingsFunction } from "./handler";
10
11const testOptions = { providers: AWS.providers() };
12const { test, beforeAll, afterAll } = Test.make(testOptions);
13const sharedStack = Core.scratchStack(testOptions, "RumBindings");
14
15// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
16// over 60s on a fresh deploy.
17const readinessPolicy = Schedule.max([
18 Schedule.fixed("2 seconds"),
19 Schedule.recurs(75),
20]);
21
22let baseUrl: string;
23
24const getJson = (path: string) =>
25 HttpClient.get(`${baseUrl}${path}`).pipe(Effect.flatMap((r) => r.json));
26
27const postJson = (path: string) =>
28 HttpClient.execute(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
29 Effect.flatMap((r) => r.json),
30 );
31
32interface TagResponse {
33 tag: string;
34 error?: string;
35 count?: number;
36}
37
38// The role policy granting rum:* on the monitor is attached moments before
39// the first request — IAM propagation can lag ~10-30s, surfacing as
40// AccessDeniedException. Retry (bounded) until the grant lands; any other
41// tag surfaces immediately for assertion.
42const untilGranted = <E, R>(request: Effect.Effect<unknown, E, R>) =>
43 request.pipe(
44 Effect.map((r) => r as TagResponse),
45 Effect.repeat({
46 schedule: Schedule.spaced("5 seconds"),
47 until: (r): boolean => r.tag !== "AccessDeniedException",
48 times: 10,
49 }),
50 );
51
52describe.sequential(
53 "RUM Bindings",
54 { tags: ["provider:aws", "provider:aws:lambda", "provider:aws:rum", "live"] },
55 () => {
56 beforeAll(
57 Effect.gen(function* () {
58 yield* Effect.logInfo("RUM test setup: destroying previous resources");
59 yield* sharedStack.destroy();
60
61 yield* Effect.logInfo("RUM test setup: deploying fixture");
62 const attrs = yield* sharedStack.deploy(
63 Effect.gen(function* () {
64 return yield* RumBindingsFunction;
65 }).pipe(Effect.provide(RumBindingsFunctionLive)),
66 );
67
68 expect(attrs.functionUrl).toBeTruthy();
69 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
70
71 yield* Effect.logInfo("RUM test setup: probing readiness");
72 yield* HttpClient.get(`${baseUrl}/bindings`).pipe(
73 Effect.flatMap((response) =>
74 response.status === 200
75 ? Effect.succeed(response)
76 : Effect.fail(
77 new Error(`Function not ready: ${response.status}`),
78 ),
79 ),
80 Effect.retry({ schedule: readinessPolicy }),
81 );
82 }),
83 { timeout: 300_000 },
84 );
85
86 afterAll(process.env.NO_DESTROY ? Effect.void : sharedStack.destroy(), {
87 timeout: 300_000,
88 });
89
90 describe("binding registration", () => {
91 test.provider("both capabilities initialize in the runtime", (_stack) =>
92 Effect.gen(function* () {
93 const response = (yield* getJson("/bindings")) as { bound: string[] };
94 expect(response.bound).toHaveLength(2);
95 expect(response.bound).toContain("putRumEvents");
96 expect(response.bound).toContain("getAppMonitorData");
97 }),
98 );
99 });
100
101 describe("PutRumEvents", () => {
102 test.provider(
103 "sends a session's events to the data-plane endpoint",
104 (_stack) =>
105 Effect.gen(function* () {
106 const response = (yield* untilGranted(
107 postJson("/events"),
108 )) as TagResponse;
109 yield* Effect.logInfo(
110 `/events response: ${JSON.stringify(response)}`,
111 );
112 // "ok" proves the dataplane host prefix, the id/details injection,
113 // and the rum:PutRumEvents grant all round-tripped. On failure the
114 // route's error string surfaces in the assertion diff.
115 expect(response.error ?? response.tag).toBe("ok");
116 }),
117 { timeout: 120_000 },
118 );
119 });
120
121 describe("GetAppMonitorData", () => {
122 test.provider(
123 "reads the trailing hour of events (name injected)",
124 (_stack) =>
125 Effect.gen(function* () {
126 const response = (yield* untilGranted(
127 getJson("/data"),
128 )) as TagResponse;
129 yield* Effect.logInfo(
130 `/data response: ${JSON.stringify(response)}`,
131 );
132 expect(response.error ?? response.tag).toBe("ok");
133 // Ingested events surface asynchronously (minutes) — a zero count
134 // still proves the grant + monitor-name injection round-tripped.
135 expect(response.count).toBeGreaterThanOrEqual(0);
136 }),
137 { timeout: 120_000 },
138 );
139 });
140 },
141);