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 Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import { HELLO_PNG_TEXT, TRANSLATE_TEXT } from "./constants.ts";
11import AICapabilitiesTestFunctionLive, {
12 AICapabilitiesTestFunction,
13} from "./handler.ts";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "AICapabilitiesBindings");
18
19// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
20// over 60s on a fresh deploy under parallel-suite load. Budget ~150s of
21// readiness polling.
22const readinessPolicy = Schedule.max([
23 Schedule.fixed("2 seconds"),
24 Schedule.recurs(75),
25]);
26
27let baseUrl: string;
28
29class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
30 readonly status: number;
31 readonly body: string;
32}> {}
33
34// The shared Lambda fixture occasionally answers a transient 5xx under
35// parallel load (cold re-init, IAM propagation, upstream throttling surfaced
36// as a defect). Retry 5xx only; a genuine 4xx/assertion failure surfaces
37// immediately.
38const send = (request: HttpClientRequest.HttpClientRequest) =>
39 HttpClient.execute(request).pipe(
40 Effect.flatMap((response) =>
41 response.status >= 500
42 ? response.text.pipe(
43 Effect.flatMap((body) =>
44 Effect.fail(
45 new TransientUpstream({ status: response.status, body }),
46 ),
47 ),
48 )
49 : Effect.succeed(response),
50 ),
51 Effect.retry({
52 while: (e) => e._tag === "TransientUpstream",
53 schedule: Schedule.max([
54 Schedule.exponential("1 second"),
55 Schedule.recurs(5),
56 ]),
57 }),
58 );
59
60describe(
61 "AI Capabilities Bindings",
62 {
63 tags: [
64 "provider:aws",
65 "provider:aws:comprehend",
66 "provider:aws:lambda",
67 "provider:aws:polly",
68 "provider:aws:rekognition",
69 "provider:aws:textract",
70 "provider:aws:translate",
71 "live",
72 ],
73 },
74 () => {
75 beforeAll(
76 Effect.gen(function* () {
77 yield* Effect.logInfo(
78 "AICapabilities test setup: destroying previous resources",
79 );
80 yield* sharedStack.destroy();
81
82 yield* Effect.logInfo("AICapabilities test setup: deploying fixture");
83 const { functionUrl } = yield* sharedStack.deploy(
84 Effect.gen(function* () {
85 return yield* AICapabilitiesTestFunction;
86 }).pipe(Effect.provide(AICapabilitiesTestFunctionLive)),
87 );
88
89 expect(functionUrl).toBeTruthy();
90 baseUrl = functionUrl!.replace(/\/+$/, "");
91 const readinessUrl = `${baseUrl}/ping`;
92
93 yield* Effect.logInfo(
94 `AICapabilities test setup: probing readiness at ${readinessUrl}`,
95 );
96 yield* HttpClient.get(readinessUrl).pipe(
97 Effect.flatMap((response) =>
98 response.status === 200
99 ? Effect.succeed(response)
100 : Effect.fail(
101 new Error(`Function not ready: ${response.status}`),
102 ),
103 ),
104 Effect.tapError((error) =>
105 Effect.logWarning(
106 `AICapabilities test setup: fixture not ready yet (${String(error)})`,
107 ),
108 ),
109 Effect.retry({ schedule: readinessPolicy }),
110 );
111 }),
112 { timeout: 240_000 },
113 );
114
115 afterAll(sharedStack.destroy(), { timeout: 120_000 });
116
117 describe("Rekognition.DetectLabels", () => {
118 test.provider(
119 "detects labels in checked-in image bytes",
120 (_stack) =>
121 Effect.gen(function* () {
122 const response = (yield* send(
123 HttpClientRequest.get(`${baseUrl}/detect-labels`),
124 ).pipe(Effect.flatMap((r) => r.json))) as {
125 labels: string[];
126 labelModelVersion?: string;
127 };
128
129 expect(Array.isArray(response.labels)).toBe(true);
130 expect(response.labels.length).toBeGreaterThan(0);
131 // The fixture image is rendered text — Rekognition consistently
132 // labels it as textual content.
133 expect(response.labels).toContain("Text");
134 }),
135 { timeout: 120_000 },
136 );
137 });
138
139 describe("Textract.DetectDocumentText", () => {
140 test.provider(
141 "detects the rendered text in checked-in document bytes",
142 (_stack) =>
143 Effect.gen(function* () {
144 const response = (yield* send(
145 HttpClientRequest.get(`${baseUrl}/detect-document-text`),
146 ).pipe(Effect.flatMap((r) => r.json))) as {
147 lines: string[];
148 pages?: number;
149 };
150
151 expect(response.pages).toBe(1);
152 expect(Array.isArray(response.lines)).toBe(true);
153 expect(response.lines).toContain(HELLO_PNG_TEXT);
154 }),
155 { timeout: 120_000 },
156 );
157 });
158
159 describe("Polly.SynthesizeSpeech", () => {
160 test.provider(
161 "synthesizes text to non-empty mp3 audio bytes",
162 (_stack) =>
163 Effect.gen(function* () {
164 const response = (yield* send(
165 HttpClientRequest.get(`${baseUrl}/synthesize-speech`),
166 ).pipe(Effect.flatMap((r) => r.json))) as {
167 contentType?: string;
168 byteLength: number;
169 };
170
171 expect(response.contentType).toContain("audio/mpeg");
172 expect(response.byteLength).toBeGreaterThan(1000);
173 }),
174 { timeout: 120_000 },
175 );
176 });
177
178 describe("Translate.TranslateText", () => {
179 test.provider(
180 "translates English to Spanish",
181 (_stack) =>
182 Effect.gen(function* () {
183 const response = (yield* send(
184 HttpClientRequest.get(`${baseUrl}/translate-text`),
185 ).pipe(Effect.flatMap((r) => r.json))) as {
186 translatedText: string;
187 sourceLanguageCode: string;
188 targetLanguageCode: string;
189 };
190
191 expect(response.sourceLanguageCode).toBe("en");
192 expect(response.targetLanguageCode).toBe("es");
193 expect(response.translatedText.trim().length).toBeGreaterThan(0);
194 expect(response.translatedText).not.toBe(TRANSLATE_TEXT);
195 }),
196 { timeout: 120_000 },
197 );
198 });
199
200 describe("Comprehend.DetectSentiment", () => {
201 test.provider(
202 "detects positive sentiment",
203 (_stack) =>
204 Effect.gen(function* () {
205 const response = (yield* send(
206 HttpClientRequest.get(`${baseUrl}/detect-sentiment`),
207 ).pipe(Effect.flatMap((r) => r.json))) as {
208 sentiment?: string;
209 sentimentScore?: { Positive?: number };
210 };
211
212 expect(["POSITIVE", "NEGATIVE", "NEUTRAL", "MIXED"]).toContain(
213 response.sentiment,
214 );
215 // SENTIMENT_TEXT is unambiguously positive.
216 expect(response.sentiment).toBe("POSITIVE");
217 expect(response.sentimentScore?.Positive ?? 0).toBeGreaterThan(0.5);
218 }),
219 { timeout: 120_000 },
220 );
221 });
222 },
223);