1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http";
8
9import { makeAntigravityUsageProbe } from "./antigravityUsageLimits.ts";
10
11const summary = {
12 groups: [
13 {
14 displayName: "Gemini Models",
15 buckets: [
16 {
17 bucketId: "gemini-weekly",
18 window: "weekly",
19 remainingFraction: 0.96,
20 resetTime: "2026-10-12T19:41:43Z",
21 },
22 {
23 bucketId: "gemini-5h",
24 window: "5h",
25 remainingFraction: 0.91,
26 resetTime: "2026-10-09T16:06:01Z",
27 },
28 ],
29 },
30 {
31 displayName: "Claude and GPT models",
32 buckets: [
33 {
34 bucketId: "3p-weekly",
35 window: "weekly",
36 remainingFraction: 0,
37 resetTime: "2026-10-10T15:28:22Z",
38 },
39 {
40 bucketId: "3p-5h",
41 window: "5h",
42 remainingFraction: 0.44,
43 resetTime: "2026-10-09T16:06:30Z",
44 },
45 ],
46 },
47 ],
48};
49
50const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (
51 options: {
52 summary?: unknown;
53 models?: unknown;
54 summaryStatus?: number;
55 tokenStatus?: number;
56 refreshToken?: string;
57 /** Google account id from userinfo; omit to make userinfo fail. */
58 userId?: string;
59 missingToken?: boolean;
60 authMethod?: "oauth-personal" | "gemini-api-key";
61 } = {},
62) {
63 const fs = yield* FileSystem.FileSystem;
64 const path = yield* Path.Path;
65 const profileDirectory = yield* fs.makeTempDirectoryScoped();
66 yield* fs.makeDirectory(path.join(profileDirectory, "antigravity-acp"));
67 const tokenPath = path.join(profileDirectory, "antigravity-acp", "acp_token.json");
68 const writeToken = (refreshToken: string) =>
69 fs.writeFileString(
70 tokenPath,
71 JSON.stringify({
72 client_id: "client-id",
73 client_secret: "client-secret",
74 refresh_token: refreshToken,
75 project_id: "project-id",
76 }),
77 );
78 if (!options.missingToken) yield* writeToken(options.refreshToken ?? "refresh-a");
79 const requests: Array<{ url: string; body: string }> = [];
80 const http = HttpClient.make((request, _url, _signal, fiber) =>
81 Effect.sync(() => {
82 const init = Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit);
83 expect(init?.redirect).toBe("manual");
84 const body =
85 request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
86 requests.push({ url: request.url, body });
87 if (request.url.endsWith("/token")) {
88 expect(new URLSearchParams(body).get("grant_type")).toBe("refresh_token");
89 return HttpClientResponse.fromWeb(
90 request,
91 Response.json(
92 { access_token: "access-token", expires_in: 3600 },
93 { status: options.tokenStatus ?? 200 },
94 ),
95 );
96 }
97 expect(request.headers.authorization).toBe("Bearer access-token");
98 if (request.url.endsWith("/userinfo")) {
99 return HttpClientResponse.fromWeb(
100 request,
101 options.userId
102 ? Response.json({ id: options.userId, email: "someone@example.com" })
103 : Response.json({}, { status: 403 }),
104 );
105 }
106 expect(JSON.parse(body)).toEqual({ project: "project-id" });
107 return HttpClientResponse.fromWeb(
108 request,
109 request.url.endsWith(":retrieveUserQuotaSummary")
110 ? Response.json(options.summary ?? summary, { status: options.summaryStatus ?? 200 })
111 : Response.json(options.models ?? { models: {} }),
112 );
113 }),
114 );
115 const usage = yield* makeAntigravityUsageProbe({
116 profileDirectory,
117 authMethod: options.authMethod ?? "oauth-personal",
118 }).pipe(Effect.provideService(HttpClient.HttpClient, http));
119 return { ...usage, requests, writeToken, tokenPath };
120});
121
122describe("Antigravity subscription limits", () => {
123 it.effect("reports separate Gemini and shared Claude/GPT five-hour and weekly limits", () =>
124 Effect.gen(function* () {
125 const { probe, requests } = yield* fixture();
126 const limits = yield* probe;
127 expect(limits.windows).toHaveLength(4);
128 expect(limits.unavailable).toBeUndefined();
129 expect(limits.credentialFingerprint).toMatch(/^[0-9a-f]{64}$/);
130 const byId = new Map(limits.windows.map((window) => [window.id, window]));
131 expect(byId.get("gemini-weekly")?.usedPercent).toBeCloseTo(4);
132 expect(byId.get("gemini-5h")?.usedPercent).toBeCloseTo(9);
133 expect(byId.get("3p-weekly")).toMatchObject({
134 label: "Claude & GPT (shared) · Weekly",
135 kind: "weekly",
136 usedPercent: 100,
137 windowDurationMins: 10080,
138 resetsAt: "2026-10-10T15:28:22.000Z",
139 });
140 expect(byId.get("3p-5h")).toMatchObject({ kind: "session", windowDurationMins: 300 });
141 expect(byId.get("3p-5h")?.usedPercent).toBeCloseTo(56);
142 yield* probe;
143 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1);
144 expect(
145 requests.filter((request) => request.url.endsWith(":retrieveUserQuotaSummary")),
146 ).toHaveLength(2);
147 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
148 );
149
150 it.effect("re-reads changed credentials and clears cached access after sign-out", () =>
151 Effect.gen(function* () {
152 const { probe, clear, requests, writeToken, tokenPath } = yield* fixture();
153 const first = yield* probe;
154 yield* writeToken("refresh-b");
155 const second = yield* probe;
156 // Without a readable Google account, the credential itself names the account.
157 expect(second.credentialFingerprint).not.toBe(first.credentialFingerprint);
158 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(2);
159 yield* (yield* FileSystem.FileSystem).remove(tokenPath);
160 expect((yield* probe).unavailable?.reason).toBe("unsupported");
161 yield* writeToken("refresh-b");
162 yield* probe;
163 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(3);
164 yield* clear;
165 yield* probe;
166 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(4);
167 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
168 );
169
170 it.effect("names one Google account the same way from every sign-in", () =>
171 Effect.gen(function* () {
172 const read = (refreshToken: string, userId: string) =>
173 fixture({ refreshToken, userId }).pipe(Effect.flatMap((usage) => usage.probe));
174 const machineA = yield* read("refresh-a", "google-user-1");
175 const machineB = yield* read("refresh-b", "google-user-1");
176 const otherAccount = yield* read("refresh-c", "google-user-2");
177 expect(machineA.credentialFingerprint).toMatch(/^[0-9a-f]{64}$/);
178 expect(machineB.credentialFingerprint).toBe(machineA.credentialFingerprint);
179 expect(otherAccount.credentialFingerprint).not.toBe(machineA.credentialFingerprint);
180 expect(JSON.stringify(machineA)).not.toMatch(/google-user-1|someone@example.com/);
181 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
182 );
183
184 it.effect("falls back to model quotas without inventing a window duration", () =>
185 Effect.gen(function* () {
186 const { probe } = yield* fixture({
187 summaryStatus: 404,
188 models: {
189 models: {
190 "gemini-a": {
191 modelProvider: "MODEL_PROVIDER_GOOGLE",
192 quotaInfo: { remainingFraction: 0.8, resetTime: "2026-10-10T12:00:00Z" },
193 },
194 "gemini-b": {
195 modelProvider: "MODEL_PROVIDER_GOOGLE",
196 quotaInfo: { remainingFraction: 0.6, resetTime: "2026-10-10T12:00:00Z" },
197 },
198 "claude-a": { quotaInfo: { remainingFraction: 0.2 } },
199 },
200 },
201 });
202 const limits = yield* probe;
203 expect(limits.windows).toHaveLength(2);
204 expect(limits.windows.find((window) => window.id === "gemini-quota")?.usedPercent).toBe(40);
205 expect(
206 limits.windows.every(
207 (window) => window.kind === "other" && window.windowDurationMins === undefined,
208 ),
209 ).toBe(true);
210 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
211 );
212
213 it.effect("skips disabled and missing fractions and clamps valid percentages", () =>
214 Effect.gen(function* () {
215 const { probe } = yield* fixture({
216 summary: {
217 buckets: [
218 { bucketId: "disabled", remainingFraction: 0, disabled: true },
219 { bucketId: "missing", resetTime: "2026-10-10T12:00:00Z" },
220 { bucketId: "gemini-5h", window: "5h", remainingFraction: 2, resetTime: "invalid" },
221 ],
222 },
223 });
224 expect((yield* probe).windows).toEqual([
225 {
226 id: "gemini-5h",
227 kind: "session",
228 label: "Gemini · 5-hour",
229 usedPercent: 0,
230 windowDurationMins: 300,
231 },
232 ]);
233 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
234 );
235
236 it.effect("preserves cached access token when quota endpoints return no usable windows", () =>
237 Effect.gen(function* () {
238 const { probe, requests } = yield* fixture({
239 summary: { groups: [] },
240 models: { models: {} },
241 });
242 const first = yield* probe;
243 expect(first.unavailable?.reason).toBe("probeFailed");
244 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1);
245 const second = yield* probe;
246 expect(second.unavailable?.reason).toBe("probeFailed");
247 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1);
248 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
249 );
250
251 it.effect("returns a safe failure when OAuth refresh fails", () =>
252 Effect.gen(function* () {
253 const { probe } = yield* fixture({ tokenStatus: 401 });
254 const limits = yield* probe;
255 expect(limits.windows).toEqual([]);
256 expect(limits.unavailable?.reason).toBe("probeFailed");
257 expect(JSON.stringify(limits)).not.toMatch(/client-secret|refresh-a|access-token/);
258 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
259 );
260
261 it.effect("does not make network requests for API keys or missing sign-ins", () =>
262 Effect.gen(function* () {
263 for (const options of [{ authMethod: "gemini-api-key" as const }, { missingToken: true }]) {
264 const { probe, requests } = yield* fixture(options);
265 expect((yield* probe).unavailable?.reason).toBe("unsupported");
266 expect(requests).toEqual([]);
267 }
268 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
269 );
270});