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 PollyTestFunctionLive, {
11 BUCKET,
12 LEXICON_NAME,
13 PollyTestFunction,
14} from "./handler.ts";
15
16const testOptions = { providers: AWS.providers() };
17const { test, beforeAll, afterAll } = Test.make(testOptions);
18const sharedStack = Core.scratchStack(testOptions, "PollyBindings");
19
20// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
21// over 60s on a fresh deploy.
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 load
35// (cold re-init, IAM propagation). Retry only 5xx; a genuine 4xx/assertion
36// failure surfaces immediately.
37const send = (request: HttpClientRequest.HttpClientRequest) =>
38 HttpClient.execute(request).pipe(
39 Effect.flatMap((response) =>
40 response.status >= 500
41 ? response.text.pipe(
42 Effect.flatMap((body) =>
43 Effect.fail(
44 new TransientUpstream({ status: response.status, body }),
45 ),
46 ),
47 )
48 : Effect.succeed(response),
49 ),
50 Effect.retry({
51 while: (e) => e._tag === "TransientUpstream",
52 schedule: Schedule.max([
53 Schedule.exponential("2 seconds"),
54 Schedule.recurs(5),
55 ]),
56 }),
57 );
58
59describe.sequential(
60 "Polly Bindings",
61 {
62 tags: [
63 "provider:aws",
64 "provider:aws:lambda",
65 "provider:aws:polly",
66 "provider:aws:s3",
67 "live",
68 ],
69 },
70 () => {
71 beforeAll(
72 Effect.gen(function* () {
73 yield* Effect.logInfo(
74 "Polly test setup: destroying previous resources",
75 );
76 yield* sharedStack.destroy();
77
78 yield* Effect.logInfo("Polly test setup: deploying fixture");
79 const { functionUrl } = yield* sharedStack.deploy(
80 Effect.gen(function* () {
81 return yield* PollyTestFunction;
82 }).pipe(Effect.provide(PollyTestFunctionLive)),
83 );
84
85 expect(functionUrl).toBeTruthy();
86 baseUrl = functionUrl!.replace(/\/+$/, "");
87
88 const readinessUrl = `${baseUrl}/ping`;
89 yield* Effect.logInfo(
90 `Polly test setup: probing readiness at ${readinessUrl}`,
91 );
92 yield* HttpClient.get(readinessUrl).pipe(
93 Effect.flatMap((response) =>
94 response.status === 200
95 ? Effect.succeed(response)
96 : Effect.fail(
97 new Error(`Function not ready: ${response.status}`),
98 ),
99 ),
100 Effect.tapError((error) =>
101 Effect.logWarning(
102 `Polly test setup: fixture not ready yet (${String(error)})`,
103 ),
104 ),
105 Effect.retry({ schedule: readinessPolicy }),
106 );
107 }),
108 { timeout: 300_000 },
109 );
110
111 afterAll.skipIf(!!process.env.NO_DESTROY)(sharedStack.destroy(), {
112 timeout: 180_000,
113 });
114
115 describe("DescribeVoices", () => {
116 test.provider(
117 "lists en-US voices including Joanna",
118 (_stack) =>
119 Effect.gen(function* () {
120 const response = (yield* send(
121 HttpClientRequest.get(`${baseUrl}/voices`),
122 ).pipe(Effect.flatMap((r) => r.json))) as {
123 error?: string;
124 count: number;
125 voiceIds: string[];
126 };
127
128 expect(response.error).toBeUndefined();
129 expect(response.count).toBeGreaterThan(0);
130 expect(response.voiceIds).toContain("Joanna");
131 }),
132 { timeout: 120_000 },
133 );
134 });
135
136 describe("ListLexicons", () => {
137 test.provider(
138 "lists the fixture's deployed lexicon",
139 (_stack) =>
140 Effect.gen(function* () {
141 const response = (yield* send(
142 HttpClientRequest.get(`${baseUrl}/lexicons`),
143 ).pipe(Effect.flatMap((r) => r.json))) as {
144 error?: string;
145 names: string[];
146 };
147
148 expect(response.error).toBeUndefined();
149 expect(response.names).toContain(LEXICON_NAME);
150 }),
151 { timeout: 120_000 },
152 );
153 });
154
155 describe("GetLexicon", () => {
156 test.provider(
157 "reads the lexicon's PLS content scoped to its ARN",
158 (_stack) =>
159 Effect.gen(function* () {
160 const response = (yield* send(
161 HttpClientRequest.get(`${baseUrl}/lexicon-content`),
162 ).pipe(Effect.flatMap((r) => r.json))) as {
163 error?: string;
164 name: string | null;
165 containsAlias: boolean;
166 lexemesCount: number | null;
167 };
168
169 expect(response.error).toBeUndefined();
170 expect(response.name).toBe(LEXICON_NAME);
171 expect(response.containsAlias).toBe(true);
172 expect(response.lexemesCount).toBe(1);
173 }),
174 { timeout: 120_000 },
175 );
176 });
177
178 describe("StartSpeechSynthesisStream", () => {
179 // skipIf-gated: the distilled HTTP transport cannot send an input event
180 // stream (bidirectional streaming). A direct probe of
181 // `polly.startSpeechSynthesisStream` with a finite
182 // `Stream.make({ TextEvent }, { CloseStreamEvent })` ActionStream never
183 // receives response headers — it hangs until `Effect.timeout` fires
184 // (`TimeoutError` after 20s locally; the Lambda route hangs to the
185 // test timeout). Same transport gap leaves transcribe-streaming and
186 // lex StartConversation unbound fleet-wide. Re-enable with
187 // AWS_TEST_POLLY_STREAM=1 once distilled core supports bidirectional
188 // event-stream request bodies.
189 test.provider.skipIf(!process.env.AWS_TEST_POLLY_STREAM)(
190 "streams text events in and collects audio events out",
191 (_stack) =>
192 Effect.gen(function* () {
193 const response = (yield* send(
194 HttpClientRequest.post(`${baseUrl}/stream`),
195 ).pipe(Effect.flatMap((r) => r.json))) as {
196 error?: string;
197 events: number;
198 audioBytes: number;
199 closed: boolean;
200 };
201
202 expect(response.error).toBeUndefined();
203 expect(response.events).toBeGreaterThan(0);
204 expect(response.audioBytes).toBeGreaterThan(1000);
205 expect(response.closed).toBe(true);
206 }),
207 { timeout: 120_000 },
208 );
209 });
210
211 describe("SynthesizeSpeech", () => {
212 test.provider(
213 "synthesizes text with the lexicon applied to non-empty mp3 bytes",
214 (_stack) =>
215 Effect.gen(function* () {
216 const response = (yield* send(
217 HttpClientRequest.get(`${baseUrl}/synthesize`),
218 ).pipe(Effect.flatMap((r) => r.json))) as {
219 error?: string;
220 contentType?: string;
221 byteLength: number;
222 };
223
224 expect(response.error).toBeUndefined();
225 expect(response.contentType).toContain("audio/mpeg");
226 expect(response.byteLength).toBeGreaterThan(1000);
227 }),
228 { timeout: 120_000 },
229 );
230 });
231
232 describe("StartSpeechSynthesisTask + GetSpeechSynthesisTask + ListSpeechSynthesisTasks", () => {
233 test.provider(
234 "runs an async synthesis task to completion in S3",
235 (_stack) =>
236 Effect.gen(function* () {
237 const response = (yield* send(
238 HttpClientRequest.post(`${baseUrl}/task`),
239 ).pipe(Effect.flatMap((r) => r.json))) as {
240 error?: string;
241 taskId: string;
242 status: string | null;
243 statusReason: string | null;
244 outputUri: string | null;
245 listedCount: number;
246 };
247
248 expect(response.error).toBeUndefined();
249 expect(response.taskId).toBeTruthy();
250 expect(response.status).toBe("completed");
251 expect(response.outputUri).toContain(BUCKET);
252 expect(response.listedCount).toBeGreaterThan(0);
253 }),
254 { timeout: 120_000 },
255 );
256 });
257 },
258);