| 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { NodeServices } from "@effect/platform-node"; |
| 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Path from "effect/Path"; |
| 7 | import * as HttpClient from "effect/http/HttpClient"; |
| 8 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 9 | import * as CursorKeychain from "./CursorKeychain.ts"; |
| 10 | import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts"; |
| 11 | |
| 12 | const withNodeServices = <A, E>( |
| 13 | effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | CursorKeychain.CursorKeychain>, |
| 14 | accessToken: CursorKeychain.CursorKeychain["Service"]["accessToken"] = Effect.die( |
| 15 | "must not read Keychain", |
| 16 | ), |
| 17 | ) => |
| 18 | effect.pipe( |
| 19 | Effect.provideService(CursorKeychain.CursorKeychain, { accessToken }), |
| 20 | Effect.provide(NodeServices.layer), |
| 21 | ); |
| 22 | |
| 23 | describe("Cursor usage limits", () => { |
| 24 | const checkedAt = "2026-09-16T00:00:00.000Z"; |
| 25 | |
| 26 | it("uses the advertised percentages and billing-cycle reset", () => { |
| 27 | const limits = cursorUsageResponseToLimits( |
| 28 | { |
| 29 | billingCycleEnd: "1789876386000", |
| 30 | planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 }, |
| 31 | }, |
| 32 | checkedAt, |
| 33 | ); |
| 34 | expect(limits.windows).toEqual( |
| 35 | expect.arrayContaining([ |
| 36 | { |
| 37 | id: "totalPercentUsed", |
| 38 | kind: "monthly", |
| 39 | label: "Overall", |
| 40 | usedPercent: 72.4, |
| 41 | resetsAt: "2026-09-20T03:53:06.000Z", |
| 42 | }, |
| 43 | { |
| 44 | id: "autoPercentUsed", |
| 45 | kind: "monthly", |
| 46 | label: "Cursor Models", |
| 47 | usedPercent: 69.5, |
| 48 | resetsAt: "2026-09-20T03:53:06.000Z", |
| 49 | }, |
| 50 | { |
| 51 | id: "apiPercentUsed", |
| 52 | kind: "monthly", |
| 53 | label: "Other Models", |
| 54 | usedPercent: 100, |
| 55 | resetsAt: "2026-09-20T03:53:06.000Z", |
| 56 | }, |
| 57 | ]), |
| 58 | ); |
| 59 | }); |
| 60 | |
| 61 | it("does not invent unused allowance for absent buckets", () => { |
| 62 | expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe( |
| 63 | "unsupported", |
| 64 | ); |
| 65 | expect( |
| 66 | cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, |
| 67 | ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]); |
| 68 | expect( |
| 69 | cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, |
| 70 | ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); |
| 71 | }); |
| 72 | |
| 73 | it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () => |
| 74 | Effect.gen(function* () { |
| 75 | yield* withNodeServices( |
| 76 | Effect.gen(function* () { |
| 77 | const fs = yield* FileSystem.FileSystem; |
| 78 | const path = yield* Path.Path; |
| 79 | const directory = yield* fs.makeTempDirectoryScoped(); |
| 80 | yield* fs.makeDirectory(path.join(directory, "cursor")); |
| 81 | yield* fs.writeFileString( |
| 82 | path.join(directory, "cursor", "auth.json"), |
| 83 | '{"accessToken":"instance-token"}', |
| 84 | ); |
| 85 | const client = HttpClient.make((request) => { |
| 86 | expect(request.url).toBe( |
| 87 | "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage", |
| 88 | ); |
| 89 | expect(request.method).toBe("POST"); |
| 90 | expect(request.headers.authorization).toBe("Bearer instance-token"); |
| 91 | expect(request.headers["connect-protocol-version"]).toBe("1"); |
| 92 | return Effect.succeed( |
| 93 | HttpClientResponse.fromWeb( |
| 94 | request, |
| 95 | Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }), |
| 96 | ), |
| 97 | ); |
| 98 | }); |
| 99 | yield* fs.makeDirectory(path.join(directory, ".cursor")); |
| 100 | yield* fs.writeFileString( |
| 101 | path.join(directory, ".cursor", "auth.json"), |
| 102 | '{"accessToken":"instance-token"}', |
| 103 | ); |
| 104 | for (const platform of ["linux", "darwin"] as const) { |
| 105 | const limits = yield* readCursorUsageLimits( |
| 106 | { apiEndpoint: "https://cursor.example/" }, |
| 107 | { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" }, |
| 108 | ).pipe( |
| 109 | Effect.provideService(HostProcess.Platform, platform), |
| 110 | Effect.provideService(HttpClient.HttpClient, client), |
| 111 | ); |
| 112 | expect(limits.windows[0]?.usedPercent).toBe(42); |
| 113 | } |
| 114 | }).pipe(Effect.scoped), |
| 115 | ); |
| 116 | }), |
| 117 | ); |
| 118 | |
| 119 | it.effect( |
| 120 | "never reads stale files for keychain or memory logins, but accepts an explicit auth token", |
| 121 | () => |
| 122 | Effect.gen(function* () { |
| 123 | for (const platform of ["linux", "darwin"] as const) { |
| 124 | for (const token of [undefined, "explicit-token"]) { |
| 125 | const limits = yield* withNodeServices( |
| 126 | readCursorUsageLimits( |
| 127 | { apiEndpoint: "" }, |
| 128 | { |
| 129 | AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", |
| 130 | ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), |
| 131 | }, |
| 132 | false, |
| 133 | ).pipe( |
| 134 | Effect.provideService(HostProcess.Platform, platform), |
| 135 | Effect.provideService( |
| 136 | FileSystem.FileSystem, |
| 137 | FileSystem.makeNoop({ |
| 138 | readFileString: () => Effect.die("must not read an unrelated credential file"), |
| 139 | }), |
| 140 | ), |
| 141 | Effect.provideService( |
| 142 | HttpClient.HttpClient, |
| 143 | HttpClient.make((request) => { |
| 144 | expect(token).toBe("explicit-token"); |
| 145 | expect(request.headers.authorization).toBe("Bearer explicit-token"); |
| 146 | return Effect.succeed( |
| 147 | HttpClientResponse.fromWeb( |
| 148 | request, |
| 149 | Response.json({ planUsage: { totalPercentUsed: 10 } }), |
| 150 | ), |
| 151 | ); |
| 152 | }), |
| 153 | ), |
| 154 | ), |
| 155 | ); |
| 156 | if (token) expect(limits.windows[0]?.usedPercent).toBe(10); |
| 157 | else expect(limits.unavailable?.reason).toBe("unsupported"); |
| 158 | } |
| 159 | } |
| 160 | }), |
| 161 | ); |
| 162 | |
| 163 | it.effect("reports failed requests without exposing credentials or response bodies", () => |
| 164 | Effect.gen(function* () { |
| 165 | const limits = yield* withNodeServices( |
| 166 | readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( |
| 167 | Effect.provideService( |
| 168 | HttpClient.HttpClient, |
| 169 | HttpClient.make((request) => |
| 170 | Effect.succeed( |
| 171 | HttpClientResponse.fromWeb( |
| 172 | request, |
| 173 | new Response("private response", { status: 401 }), |
| 174 | ), |
| 175 | ), |
| 176 | ), |
| 177 | ), |
| 178 | ), |
| 179 | ); |
| 180 | expect(limits.unavailable).toEqual({ |
| 181 | reason: "probeFailed", |
| 182 | message: "Cursor could not read usage limits.", |
| 183 | }); |
| 184 | }), |
| 185 | ); |
| 186 | |
| 187 | it.effect("does not use a stored login for an explicit API key", () => |
| 188 | Effect.gen(function* () { |
| 189 | const limits = yield* withNodeServices( |
| 190 | readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe( |
| 191 | Effect.provideService( |
| 192 | HttpClient.HttpClient, |
| 193 | HttpClient.make(() => Effect.die("must not request usage")), |
| 194 | ), |
| 195 | ), |
| 196 | ); |
| 197 | expect(limits.unavailable?.reason).toBe("unsupported"); |
| 198 | }), |
| 199 | ); |
| 200 | |
| 201 | it.effect("reads the default macOS Cursor login from Keychain for limits", () => |
| 202 | Effect.gen(function* () { |
| 203 | const limits = yield* withNodeServices( |
| 204 | readCursorUsageLimits({ apiEndpoint: "" }, {}, true).pipe( |
| 205 | Effect.provideService(HostProcess.Platform, "darwin"), |
| 206 | Effect.provideService( |
| 207 | FileSystem.FileSystem, |
| 208 | FileSystem.makeNoop({ |
| 209 | readFileString: () => Effect.die("must not read a stale credential file"), |
| 210 | }), |
| 211 | ), |
| 212 | Effect.provideService( |
| 213 | HttpClient.HttpClient, |
| 214 | HttpClient.make((request) => { |
| 215 | expect(request.headers.authorization).toBe("Bearer keychain-token"); |
| 216 | return Effect.succeed( |
| 217 | HttpClientResponse.fromWeb( |
| 218 | request, |
| 219 | Response.json({ planUsage: { totalPercentUsed: 42 } }), |
| 220 | ), |
| 221 | ); |
| 222 | }), |
| 223 | ), |
| 224 | ), |
| 225 | Effect.succeed("keychain-token"), |
| 226 | ); |
| 227 | expect(limits.windows[0]?.usedPercent).toBe(42); |
| 228 | }), |
| 229 | ); |
| 230 | |
| 231 | it.effect("reports a Keychain initialization failure without failing the provider refresh", () => |
| 232 | Effect.gen(function* () { |
| 233 | const limits = yield* withNodeServices( |
| 234 | readCursorUsageLimits({ apiEndpoint: "" }, {}, true).pipe( |
| 235 | Effect.provideService(HostProcess.Platform, "darwin"), |
| 236 | Effect.provideService( |
| 237 | HttpClient.HttpClient, |
| 238 | HttpClient.make(() => Effect.die("must not request limits without a login")), |
| 239 | ), |
| 240 | ), |
| 241 | Effect.fail( |
| 242 | new CursorKeychain.CursorKeychainReadError({ |
| 243 | cause: new Error("Keychain initialization failed"), |
| 244 | }), |
| 245 | ), |
| 246 | ); |
| 247 | expect(limits.unavailable?.reason).toBe("probeFailed"); |
| 248 | }), |
| 249 | ); |
| 250 | |
| 251 | it.effect("does not read Keychain or send its token to a custom endpoint", () => |
| 252 | Effect.gen(function* () { |
| 253 | for (const [apiEndpoint, environment] of [ |
| 254 | ["http://localhost:3000", {}], |
| 255 | ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], |
| 256 | ["https://cursor-proxy.example", {}], |
| 257 | ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], |
| 258 | ] as const) { |
| 259 | const limits = yield* withNodeServices( |
| 260 | readCursorUsageLimits({ apiEndpoint }, environment, true).pipe( |
| 261 | Effect.provideService(HostProcess.Platform, "darwin"), |
| 262 | Effect.provideService( |
| 263 | HttpClient.HttpClient, |
| 264 | HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), |
| 265 | ), |
| 266 | ), |
| 267 | ); |
| 268 | expect(limits.unavailable?.reason).toBe("unsupported"); |
| 269 | expect(limits.unavailable?.message).toContain("default Cursor endpoint"); |
| 270 | } |
| 271 | }), |
| 272 | ); |
| 273 | }); |