1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as FetchHttpClient from "effect/http/FetchHttpClient";
7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientError from "effect/http/HttpClientError";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import * as HttpClientResponse from "effect/http/HttpClientResponse";
11import * as Path from "effect/Path";
12
13import * as CursorKeychain from "./CursorKeychain.ts";
14import { readCursorAccountUsage } from "./accountUsage.ts";
15
16/** A scoped temporary directory holding `auth.json` with `accessToken`. */
17const writeAuth = (accessToken: string) =>
18 Effect.gen(function* () {
19 const fileSystem = yield* FileSystem.FileSystem;
20 const path = yield* Path.Path;
21 const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" });
22 const authPath = path.join(dir, "auth.json");
23 yield* fileSystem.writeFileString(authPath, JSON.stringify({ accessToken }));
24 return { dir, authPath };
25 });
26
27/** One dashboard request as Cursor sees it: the URL, the fetch options and the decoded page. */
28interface DashboardRequest {
29 readonly url: string;
30 readonly init: RequestInit;
31 readonly page: number;
32 readonly headers: Headers;
33}
34
35/** Stands in for Cursor's dashboard: `answer` replies to each page request. */
36const dashboard = (answer: (request: DashboardRequest) => Response | Promise<Response>) =>
37 HttpClient.make((request, url, signal, fiber) =>
38 Effect.tryPromise({
39 try: async () => {
40 const init = Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit);
41 const body =
42 request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
43 const response = await answer({
44 url: url.toString(),
45 init: { ...init, signal, body },
46 page: JSON.parse(body).page,
47 headers: new Headers(request.headers),
48 });
49 return HttpClientResponse.fromWeb(request, response);
50 },
51 catch: (cause) =>
52 new HttpClientError.HttpClientError({
53 reason: new HttpClientError.TransportError({ request, cause }),
54 }),
55 }),
56 );
57
58/** Runs `readCursorAccountUsage` against `answer` standing in for Cursor. */
59const readAccountUsage = (
60 credentialSource: Parameters<typeof readCursorAccountUsage>[0],
61 sinceMs: number,
62 endDate: number,
63 answer: (request: DashboardRequest) => Response | Promise<Response>,
64 keychainToken?: Parameters<typeof readCursorAccountUsage>[3],
65) =>
66 readCursorAccountUsage(credentialSource, sinceMs, endDate, keychainToken).pipe(
67 Effect.provideService(HttpClient.HttpClient, dashboard(answer)),
68 );
69
70describe("Cursor account history", () => {
71 it.effect("reads Cursor account history with the default macOS Keychain login", () =>
72 Effect.gen(function* () {
73 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
74 let keychainReads = 0;
75 const result = yield* readAccountUsage(
76 { kind: "keychain" },
77 0,
78 1781000000000,
79 ({ headers }) => {
80 assert.include(headers.get("cookie") ?? "", "demo%3A%3A");
81 return Response.json({ totalUsageEventsCount: 0, usageEventsDisplay: [] });
82 },
83 Effect.sync(() => {
84 keychainReads++;
85 return accessToken;
86 }),
87 );
88 assert.strictEqual(keychainReads, 1);
89 assert.isNull(result.error);
90 assert.isFalse(result.missing);
91 assert.isNotNull(result.accountKey);
92 }).pipe(Effect.provide(NodeServices.layer)),
93 );
94
95 it.effect(
96 "reads paginated Cursor account history including headless calls with separate cache tokens",
97 () =>
98 Effect.gen(function* () {
99 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`;
100 const { authPath } = yield* writeAuth(accessToken);
101 const pages: number[] = [];
102 const signals: AbortSignal[] = [];
103 const request = ({ url, init, headers, page }: DashboardRequest) => {
104 assert.strictEqual(url, "https://cursor.com/api/dashboard/get-filtered-usage-events");
105 assert.strictEqual(init.redirect, "error");
106 assert.strictEqual(headers.get("origin"), "https://cursor.com");
107 assert.strictEqual(headers.get("content-type"), "application/json");
108 assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A");
109 const body = { page };
110 pages.push(body.page);
111 if (init.signal) signals.push(init.signal);
112 return Response.json({
113 totalUsageEventsCount: 1001,
114 usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({
115 timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000),
116 model: "claude-sonnet-4-5",
117 conversationId: `conversation-${body.page}`,
118 isHeadless: body.page === 2,
119 chargedCents: 0,
120 tokenUsage: {
121 inputTokens: 10,
122 outputTokens: 5,
123 cacheReadTokens: 30,
124 cacheWriteTokens: 2,
125 totalCents: 25,
126 },
127 })),
128 });
129 };
130 const result = yield* readAccountUsage(authPath, 0, 1781000000000, request);
131 assert.isNull(result.error);
132 assert.deepStrictEqual(pages, [1, 2]);
133 assert.lengthOf(signals, 2);
134 assert.notStrictEqual(signals[0], signals[1]);
135 assert.strictEqual(result.records.length, 1001);
136 assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2");
137 assert.deepStrictEqual(result.records[0]?.totals, {
138 uncachedInputTokens: 10,
139 cachedInputTokens: 30,
140 cacheCreationTokens: 2,
141 outputTokens: 5,
142 reasoningTokens: 0,
143 });
144 assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25);
145 assert.isFalse(result.accountKey?.includes("demo") ?? true);
146 }).pipe(Effect.provide(NodeServices.layer)),
147 );
148
149 it.effect("reads Cursor account history beyond 100 pages", () =>
150 Effect.gen(function* () {
151 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
152 const { authPath } = yield* writeAuth(accessToken);
153 const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null }));
154 let requests = 0;
155 const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => {
156 requests += 1;
157 return Response.json({
158 totalUsageEventsCount: 100_001,
159 usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }],
160 });
161 });
162 assert.isNull(result.error);
163 assert.strictEqual(requests, 101);
164 assert.deepStrictEqual(result.records, []);
165 }).pipe(Effect.provide(NodeServices.layer)),
166 );
167
168 it.effect("accepts confirmed empty Cursor usage but rejects error envelopes", () =>
169 Effect.gen(function* () {
170 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
171 const { authPath } = yield* writeAuth(accessToken);
172 for (const body of [
173 {},
174 { totalUsageEventsCount: 0 },
175 { totalUsageEventsCount: 0, usageEventsDisplay: [] },
176 ]) {
177 const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () =>
178 Response.json(body),
179 );
180 assert.isNull(result.error);
181 assert.deepStrictEqual(result.records, []);
182 assert.isFalse(result.missing);
183 }
184 for (const body of [
185 { error: "upstream error" },
186 { detail: "unknown error envelope" },
187 { totalUsageEventsCount: 0, error: "upstream error" },
188 null,
189 [],
190 "invalid",
191 0,
192 ]) {
193 const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () =>
194 Response.json(body),
195 );
196 assert.isNotNull(result.error);
197 assert.deepStrictEqual(result.records, []);
198 }
199 }).pipe(Effect.provide(NodeServices.layer)),
200 );
201
202 it.effect("requires a terminal Cursor page after a full page reaches the reported count", () =>
203 Effect.gen(function* () {
204 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
205 const { authPath } = yield* writeAuth(accessToken);
206 let requests = 0;
207 const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => {
208 requests++;
209 return Response.json(
210 requests === 1
211 ? {
212 totalUsageEventsCount: 1000,
213 usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({
214 timestamp: String(1780000000000 + index),
215 model: "gpt-5",
216 tokenUsage: { inputTokens: 10, outputTokens: 5 },
217 })),
218 }
219 : { totalUsageEventsCount: 1000 },
220 );
221 });
222 assert.isNull(result.error);
223 assert.strictEqual(result.records.length, 1000);
224 assert.strictEqual(requests, 2);
225 }).pipe(Effect.provide(NodeServices.layer)),
226 );
227
228 it.effect(
229 "removes only count-proven Cursor boundary copies and preserves identical billed events",
230 () =>
231 Effect.gen(function* () {
232 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
233 const { authPath } = yield* writeAuth(accessToken);
234 const event = (index: number) => ({
235 timestamp: String(1780000000000 + index),
236 model: "gpt-5",
237 tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 },
238 });
239 for (const total of [2000, 2001]) {
240 let requests = 0;
241 const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => {
242 requests++;
243 return Response.json({
244 totalUsageEventsCount: total,
245 usageEventsDisplay:
246 requests === 1
247 ? Array.from({ length: 1000 }, (_, index) => event(index))
248 : requests === 2
249 ? Array.from({ length: 1000 }, (_, index) => event(999 + index))
250 : [event(1999)],
251 });
252 });
253 assert.isNull(result.error);
254 assert.strictEqual(result.records.length, total);
255 assert.strictEqual(requests, 3);
256 assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999);
257 assert.strictEqual(
258 result.records.filter((record) => record.timestampMs === 1780000000999).length,
259 total === 2000 ? 1 : 2,
260 );
261 assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total);
262 }
263 let requests = 0;
264 const inconsistent = yield* readAccountUsage(authPath, 0, 1781000000000, async () => {
265 requests++;
266 return Response.json({
267 totalUsageEventsCount: 1001,
268 usageEventsDisplay:
269 requests === 1
270 ? Array.from({ length: 1000 }, (_, index) => event(index))
271 : [event(500), event(1000)],
272 });
273 });
274 assert.isNotNull(inconsistent.error);
275 assert.deepStrictEqual(inconsistent.records, []);
276 }).pipe(Effect.provide(NodeServices.layer)),
277 );
278
279 it.effect(
280 "does not present truncated Cursor account pages or authentication failures as complete history",
281 () =>
282 Effect.gen(function* () {
283 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`;
284 const { dir, authPath } = yield* writeAuth(accessToken);
285 const truncated = yield* readAccountUsage(authPath, 0, 1781000000000, async () =>
286 Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }),
287 );
288 assert.isNotNull(truncated.error);
289 assert.deepStrictEqual(truncated.records, []);
290 const denied = yield* readAccountUsage(
291 authPath,
292 0,
293 1781000000000,
294 async () => new Response(accessToken, { status: 401 }),
295 );
296 assert.isNotNull(denied.error);
297 assert.isFalse(denied.error?.includes(accessToken) ?? true);
298 assert.deepStrictEqual(denied.records, []);
299 let requested = false;
300 const missing = yield* readAccountUsage(
301 (yield* Path.Path).join(dir, "missing.json"),
302 0,
303 1781000000000,
304 async () => {
305 requested = true;
306 return Response.json({});
307 },
308 );
309 assert.isTrue(missing.missing);
310 assert.isFalse(requested);
311 }).pipe(Effect.provide(NodeServices.layer)),
312 );
313});
314
315describe("readCursorAccountUsage", () => {
316 it.effect("asks for Keychain approval when the prompt goes unanswered", () =>
317 Effect.gen(function* () {
318 const result = yield* readAccountUsage(
319 { kind: "keychain" },
320 0,
321 1,
322 () => {
323 throw new Error("no network expected");
324 },
325 Effect.fail(new CursorKeychain.CursorKeychainTimeoutError()),
326 );
327 assert.deepStrictEqual(result, {
328 accountKey: null,
329 records: [],
330 missing: false,
331 error: "Allow Keychain access on the Mac running T3 Code, then refresh.",
332 });
333 }).pipe(Effect.provide(NodeServices.layer)),
334 );
335
336 it.effect("reads the pages behind the first together and keeps them in page order", () =>
337 Effect.gen(function* () {
338 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
339 let inFlight = 0;
340 let mostInFlight = 0;
341 const result = yield* readAccountUsage(
342 { kind: "keychain" },
343 0,
344 1781000000000,
345 async ({ page }) => {
346 inFlight++;
347 mostInFlight = Math.max(mostInFlight, inFlight);
348 // Later pages answer first, so record order cannot come from arrival order.
349 for (let turn = page; turn < 9; turn++) await Promise.resolve();
350 inFlight--;
351 return Response.json({
352 totalUsageEventsCount: 8001,
353 usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({
354 timestamp: String(1780000000000 + (page - 1) * 1000 + index),
355 model: "gpt-5",
356 tokenUsage: { inputTokens: 1 },
357 })),
358 });
359 },
360 Effect.succeed(accessToken),
361 );
362 assert.isNull(result.error);
363 assert.strictEqual(mostInFlight, 6);
364 assert.deepStrictEqual(
365 result.records.map((record) => record.timestampMs),
366 Array.from({ length: 8001 }, (_, index) => 1780000000000 + index),
367 );
368 }).pipe(Effect.provide(NodeServices.layer)),
369 );
370
371 it.effect("ends like a page-by-page read when a page fails, leaving no request open", () =>
372 Effect.gen(function* () {
373 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
374 const fullPage = {
375 totalUsageEventsCount: 8001,
376 usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })),
377 };
378 // Page 3 fails while the pages after it are still waiting on Cursor.
379 for (const [secondPage, error] of [
380 [() => Response.json(fullPage), "Cursor account usage could not be read."],
381 [
382 () => new Response(null, { status: 401 }),
383 "Sign in to Cursor again to read account usage.",
384 ],
385 ] as const) {
386 let open = 0;
387 const result = yield* readAccountUsage(
388 { kind: "keychain" },
389 0,
390 1781000000000,
391 ({ init, page }) => {
392 if (page === 1) return Promise.resolve(Response.json(fullPage));
393 if (page === 2) return Promise.resolve(secondPage());
394 if (page === 3) return Promise.reject(new Error("connection reset"));
395 open++;
396 return new Promise((_resolve, reject) => {
397 init.signal?.addEventListener("abort", () => {
398 open--;
399 reject(init.signal?.reason);
400 });
401 });
402 },
403 Effect.succeed(accessToken),
404 );
405 assert.strictEqual(result.error, error);
406 assert.deepStrictEqual(result.records, []);
407 assert.strictEqual(open, 0);
408 }
409 }).pipe(Effect.provide(NodeServices.layer)),
410 );
411});