1import * as Alchemy from "alchemy";
2import * as GCP from "alchemy/GCP";
3import * as Test from "alchemy/Test/Bun";
4import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
5import * as cloudrun from "@distilled.cloud/gcp/run_v2";
6import { describe, expect } from "bun:test";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Schedule from "effect/Schedule";
10import * as FetchHttpClient from "effect/http/FetchHttpClient";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientRequest from "effect/http/HttpClientRequest";
13import { spawnSync } from "node:child_process";
14import Stack from "../alchemy.run.ts";
15
16const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
17 providers: GCP.providers(),
18 state: Alchemy.localState(),
19});
20
21const { getWhenReady } = Test;
22
23// Out-of-band calls to the Google APIs resolve the same stored credentials
24// the deploy uses, so the test runs against the configured profile.
25const GcpHttp = Layer.mergeAll(
26 GCP.GcpAuth,
27 GCP.fromAuthProvider(),
28 FetchHttpClient.layer,
29);
30
31// The project comes from the same credential the deploy uses.
32const currentProject = GCP.GcpEnvironment.current.pipe(
33 Effect.map((env) => env.project),
34 Effect.provide(GCP.fromCredentials().pipe(Layer.provide(GcpHttp))),
35);
36
37// The service is built from `main`, which needs a local image build.
38const dockerAvailable =
39 spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }).status ===
40 0;
41
42// Deploy, tests and destroy all sit behind the same Docker guard.
43describe.skipIf(!dockerAvailable)("gcp-vertex-ai", () => {
44 const stack = beforeAll(deploy(Stack), { timeout: 900_000 });
45
46 const serviceState = (name: string) =>
47 cloudrun.getProjectsLocationsServices({ name }).pipe(
48 Effect.as("found" as const),
49 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
50 Effect.orDie,
51 Effect.provide(GcpHttp),
52 );
53
54 /** Members holding `roles/aiplatform.user` on the project. */
55 const aiplatformUsers = currentProject.pipe(
56 Effect.flatMap((project) =>
57 resourcemanager.getIamPolicyProjects({
58 resource: `projects/${project}`,
59 body: { options: { requestedPolicyVersion: 3 } },
60 }),
61 ),
62 Effect.map((policy) =>
63 (policy.bindings ?? [])
64 .filter((binding) => binding.role === "roles/aiplatform.user")
65 .flatMap((binding) => binding.members ?? []),
66 ),
67 Effect.orDie,
68 Effect.provide(GcpHttp),
69 );
70
71 afterAll.skipIf(!!process.env.NO_DESTROY)(
72 Effect.gen(function* () {
73 const { serviceName, serviceAccount } = yield* stack;
74 yield* destroy(Stack);
75 // Nothing is left behind: the service is gone and its grant revoked.
76 expect(yield* serviceState(serviceName)).toEqual("gone");
77 expect(yield* aiplatformUsers).not.toContain(
78 `serviceAccount:${serviceAccount}`,
79 );
80 }),
81 { timeout: 600_000 },
82 );
83
84 const baseUrlOf = (url: string | undefined) => {
85 if (url === undefined) throw new Error("the service has no URL");
86 return url.replace(/\/+$/, "");
87 };
88
89 const chat = (baseUrl: string, body: Record<string, unknown>) =>
90 HttpClient.execute(
91 HttpClientRequest.post(`${baseUrl}/chat`).pipe(
92 HttpClientRequest.bodyJsonUnsafe(body),
93 ),
94 );
95
96 test(
97 "grants the runtime service account roles/aiplatform.user",
98 Effect.gen(function* () {
99 const { serviceAccount } = yield* stack;
100 expect(yield* aiplatformUsers).toContain(
101 `serviceAccount:${serviceAccount}`,
102 );
103 }),
104 { timeout: 60_000 },
105 );
106
107 test(
108 "POST /chat answers with Gemini",
109 Effect.gen(function* () {
110 const { url } = yield* stack;
111 const baseUrl = baseUrlOf(url);
112 yield* getWhenReady(`${baseUrl}/`);
113
114 // A fresh project-level grant can take a minute or two to reach
115 // Vertex AI; until then the service answers 500 (Forbidden).
116 const res = yield* chat(baseUrl, {
117 prompt: "Reply with exactly: pong",
118 temperature: 0,
119 }).pipe(
120 Effect.repeat({
121 schedule: Schedule.spaced("10 seconds"),
122 until: (response) => response.status !== 500,
123 times: 30,
124 }),
125 );
126 expect(res.status).toBe(200);
127 const body = (yield* res.json) as {
128 text: string;
129 modelVersion: string;
130 usage: { inputTokens: number };
131 };
132 expect(body.text.length).toBeGreaterThan(0);
133 expect(body.text.toLowerCase()).toContain("pong");
134 expect(body.modelVersion).toContain("gemini-2.5-flash");
135 expect(body.usage.inputTokens).toBeGreaterThan(0);
136 }),
137 { timeout: 420_000 },
138 );
139
140 test(
141 "POST /chat rejects a missing prompt",
142 Effect.gen(function* () {
143 const { url } = yield* stack;
144 const baseUrl = baseUrlOf(url);
145 yield* getWhenReady(`${baseUrl}/`);
146 const res = yield* chat(baseUrl, {});
147 expect(res.status).toBe(400);
148 }),
149 { timeout: 120_000 },
150 );
151});