1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as HttpClient from "effect/http/HttpClient";
5import { HttpServerRequest } from "effect/http/HttpServerRequest";
6import * as HttpServerResponse from "effect/http/HttpServerResponse";
7import { spawnSync } from "node:child_process";
8
9/**
10 * Harness for GCP binding tests, the counterpart of the AWS Lambda binding
11 * fixtures (e.g. test/AWS/DynamoDB/handler.ts): each service's bindings run
12 * inside a deployed Effect-native Cloud Run service, as that service's own
13 * runtime service account, so the IAM grant every binding registers is what
14 * authorizes the call.
15 *
16 * Fixture side (`fixtures/bindings-host.ts`): build a `routes` record of
17 * probe Effects and serve it with {@link serveProbes}. Test side: call
18 * {@link callProbe} with the deployed URL and a route name.
19 */
20
21/** Whether Docker is available to build the fixture image. */
22export const dockerAvailable = (() => {
23 try {
24 return (
25 spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 })
26 .status === 0
27 );
28 } catch {
29 return false;
30 }
31})();
32
33/** What a probe route answers: the value, or the typed failure it hit. */
34export type ProbeOutcome<A> =
35 | { readonly ok: true; readonly value: A }
36 | {
37 readonly ok: false;
38 readonly error: { readonly _tag: string; readonly message?: string };
39 };
40
41export type Probe = Effect.Effect<unknown, { readonly _tag: string }, any>;
42
43const toJsonSafe = (value: unknown): unknown =>
44 JSON.parse(
45 JSON.stringify(value, (_key, item) =>
46 item instanceof Uint8Array
47 ? { $bytes: Buffer.from(item).toString("base64") }
48 : typeof item === "bigint"
49 ? item.toString()
50 : item,
51 ) ?? "null",
52 );
53
54/**
55 * Fixture-side `fetch`: `GET /probe/<name>` runs `routes[name]` and answers
56 * its {@link ProbeOutcome} as JSON. `GET /` answers 200 for readiness.
57 */
58export const serveProbes = (routes: Record<string, Probe>) =>
59 Effect.gen(function* () {
60 const request = yield* HttpServerRequest;
61 const pathname = new URL(request.url, "http://host").pathname;
62 const name = /^\/probe\/([^/]+)$/.exec(pathname)?.[1];
63 if (name === undefined) return HttpServerResponse.text("ok");
64 const probe = routes[decodeURIComponent(name)];
65 if (probe === undefined) {
66 return HttpServerResponse.text(`no probe ${name}`, { status: 404 });
67 }
68 const outcome: ProbeOutcome<unknown> = yield* probe.pipe(
69 Effect.map((value) => ({ ok: true as const, value: toJsonSafe(value) })),
70 Effect.catch((error) =>
71 Effect.succeed({
72 ok: false as const,
73 error: {
74 _tag: error._tag,
75 message: (error as { readonly message?: string }).message,
76 },
77 }),
78 ),
79 );
80 return yield* HttpServerResponse.json(outcome);
81 });
82
83class ProbeNotReady extends Data.TaggedError("ProbeNotReady")<{
84 readonly reason: string;
85}> {}
86
87/**
88 * Call a probe route on the deployed fixture. Retries (bounded, ~7 min) while
89 * the service is still starting (non-200) or the binding's fresh IAM grant is
90 * still propagating (the probe hit `Forbidden`/`PermissionDenied`), then
91 * returns the outcome.
92 */
93export const callProbe = <A = unknown>(baseUrl: string, name: string) =>
94 HttpClient.get(`${baseUrl.replace(/\/+$/, "")}/probe/${name}`).pipe(
95 Effect.flatMap((response) =>
96 response.status === 200
97 ? (response.json as Effect.Effect<ProbeOutcome<A>, never>)
98 : Effect.fail(new ProbeNotReady({ reason: `HTTP ${response.status}` })),
99 ),
100 Effect.flatMap((outcome) =>
101 !outcome.ok &&
102 (outcome.error._tag === "Forbidden" ||
103 outcome.error._tag === "PermissionDenied")
104 ? Effect.fail(
105 new ProbeNotReady({ reason: `IAM: ${outcome.error._tag}` }),
106 )
107 : Effect.succeed(outcome),
108 ),
109 Effect.mapError((error) =>
110 error._tag === "ProbeNotReady"
111 ? error
112 : new ProbeNotReady({ reason: String(error) }),
113 ),
114 Effect.retry({
115 while: (error) => error._tag === "ProbeNotReady",
116 schedule: Schedule.spaced("10 seconds"),
117 times: 42,
118 }),
119 );
120
121/** {@link callProbe}, failing the test unless the probe succeeded. */
122export const expectProbe = <A = unknown>(baseUrl: string, name: string) =>
123 callProbe<A>(baseUrl, name).pipe(
124 Effect.flatMap((outcome) =>
125 outcome.ok
126 ? Effect.succeed(outcome.value)
127 : Effect.die(
128 new Error(
129 `probe ${name} failed: ${outcome.error._tag} ${outcome.error.message ?? ""}`,
130 ),
131 ),
132 ),
133 );