| 1 | import * as Amplify from "@/AWS/Amplify"; |
| 2 | import * as Lambda from "@/AWS/Lambda"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 8 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 9 | import path from "pathe"; |
| 10 | |
| 11 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 12 | |
| 13 | export class AmplifyTestFunction extends Lambda.Function<Lambda.Function>()( |
| 14 | "AmplifyTestFunction", |
| 15 | ) {} |
| 16 | |
| 17 | export default AmplifyTestFunction.make( |
| 18 | { |
| 19 | main, |
| 20 | functionUrl: true, |
| 21 | // GenerateAccessLogs can take longer than Lambda's 3s default while |
| 22 | // Amplify prepares the pre-signed archive URL. Keep the invocation alive |
| 23 | // long enough for the handler to return either the typed service result or |
| 24 | // its structured diagnostic response instead of a raw Function URL 502. |
| 25 | timeout: Duration.seconds(30), |
| 26 | }, |
| 27 | Effect.gen(function* () { |
| 28 | const app = yield* Amplify.App("BindingsTestApp", { |
| 29 | description: "Amplify bindings fixture (manual deploys, no repo)", |
| 30 | platform: "WEB", |
| 31 | }); |
| 32 | |
| 33 | const AppId = yield* app.appId; |
| 34 | const DefaultDomain = yield* app.defaultDomain; |
| 35 | |
| 36 | const createDeployment = yield* Amplify.CreateDeployment(app); |
| 37 | const startDeployment = yield* Amplify.StartDeployment(app); |
| 38 | const startJob = yield* Amplify.StartJob(app); |
| 39 | const stopJob = yield* Amplify.StopJob(app); |
| 40 | const getJob = yield* Amplify.GetJob(app); |
| 41 | const listJobs = yield* Amplify.ListJobs(app); |
| 42 | const deleteJob = yield* Amplify.DeleteJob(app); |
| 43 | const listArtifacts = yield* Amplify.ListArtifacts(app); |
| 44 | const getArtifactUrl = yield* Amplify.GetArtifactUrl(app); |
| 45 | const generateAccessLogs = yield* Amplify.GenerateAccessLogs(app); |
| 46 | |
| 47 | // Event source: creates the EventBridge rule + Lambda permission at |
| 48 | // deploy; at runtime, delivered events are recorded in instance memory |
| 49 | // (best-effort — a different instance may serve /events) and exposed on |
| 50 | // the /events route. |
| 51 | const deploymentEvents: Amplify.DeploymentStatusChangeDetail[] = []; |
| 52 | yield* Amplify.consumeDeploymentStatusChanges( |
| 53 | { id: "BindingsTestApp" }, |
| 54 | (events) => |
| 55 | Stream.runForEach(events, (event) => |
| 56 | Effect.sync(() => { |
| 57 | deploymentEvents.push(event.detail); |
| 58 | }), |
| 59 | ), |
| 60 | ); |
| 61 | |
| 62 | return { |
| 63 | fetch: Effect.gen(function* () { |
| 64 | const request = yield* HttpServerRequest; |
| 65 | const url = new URL(request.originalUrl); |
| 66 | const pathname = url.pathname; |
| 67 | |
| 68 | if (request.method === "GET" && pathname === "/meta") { |
| 69 | return yield* HttpServerResponse.json({ |
| 70 | appId: yield* AppId, |
| 71 | defaultDomain: yield* DefaultDomain, |
| 72 | }); |
| 73 | } |
| 74 | |
| 75 | if (request.method === "POST" && pathname === "/deployments") { |
| 76 | const body = (yield* request.json) as { branchName: string }; |
| 77 | const result = yield* createDeployment({ |
| 78 | branchName: body.branchName, |
| 79 | }); |
| 80 | return yield* HttpServerResponse.json({ |
| 81 | jobId: result.jobId, |
| 82 | zipUploadUrl: result.zipUploadUrl, |
| 83 | }); |
| 84 | } |
| 85 | |
| 86 | if (request.method === "POST" && pathname === "/deployments/start") { |
| 87 | const body = (yield* request.json) as { |
| 88 | branchName: string; |
| 89 | jobId?: string; |
| 90 | sourceUrl?: string; |
| 91 | }; |
| 92 | const result = yield* startDeployment({ |
| 93 | branchName: body.branchName, |
| 94 | jobId: body.jobId, |
| 95 | sourceUrl: body.sourceUrl, |
| 96 | }); |
| 97 | return yield* HttpServerResponse.json({ |
| 98 | jobSummary: result.jobSummary, |
| 99 | }); |
| 100 | } |
| 101 | |
| 102 | if (request.method === "GET" && pathname === "/job") { |
| 103 | const branchName = url.searchParams.get("branchName")!; |
| 104 | const jobId = url.searchParams.get("jobId")!; |
| 105 | const result = yield* getJob({ branchName, jobId }); |
| 106 | return yield* HttpServerResponse.json({ |
| 107 | status: result.job.summary.status, |
| 108 | stepCount: result.job.steps.length, |
| 109 | }); |
| 110 | } |
| 111 | |
| 112 | if (request.method === "GET" && pathname === "/jobs") { |
| 113 | const branchName = url.searchParams.get("branchName")!; |
| 114 | const result = yield* listJobs({ branchName }); |
| 115 | return yield* HttpServerResponse.json({ |
| 116 | jobSummaries: result.jobSummaries, |
| 117 | }); |
| 118 | } |
| 119 | |
| 120 | if (request.method === "POST" && pathname === "/jobs/start") { |
| 121 | const body = (yield* request.json) as { |
| 122 | branchName: string; |
| 123 | jobType: string; |
| 124 | }; |
| 125 | // A manual-deploy branch has no connected repo, so RELEASE jobs are |
| 126 | // rejected by the service — surface the typed tag instead of dying |
| 127 | // so the test can assert the binding + IAM wiring end-to-end. |
| 128 | const result = yield* startJob({ |
| 129 | branchName: body.branchName, |
| 130 | jobType: body.jobType as "RELEASE", |
| 131 | }).pipe( |
| 132 | Effect.map((r) => ({ |
| 133 | started: true as const, |
| 134 | jobId: r.jobSummary.jobId, |
| 135 | })), |
| 136 | Effect.catchTag(["BadRequestException", "NotFoundException"], (e) => |
| 137 | Effect.succeed({ started: false as const, errorTag: e._tag }), |
| 138 | ), |
| 139 | ); |
| 140 | return yield* HttpServerResponse.json(result); |
| 141 | } |
| 142 | |
| 143 | if (request.method === "POST" && pathname === "/jobs/stop") { |
| 144 | const body = (yield* request.json) as { |
| 145 | branchName: string; |
| 146 | jobId: string; |
| 147 | }; |
| 148 | // Stopping a job that already settled is a BadRequest/NotFound — |
| 149 | // report the tag rather than 500 so the race stays assertable. |
| 150 | const result = yield* stopJob({ |
| 151 | branchName: body.branchName, |
| 152 | jobId: body.jobId, |
| 153 | }).pipe( |
| 154 | Effect.map((r) => ({ |
| 155 | stopped: true as const, |
| 156 | status: r.jobSummary.status, |
| 157 | })), |
| 158 | Effect.catchTag( |
| 159 | [ |
| 160 | "BadRequestException", |
| 161 | "NotFoundException", |
| 162 | "LimitExceededException", |
| 163 | ], |
| 164 | (e) => |
| 165 | Effect.succeed({ stopped: false as const, errorTag: e._tag }), |
| 166 | ), |
| 167 | ); |
| 168 | return yield* HttpServerResponse.json(result); |
| 169 | } |
| 170 | |
| 171 | if (request.method === "POST" && pathname === "/jobs/delete") { |
| 172 | const body = (yield* request.json) as { |
| 173 | branchName: string; |
| 174 | jobId: string; |
| 175 | }; |
| 176 | const result = yield* deleteJob({ |
| 177 | branchName: body.branchName, |
| 178 | jobId: body.jobId, |
| 179 | }).pipe( |
| 180 | Effect.map((r) => ({ |
| 181 | deleted: true as const, |
| 182 | status: r.jobSummary.status, |
| 183 | })), |
| 184 | Effect.catchTag(["BadRequestException", "NotFoundException"], (e) => |
| 185 | Effect.succeed({ |
| 186 | deleted: false as const, |
| 187 | errorTag: e._tag, |
| 188 | message: e.message, |
| 189 | }), |
| 190 | ), |
| 191 | ); |
| 192 | return yield* HttpServerResponse.json(result); |
| 193 | } |
| 194 | |
| 195 | if (request.method === "GET" && pathname === "/artifacts") { |
| 196 | const branchName = url.searchParams.get("branchName")!; |
| 197 | const jobId = url.searchParams.get("jobId")!; |
| 198 | const result = yield* listArtifacts({ branchName, jobId }); |
| 199 | return yield* HttpServerResponse.json({ |
| 200 | artifacts: result.artifacts, |
| 201 | }); |
| 202 | } |
| 203 | |
| 204 | if (request.method === "GET" && pathname === "/artifact-url") { |
| 205 | const artifactId = url.searchParams.get("artifactId")!; |
| 206 | const result = yield* getArtifactUrl({ artifactId }); |
| 207 | return yield* HttpServerResponse.json({ |
| 208 | artifactUrl: result.artifactUrl, |
| 209 | }); |
| 210 | } |
| 211 | |
| 212 | if (request.method === "POST" && pathname === "/access-logs") { |
| 213 | const body = (yield* request.json) as { domainName: string }; |
| 214 | const result = yield* generateAccessLogs({ |
| 215 | domainName: body.domainName, |
| 216 | }).pipe( |
| 217 | Effect.map((r) => ({ ok: true as const, logUrl: r.logUrl })), |
| 218 | Effect.catchTag(["BadRequestException", "NotFoundException"], (e) => |
| 219 | Effect.succeed({ |
| 220 | ok: false as const, |
| 221 | errorTag: e._tag, |
| 222 | message: e.message, |
| 223 | }), |
| 224 | ), |
| 225 | Effect.catchCause((cause) => |
| 226 | Effect.succeed({ |
| 227 | ok: false as const, |
| 228 | errorTag: "UnexpectedCause", |
| 229 | cause: String(cause), |
| 230 | }), |
| 231 | ), |
| 232 | ); |
| 233 | return yield* HttpServerResponse.json(result); |
| 234 | } |
| 235 | |
| 236 | if (request.method === "GET" && pathname === "/events") { |
| 237 | return yield* HttpServerResponse.json({ events: deploymentEvents }); |
| 238 | } |
| 239 | |
| 240 | if (request.method === "GET" && pathname === "/ping") { |
| 241 | return yield* HttpServerResponse.json({ ok: true }); |
| 242 | } |
| 243 | |
| 244 | return yield* HttpServerResponse.json( |
| 245 | { error: "Not found", method: request.method, pathname }, |
| 246 | { status: 404 }, |
| 247 | ); |
| 248 | }).pipe( |
| 249 | // Surface unexpected failures in the response body — the fixture |
| 250 | // Lambda has no CloudWatch logs permission, so the HTTP body is the |
| 251 | // only diagnostic channel the test can observe. |
| 252 | Effect.catchCause((cause) => |
| 253 | HttpServerResponse.json( |
| 254 | { error: "unhandled", cause: String(cause) }, |
| 255 | { status: 500 }, |
| 256 | ), |
| 257 | ), |
| 258 | ), |
| 259 | }; |
| 260 | }).pipe( |
| 261 | Effect.provide( |
| 262 | Layer.mergeAll( |
| 263 | Lambda.EventSource, |
| 264 | Amplify.CreateDeploymentHttp, |
| 265 | Amplify.StartDeploymentHttp, |
| 266 | Amplify.StartJobHttp, |
| 267 | Amplify.StopJobHttp, |
| 268 | Amplify.GetJobHttp, |
| 269 | Amplify.ListJobsHttp, |
| 270 | Amplify.DeleteJobHttp, |
| 271 | Amplify.ListArtifactsHttp, |
| 272 | Amplify.GetArtifactUrlHttp, |
| 273 | Amplify.GenerateAccessLogsHttp, |
| 274 | ), |
| 275 | ), |
| 276 | ), |
| 277 | ); |