openusage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/openusage +91 -0
- package/package.json +33 -0
- package/plugins/amp/icon.svg +6 -0
- package/plugins/amp/plugin.js +175 -0
- package/plugins/amp/plugin.json +20 -0
- package/plugins/amp/plugin.test.js +365 -0
- package/plugins/antigravity/icon.svg +3 -0
- package/plugins/antigravity/plugin.js +484 -0
- package/plugins/antigravity/plugin.json +17 -0
- package/plugins/antigravity/plugin.test.js +1356 -0
- package/plugins/claude/icon.svg +3 -0
- package/plugins/claude/plugin.js +565 -0
- package/plugins/claude/plugin.json +28 -0
- package/plugins/claude/plugin.test.js +1012 -0
- package/plugins/codex/icon.svg +3 -0
- package/plugins/codex/plugin.js +673 -0
- package/plugins/codex/plugin.json +30 -0
- package/plugins/codex/plugin.test.js +1071 -0
- package/plugins/copilot/icon.svg +3 -0
- package/plugins/copilot/plugin.js +264 -0
- package/plugins/copilot/plugin.json +20 -0
- package/plugins/copilot/plugin.test.js +529 -0
- package/plugins/cursor/icon.svg +3 -0
- package/plugins/cursor/plugin.js +526 -0
- package/plugins/cursor/plugin.json +24 -0
- package/plugins/cursor/plugin.test.js +1168 -0
- package/plugins/factory/icon.svg +1 -0
- package/plugins/factory/plugin.js +407 -0
- package/plugins/factory/plugin.json +19 -0
- package/plugins/factory/plugin.test.js +833 -0
- package/plugins/gemini/icon.svg +4 -0
- package/plugins/gemini/plugin.js +413 -0
- package/plugins/gemini/plugin.json +20 -0
- package/plugins/gemini/plugin.test.js +735 -0
- package/plugins/jetbrains-ai-assistant/icon.svg +3 -0
- package/plugins/jetbrains-ai-assistant/plugin.js +357 -0
- package/plugins/jetbrains-ai-assistant/plugin.json +17 -0
- package/plugins/jetbrains-ai-assistant/plugin.test.js +338 -0
- package/plugins/kimi/icon.svg +3 -0
- package/plugins/kimi/plugin.js +358 -0
- package/plugins/kimi/plugin.json +19 -0
- package/plugins/kimi/plugin.test.js +619 -0
- package/plugins/minimax/icon.svg +4 -0
- package/plugins/minimax/plugin.js +388 -0
- package/plugins/minimax/plugin.json +17 -0
- package/plugins/minimax/plugin.test.js +943 -0
- package/plugins/perplexity/icon.svg +1 -0
- package/plugins/perplexity/plugin.js +378 -0
- package/plugins/perplexity/plugin.json +15 -0
- package/plugins/perplexity/plugin.test.js +602 -0
- package/plugins/windsurf/icon.svg +3 -0
- package/plugins/windsurf/plugin.js +218 -0
- package/plugins/windsurf/plugin.json +16 -0
- package/plugins/windsurf/plugin.test.js +455 -0
- package/plugins/zai/icon.svg +5 -0
- package/plugins/zai/plugin.js +156 -0
- package/plugins/zai/plugin.json +18 -0
- package/plugins/zai/plugin.test.js +396 -0
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { makeCtx } from "../test-helpers.js"
|
|
3
|
+
|
|
4
|
+
const loadPlugin = async () => {
|
|
5
|
+
await import("./plugin.js")
|
|
6
|
+
return globalThis.__openusage_plugin
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function makeJwt(payload) {
|
|
10
|
+
const jwtPayload = Buffer.from(JSON.stringify(payload), "utf8")
|
|
11
|
+
.toString("base64")
|
|
12
|
+
.replace(/=+$/g, "")
|
|
13
|
+
return `a.${jwtPayload}.c`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("cursor plugin", () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
delete globalThis.__openusage_plugin
|
|
19
|
+
vi.resetModules()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("throws when no token", async () => {
|
|
23
|
+
const ctx = makeCtx()
|
|
24
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([]))
|
|
25
|
+
const plugin = await loadPlugin()
|
|
26
|
+
expect(() => plugin.probe(ctx)).toThrow("Not logged in")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("loads tokens from keychain when sqlite has none", async () => {
|
|
30
|
+
const ctx = makeCtx()
|
|
31
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([]))
|
|
32
|
+
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
|
|
33
|
+
if (service === "cursor-access-token") return "keychain-access-token"
|
|
34
|
+
if (service === "cursor-refresh-token") return "keychain-refresh-token"
|
|
35
|
+
return null
|
|
36
|
+
})
|
|
37
|
+
ctx.host.http.request.mockReturnValue({
|
|
38
|
+
status: 200,
|
|
39
|
+
bodyText: JSON.stringify({
|
|
40
|
+
enabled: true,
|
|
41
|
+
planUsage: { totalSpend: 1200, limit: 2400 },
|
|
42
|
+
}),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const plugin = await loadPlugin()
|
|
46
|
+
const result = plugin.probe(ctx)
|
|
47
|
+
|
|
48
|
+
expect(result.lines.find((line) => line.label === "Total usage")).toBeTruthy()
|
|
49
|
+
expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledWith("cursor-access-token")
|
|
50
|
+
expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledWith("cursor-refresh-token")
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("refreshes keychain access token and persists to keychain source", async () => {
|
|
54
|
+
const ctx = makeCtx()
|
|
55
|
+
const expiredPayload = Buffer.from(JSON.stringify({ exp: 1 }), "utf8")
|
|
56
|
+
.toString("base64")
|
|
57
|
+
.replace(/=+$/g, "")
|
|
58
|
+
const expiredAccessToken = `a.${expiredPayload}.c`
|
|
59
|
+
const freshPayload = Buffer.from(JSON.stringify({ exp: 9999999999 }), "utf8")
|
|
60
|
+
.toString("base64")
|
|
61
|
+
.replace(/=+$/g, "")
|
|
62
|
+
const refreshedAccessToken = `a.${freshPayload}.c`
|
|
63
|
+
|
|
64
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([]))
|
|
65
|
+
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
|
|
66
|
+
if (service === "cursor-access-token") return expiredAccessToken
|
|
67
|
+
if (service === "cursor-refresh-token") return "keychain-refresh-token"
|
|
68
|
+
return null
|
|
69
|
+
})
|
|
70
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
71
|
+
if (String(opts.url).includes("/oauth/token")) {
|
|
72
|
+
return {
|
|
73
|
+
status: 200,
|
|
74
|
+
bodyText: JSON.stringify({ access_token: refreshedAccessToken }),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
status: 200,
|
|
79
|
+
bodyText: JSON.stringify({
|
|
80
|
+
enabled: true,
|
|
81
|
+
planUsage: { totalSpend: 1200, limit: 2400 },
|
|
82
|
+
}),
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const plugin = await loadPlugin()
|
|
87
|
+
const result = plugin.probe(ctx)
|
|
88
|
+
|
|
89
|
+
expect(result.lines.find((line) => line.label === "Total usage")).toBeTruthy()
|
|
90
|
+
expect(ctx.host.keychain.writeGenericPassword).toHaveBeenCalledWith(
|
|
91
|
+
"cursor-access-token",
|
|
92
|
+
refreshedAccessToken
|
|
93
|
+
)
|
|
94
|
+
expect(ctx.host.sqlite.exec).not.toHaveBeenCalled()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("prefers sqlite tokens when sqlite and keychain both have tokens", async () => {
|
|
98
|
+
const ctx = makeCtx()
|
|
99
|
+
const sqlitePayload = Buffer.from(JSON.stringify({ exp: 9999999999 }), "utf8")
|
|
100
|
+
.toString("base64")
|
|
101
|
+
.replace(/=+$/g, "")
|
|
102
|
+
const sqliteToken = `a.${sqlitePayload}.c`
|
|
103
|
+
const keychainPayload = Buffer.from(JSON.stringify({ exp: 9999999999, sub: "keychain" }), "utf8")
|
|
104
|
+
.toString("base64")
|
|
105
|
+
.replace(/=+$/g, "")
|
|
106
|
+
const keychainToken = `a.${keychainPayload}.c`
|
|
107
|
+
|
|
108
|
+
ctx.host.sqlite.query.mockImplementation((db, sql) => {
|
|
109
|
+
if (String(sql).includes("cursorAuth/accessToken")) {
|
|
110
|
+
return JSON.stringify([{ value: sqliteToken }])
|
|
111
|
+
}
|
|
112
|
+
if (String(sql).includes("cursorAuth/refreshToken")) {
|
|
113
|
+
return JSON.stringify([{ value: "sqlite-refresh-token" }])
|
|
114
|
+
}
|
|
115
|
+
return JSON.stringify([])
|
|
116
|
+
})
|
|
117
|
+
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
|
|
118
|
+
if (service === "cursor-access-token") return keychainToken
|
|
119
|
+
if (service === "cursor-refresh-token") return "keychain-refresh-token"
|
|
120
|
+
return null
|
|
121
|
+
})
|
|
122
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
123
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
124
|
+
expect(opts.headers.Authorization).toBe("Bearer " + sqliteToken)
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
status: 200,
|
|
128
|
+
bodyText: JSON.stringify({
|
|
129
|
+
enabled: true,
|
|
130
|
+
planUsage: { totalSpend: 1200, limit: 2400 },
|
|
131
|
+
}),
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const plugin = await loadPlugin()
|
|
136
|
+
const result = plugin.probe(ctx)
|
|
137
|
+
|
|
138
|
+
expect(result.lines.find((line) => line.label === "Total usage")).toBeTruthy()
|
|
139
|
+
expect(ctx.host.keychain.readGenericPassword).not.toHaveBeenCalled()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("throws on sqlite errors when reading token", async () => {
|
|
143
|
+
const ctx = makeCtx()
|
|
144
|
+
ctx.host.sqlite.query.mockImplementation(() => {
|
|
145
|
+
throw new Error("boom")
|
|
146
|
+
})
|
|
147
|
+
const plugin = await loadPlugin()
|
|
148
|
+
expect(() => plugin.probe(ctx)).toThrow("Not logged in")
|
|
149
|
+
expect(ctx.host.log.warn).toHaveBeenCalled()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("throws on disabled usage", async () => {
|
|
153
|
+
const ctx = makeCtx()
|
|
154
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
155
|
+
ctx.host.http.request.mockReturnValue({
|
|
156
|
+
status: 200,
|
|
157
|
+
bodyText: JSON.stringify({ enabled: false }),
|
|
158
|
+
})
|
|
159
|
+
const plugin = await loadPlugin()
|
|
160
|
+
expect(() => plugin.probe(ctx)).toThrow("No active Cursor subscription.")
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("throws on missing plan usage data", async () => {
|
|
164
|
+
const ctx = makeCtx()
|
|
165
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
166
|
+
ctx.host.http.request.mockReturnValue({
|
|
167
|
+
status: 200,
|
|
168
|
+
bodyText: JSON.stringify({ enabled: true }),
|
|
169
|
+
})
|
|
170
|
+
const plugin = await loadPlugin()
|
|
171
|
+
expect(() => plugin.probe(ctx)).toThrow("No active Cursor subscription.")
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("accepts team usage when enabled flag is missing", async () => {
|
|
175
|
+
const ctx = makeCtx()
|
|
176
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
177
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
178
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
179
|
+
return {
|
|
180
|
+
status: 200,
|
|
181
|
+
bodyText: JSON.stringify({
|
|
182
|
+
billingCycleStart: "1770064133000",
|
|
183
|
+
billingCycleEnd: "1772483333000",
|
|
184
|
+
planUsage: { totalSpend: 8474, limit: 2000, bonusSpend: 6474 },
|
|
185
|
+
spendLimitUsage: {
|
|
186
|
+
pooledLimit: 60000,
|
|
187
|
+
pooledRemaining: 19216,
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (String(opts.url).includes("GetPlanInfo")) {
|
|
193
|
+
return {
|
|
194
|
+
status: 200,
|
|
195
|
+
bodyText: JSON.stringify({ planInfo: { planName: "Team" } }),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { status: 200, bodyText: "{}" }
|
|
199
|
+
})
|
|
200
|
+
const plugin = await loadPlugin()
|
|
201
|
+
const result = plugin.probe(ctx)
|
|
202
|
+
expect(result.plan).toBe("Team")
|
|
203
|
+
const totalLine = result.lines.find((line) => line.label === "Total usage")
|
|
204
|
+
expect(totalLine).toBeTruthy()
|
|
205
|
+
expect(totalLine.format).toEqual({ kind: "dollars" })
|
|
206
|
+
expect(totalLine.used).toBe(84.74)
|
|
207
|
+
expect(totalLine.limit).toBe(20)
|
|
208
|
+
expect(result.lines.find((line) => line.label === "Bonus spend")).toBeTruthy()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it("throws on missing total usage limit", async () => {
|
|
212
|
+
const ctx = makeCtx()
|
|
213
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
214
|
+
ctx.host.http.request.mockReturnValue({
|
|
215
|
+
status: 200,
|
|
216
|
+
bodyText: JSON.stringify({
|
|
217
|
+
enabled: true,
|
|
218
|
+
planUsage: { totalSpend: 1200 }, // missing limit
|
|
219
|
+
}),
|
|
220
|
+
})
|
|
221
|
+
const plugin = await loadPlugin()
|
|
222
|
+
expect(() => plugin.probe(ctx)).toThrow("Total usage limit missing")
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("falls back to computed percent when totalSpend missing and no totalPercentUsed", async () => {
|
|
226
|
+
const ctx = makeCtx()
|
|
227
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
228
|
+
ctx.host.http.request.mockReturnValue({
|
|
229
|
+
status: 200,
|
|
230
|
+
bodyText: JSON.stringify({
|
|
231
|
+
enabled: true,
|
|
232
|
+
planUsage: { limit: 2400, remaining: 1200 },
|
|
233
|
+
}),
|
|
234
|
+
})
|
|
235
|
+
const plugin = await loadPlugin()
|
|
236
|
+
const result = plugin.probe(ctx)
|
|
237
|
+
const planLine = result.lines.find((l) => l.label === "Total usage")
|
|
238
|
+
expect(planLine).toBeTruthy()
|
|
239
|
+
expect(planLine.format).toEqual({ kind: "percent" })
|
|
240
|
+
// computed = (2400 - 1200) / 2400 * 100 = 50
|
|
241
|
+
expect(planLine.used).toBe(50)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("renders usage + plan info", async () => {
|
|
245
|
+
const ctx = makeCtx()
|
|
246
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
247
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
248
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
249
|
+
return {
|
|
250
|
+
status: 200,
|
|
251
|
+
bodyText: JSON.stringify({
|
|
252
|
+
enabled: true,
|
|
253
|
+
planUsage: { totalSpend: 1200, limit: 2400, bonusSpend: 100 },
|
|
254
|
+
spendLimitUsage: { individualLimit: 5000, individualRemaining: 1000 },
|
|
255
|
+
billingCycleEnd: Date.now(),
|
|
256
|
+
}),
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
status: 200,
|
|
261
|
+
bodyText: JSON.stringify({ planInfo: { planName: "pro plan" } }),
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
const plugin = await loadPlugin()
|
|
265
|
+
const result = plugin.probe(ctx)
|
|
266
|
+
expect(result.plan).toBeTruthy()
|
|
267
|
+
expect(result.lines.find((line) => line.label === "Total usage")).toBeTruthy()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it("omits plan badge for blank plan names", async () => {
|
|
271
|
+
const ctx = makeCtx()
|
|
272
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
273
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
274
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
275
|
+
return {
|
|
276
|
+
status: 200,
|
|
277
|
+
bodyText: JSON.stringify({
|
|
278
|
+
enabled: true,
|
|
279
|
+
planUsage: { totalSpend: 1200, limit: 2400 },
|
|
280
|
+
}),
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
status: 200,
|
|
285
|
+
bodyText: JSON.stringify({ planInfo: { planName: " " } }),
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
const plugin = await loadPlugin()
|
|
289
|
+
const result = plugin.probe(ctx)
|
|
290
|
+
expect(result.plan).toBeFalsy()
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it("uses pooled spend limits when individual values missing", async () => {
|
|
294
|
+
const ctx = makeCtx()
|
|
295
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
296
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
297
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
298
|
+
return {
|
|
299
|
+
status: 200,
|
|
300
|
+
bodyText: JSON.stringify({
|
|
301
|
+
enabled: true,
|
|
302
|
+
planUsage: { totalSpend: 1200, limit: 2400 },
|
|
303
|
+
spendLimitUsage: { pooledLimit: 2000, pooledRemaining: 500 },
|
|
304
|
+
}),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
status: 200,
|
|
309
|
+
bodyText: JSON.stringify({ planInfo: { planName: "pro plan" } }),
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
const plugin = await loadPlugin()
|
|
313
|
+
const result = plugin.probe(ctx)
|
|
314
|
+
expect(result.lines.find((line) => line.label === "On-demand")).toBeTruthy()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it("throws on token expired", async () => {
|
|
318
|
+
const ctx = makeCtx()
|
|
319
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
320
|
+
ctx.host.http.request.mockReturnValue({ status: 401, bodyText: "" })
|
|
321
|
+
const plugin = await loadPlugin()
|
|
322
|
+
expect(() => plugin.probe(ctx)).toThrow("Token expired")
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it("throws on http errors", async () => {
|
|
326
|
+
const ctx = makeCtx()
|
|
327
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
328
|
+
ctx.host.http.request.mockReturnValue({ status: 500, bodyText: "" })
|
|
329
|
+
const plugin = await loadPlugin()
|
|
330
|
+
expect(() => plugin.probe(ctx)).toThrow("HTTP 500")
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it("throws on usage request errors", async () => {
|
|
334
|
+
const ctx = makeCtx()
|
|
335
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
336
|
+
ctx.host.http.request.mockImplementation(() => {
|
|
337
|
+
throw new Error("boom")
|
|
338
|
+
})
|
|
339
|
+
const plugin = await loadPlugin()
|
|
340
|
+
expect(() => plugin.probe(ctx)).toThrow("Usage request failed")
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it("throws on parse errors", async () => {
|
|
344
|
+
const ctx = makeCtx()
|
|
345
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
346
|
+
ctx.host.http.request.mockReturnValue({
|
|
347
|
+
status: 200,
|
|
348
|
+
bodyText: "not-json",
|
|
349
|
+
})
|
|
350
|
+
const plugin = await loadPlugin()
|
|
351
|
+
expect(() => plugin.probe(ctx)).toThrow("Usage response invalid")
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it("handles enterprise account with request-based usage", async () => {
|
|
355
|
+
const ctx = makeCtx()
|
|
356
|
+
|
|
357
|
+
// Build a JWT with a sub claim containing a user ID
|
|
358
|
+
const jwtPayload = Buffer.from(
|
|
359
|
+
JSON.stringify({ sub: "google-oauth2|user_abc123", exp: 9999999999 }),
|
|
360
|
+
"utf8"
|
|
361
|
+
)
|
|
362
|
+
.toString("base64")
|
|
363
|
+
.replace(/=+$/g, "")
|
|
364
|
+
const accessToken = `a.${jwtPayload}.c`
|
|
365
|
+
|
|
366
|
+
ctx.host.sqlite.query.mockReturnValue(
|
|
367
|
+
JSON.stringify([{ value: accessToken }])
|
|
368
|
+
)
|
|
369
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
370
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
371
|
+
// Enterprise returns no enabled/planUsage
|
|
372
|
+
return {
|
|
373
|
+
status: 200,
|
|
374
|
+
bodyText: JSON.stringify({
|
|
375
|
+
billingCycleStart: "1770539602363",
|
|
376
|
+
billingCycleEnd: "1770539602363",
|
|
377
|
+
displayThreshold: 100,
|
|
378
|
+
}),
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (String(opts.url).includes("GetPlanInfo")) {
|
|
382
|
+
return {
|
|
383
|
+
status: 200,
|
|
384
|
+
bodyText: JSON.stringify({
|
|
385
|
+
planInfo: { planName: "Enterprise", price: "Custom" },
|
|
386
|
+
}),
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (String(opts.url).includes("cursor.com/api/usage")) {
|
|
390
|
+
return {
|
|
391
|
+
status: 200,
|
|
392
|
+
bodyText: JSON.stringify({
|
|
393
|
+
"gpt-4": {
|
|
394
|
+
numRequests: 422,
|
|
395
|
+
numRequestsTotal: 422,
|
|
396
|
+
numTokens: 171664819,
|
|
397
|
+
maxRequestUsage: 500,
|
|
398
|
+
maxTokenUsage: null,
|
|
399
|
+
},
|
|
400
|
+
startOfMonth: "2026-02-01T06:12:57.000Z",
|
|
401
|
+
}),
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return { status: 200, bodyText: "{}" }
|
|
405
|
+
})
|
|
406
|
+
const plugin = await loadPlugin()
|
|
407
|
+
const result = plugin.probe(ctx)
|
|
408
|
+
expect(result.plan).toBe("Enterprise")
|
|
409
|
+
const reqLine = result.lines.find((l) => l.label === "Requests")
|
|
410
|
+
expect(reqLine).toBeTruthy()
|
|
411
|
+
expect(reqLine.used).toBe(422)
|
|
412
|
+
expect(reqLine.limit).toBe(500)
|
|
413
|
+
expect(reqLine.format).toEqual({ kind: "count", suffix: "requests" })
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it("throws when enterprise REST usage API fails", async () => {
|
|
417
|
+
const ctx = makeCtx()
|
|
418
|
+
|
|
419
|
+
const jwtPayload = Buffer.from(
|
|
420
|
+
JSON.stringify({ sub: "google-oauth2|user_abc123", exp: 9999999999 }),
|
|
421
|
+
"utf8"
|
|
422
|
+
)
|
|
423
|
+
.toString("base64")
|
|
424
|
+
.replace(/=+$/g, "")
|
|
425
|
+
const accessToken = `a.${jwtPayload}.c`
|
|
426
|
+
|
|
427
|
+
ctx.host.sqlite.query.mockReturnValue(
|
|
428
|
+
JSON.stringify([{ value: accessToken }])
|
|
429
|
+
)
|
|
430
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
431
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
432
|
+
return {
|
|
433
|
+
status: 200,
|
|
434
|
+
bodyText: JSON.stringify({
|
|
435
|
+
billingCycleStart: "1770539602363",
|
|
436
|
+
billingCycleEnd: "1770539602363",
|
|
437
|
+
}),
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (String(opts.url).includes("GetPlanInfo")) {
|
|
441
|
+
return {
|
|
442
|
+
status: 200,
|
|
443
|
+
bodyText: JSON.stringify({
|
|
444
|
+
planInfo: { planName: "Enterprise" },
|
|
445
|
+
}),
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (String(opts.url).includes("cursor.com/api/usage")) {
|
|
449
|
+
return { status: 500, bodyText: "" }
|
|
450
|
+
}
|
|
451
|
+
return { status: 200, bodyText: "{}" }
|
|
452
|
+
})
|
|
453
|
+
const plugin = await loadPlugin()
|
|
454
|
+
expect(() => plugin.probe(ctx)).toThrow("Enterprise usage data unavailable")
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it("still throws no subscription for non-enterprise accounts without planUsage", async () => {
|
|
458
|
+
const ctx = makeCtx()
|
|
459
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
460
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
461
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
462
|
+
return {
|
|
463
|
+
status: 200,
|
|
464
|
+
bodyText: JSON.stringify({ enabled: false }),
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (String(opts.url).includes("GetPlanInfo")) {
|
|
468
|
+
return {
|
|
469
|
+
status: 200,
|
|
470
|
+
bodyText: JSON.stringify({ planInfo: { planName: "Pro" } }),
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return { status: 200, bodyText: "{}" }
|
|
474
|
+
})
|
|
475
|
+
const plugin = await loadPlugin()
|
|
476
|
+
expect(() => plugin.probe(ctx)).toThrow("No active Cursor subscription.")
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it("handles plan fetch failure gracefully", async () => {
|
|
480
|
+
const ctx = makeCtx()
|
|
481
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
482
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
483
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
484
|
+
return {
|
|
485
|
+
status: 200,
|
|
486
|
+
bodyText: JSON.stringify({
|
|
487
|
+
enabled: true,
|
|
488
|
+
planUsage: { totalSpend: 0, limit: 100 },
|
|
489
|
+
}),
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// Plan fetch fails
|
|
493
|
+
throw new Error("plan fail")
|
|
494
|
+
})
|
|
495
|
+
const plugin = await loadPlugin()
|
|
496
|
+
const result = plugin.probe(ctx)
|
|
497
|
+
expect(result.lines.find((line) => line.label === "Total usage")).toBeTruthy()
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it("outputs Credits first when available", async () => {
|
|
501
|
+
const ctx = makeCtx()
|
|
502
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
503
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
504
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
505
|
+
return {
|
|
506
|
+
status: 200,
|
|
507
|
+
bodyText: JSON.stringify({
|
|
508
|
+
enabled: true,
|
|
509
|
+
planUsage: { totalSpend: 1200, limit: 2400 },
|
|
510
|
+
spendLimitUsage: { individualLimit: 5000, individualRemaining: 1000 },
|
|
511
|
+
}),
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (String(opts.url).includes("GetCreditGrantsBalance")) {
|
|
515
|
+
return {
|
|
516
|
+
status: 200,
|
|
517
|
+
bodyText: JSON.stringify({
|
|
518
|
+
hasCreditGrants: true,
|
|
519
|
+
totalCents: 10000,
|
|
520
|
+
usedCents: 500,
|
|
521
|
+
}),
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return { status: 200, bodyText: "{}" }
|
|
525
|
+
})
|
|
526
|
+
const plugin = await loadPlugin()
|
|
527
|
+
const result = plugin.probe(ctx)
|
|
528
|
+
|
|
529
|
+
// Credits should be first in the lines array
|
|
530
|
+
expect(result.lines[0].label).toBe("Credits")
|
|
531
|
+
expect(result.lines[1].label).toBe("Total usage")
|
|
532
|
+
// On-demand should come after
|
|
533
|
+
const onDemandIndex = result.lines.findIndex((l) => l.label === "On-demand")
|
|
534
|
+
expect(onDemandIndex).toBeGreaterThan(1)
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it("outputs Total usage first when Credits not available", async () => {
|
|
538
|
+
const ctx = makeCtx()
|
|
539
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
540
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
541
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
542
|
+
return {
|
|
543
|
+
status: 200,
|
|
544
|
+
bodyText: JSON.stringify({
|
|
545
|
+
enabled: true,
|
|
546
|
+
planUsage: { totalSpend: 1200, limit: 2400 },
|
|
547
|
+
}),
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (String(opts.url).includes("GetCreditGrantsBalance")) {
|
|
551
|
+
return {
|
|
552
|
+
status: 200,
|
|
553
|
+
bodyText: JSON.stringify({ hasCreditGrants: false }),
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return { status: 200, bodyText: "{}" }
|
|
557
|
+
})
|
|
558
|
+
const plugin = await loadPlugin()
|
|
559
|
+
const result = plugin.probe(ctx)
|
|
560
|
+
|
|
561
|
+
// Total usage should be first when Credits not available
|
|
562
|
+
expect(result.lines[0].label).toBe("Total usage")
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it("emits Auto usage and API usage percent lines when available", async () => {
|
|
566
|
+
const ctx = makeCtx()
|
|
567
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
568
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
569
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
570
|
+
return {
|
|
571
|
+
status: 200,
|
|
572
|
+
bodyText: JSON.stringify({
|
|
573
|
+
enabled: true,
|
|
574
|
+
billingCycleEnd: Date.now(),
|
|
575
|
+
planUsage: {
|
|
576
|
+
limit: 40000,
|
|
577
|
+
remaining: 32000,
|
|
578
|
+
totalPercentUsed: 20,
|
|
579
|
+
autoPercentUsed: 12.5,
|
|
580
|
+
apiPercentUsed: 7.5,
|
|
581
|
+
},
|
|
582
|
+
}),
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return { status: 200, bodyText: "{}" }
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
const plugin = await loadPlugin()
|
|
589
|
+
const result = plugin.probe(ctx)
|
|
590
|
+
const totalLine = result.lines.find((line) => line.label === "Total usage")
|
|
591
|
+
const autoLine = result.lines.find((line) => line.label === "Auto usage")
|
|
592
|
+
const apiLine = result.lines.find((line) => line.label === "API usage")
|
|
593
|
+
|
|
594
|
+
expect(totalLine).toBeTruthy()
|
|
595
|
+
expect(totalLine.used).toBe(20)
|
|
596
|
+
expect(totalLine.format).toEqual({ kind: "percent" })
|
|
597
|
+
expect(autoLine).toBeTruthy()
|
|
598
|
+
expect(autoLine.used).toBe(12.5)
|
|
599
|
+
expect(autoLine.format).toEqual({ kind: "percent" })
|
|
600
|
+
expect(apiLine).toBeTruthy()
|
|
601
|
+
expect(apiLine.used).toBe(7.5)
|
|
602
|
+
expect(apiLine.format).toEqual({ kind: "percent" })
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it("falls back to computed percent when totalPercentUsed is not finite", async () => {
|
|
606
|
+
const ctx = makeCtx()
|
|
607
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
608
|
+
ctx.host.http.request.mockReturnValue({
|
|
609
|
+
status: 200,
|
|
610
|
+
bodyText: JSON.stringify({
|
|
611
|
+
enabled: true,
|
|
612
|
+
planUsage: { limit: 2400, remaining: 1200, totalPercentUsed: Number.POSITIVE_INFINITY },
|
|
613
|
+
}),
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
const plugin = await loadPlugin()
|
|
617
|
+
const result = plugin.probe(ctx)
|
|
618
|
+
const totalLine = result.lines.find((l) => l.label === "Total usage")
|
|
619
|
+
expect(totalLine).toBeTruthy()
|
|
620
|
+
expect(totalLine.used).toBe(50)
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it("omits Auto usage and API usage when percent fields missing", async () => {
|
|
624
|
+
const ctx = makeCtx()
|
|
625
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
626
|
+
ctx.host.http.request.mockReturnValue({
|
|
627
|
+
status: 200,
|
|
628
|
+
bodyText: JSON.stringify({
|
|
629
|
+
enabled: true,
|
|
630
|
+
planUsage: { limit: 40000, remaining: 32000, totalPercentUsed: 20 },
|
|
631
|
+
}),
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
const plugin = await loadPlugin()
|
|
635
|
+
const result = plugin.probe(ctx)
|
|
636
|
+
expect(result.lines.find((line) => line.label === "Total usage")).toBeTruthy()
|
|
637
|
+
expect(result.lines.find((line) => line.label === "Auto usage")).toBeUndefined()
|
|
638
|
+
expect(result.lines.find((line) => line.label === "API usage")).toBeUndefined()
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it("team account uses dollars format for Total usage", async () => {
|
|
642
|
+
const ctx = makeCtx()
|
|
643
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
644
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
645
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
646
|
+
return {
|
|
647
|
+
status: 200,
|
|
648
|
+
bodyText: JSON.stringify({
|
|
649
|
+
enabled: true,
|
|
650
|
+
planUsage: { totalSpend: 1200, limit: 2400 },
|
|
651
|
+
spendLimitUsage: { limitType: "team", pooledLimit: 5000, pooledRemaining: 3000 },
|
|
652
|
+
}),
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return { status: 200, bodyText: "{}" }
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
const plugin = await loadPlugin()
|
|
659
|
+
const result = plugin.probe(ctx)
|
|
660
|
+
const totalLine = result.lines.find((line) => line.label === "Total usage")
|
|
661
|
+
expect(totalLine).toBeTruthy()
|
|
662
|
+
expect(totalLine.format).toEqual({ kind: "dollars" })
|
|
663
|
+
expect(totalLine.used).toBe(12)
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
it("refreshes token when expired and persists new access token", async () => {
|
|
667
|
+
const ctx = makeCtx()
|
|
668
|
+
|
|
669
|
+
const expiredPayload = Buffer.from(JSON.stringify({ exp: 1 }), "utf8")
|
|
670
|
+
.toString("base64")
|
|
671
|
+
.replace(/=+$/g, "")
|
|
672
|
+
const accessToken = `a.${expiredPayload}.c`
|
|
673
|
+
|
|
674
|
+
ctx.host.sqlite.query.mockImplementation((db, sql) => {
|
|
675
|
+
if (String(sql).includes("cursorAuth/accessToken")) {
|
|
676
|
+
return JSON.stringify([{ value: accessToken }])
|
|
677
|
+
}
|
|
678
|
+
if (String(sql).includes("cursorAuth/refreshToken")) {
|
|
679
|
+
return JSON.stringify([{ value: "refresh" }])
|
|
680
|
+
}
|
|
681
|
+
return JSON.stringify([])
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
const newPayload = Buffer.from(JSON.stringify({ exp: 9999999999 }), "utf8")
|
|
685
|
+
.toString("base64")
|
|
686
|
+
.replace(/=+$/g, "")
|
|
687
|
+
const newToken = `a.${newPayload}.c`
|
|
688
|
+
|
|
689
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
690
|
+
if (String(opts.url).includes("/oauth/token")) {
|
|
691
|
+
return { status: 200, bodyText: JSON.stringify({ access_token: newToken }) }
|
|
692
|
+
}
|
|
693
|
+
return {
|
|
694
|
+
status: 200,
|
|
695
|
+
bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }),
|
|
696
|
+
}
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
const plugin = await loadPlugin()
|
|
700
|
+
const result = plugin.probe(ctx)
|
|
701
|
+
expect(result.lines.find((line) => line.label === "Total usage")).toBeTruthy()
|
|
702
|
+
expect(ctx.host.sqlite.exec).toHaveBeenCalled()
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
it("throws session expired when refresh requires logout and no access token exists", async () => {
|
|
706
|
+
const ctx = makeCtx()
|
|
707
|
+
ctx.host.sqlite.query.mockImplementation((db, sql) => {
|
|
708
|
+
if (String(sql).includes("cursorAuth/accessToken")) {
|
|
709
|
+
return JSON.stringify([])
|
|
710
|
+
}
|
|
711
|
+
if (String(sql).includes("cursorAuth/refreshToken")) {
|
|
712
|
+
return JSON.stringify([{ value: "refresh" }])
|
|
713
|
+
}
|
|
714
|
+
return JSON.stringify([])
|
|
715
|
+
})
|
|
716
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
717
|
+
if (String(opts.url).includes("/oauth/token")) {
|
|
718
|
+
return { status: 200, bodyText: JSON.stringify({ shouldLogout: true }) }
|
|
719
|
+
}
|
|
720
|
+
return { status: 500, bodyText: "" }
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
const plugin = await loadPlugin()
|
|
724
|
+
expect(() => plugin.probe(ctx)).toThrow("Session expired")
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
it("continues with existing access token when refresh fails", async () => {
|
|
728
|
+
const ctx = makeCtx()
|
|
729
|
+
|
|
730
|
+
const payload = Buffer.from(JSON.stringify({ exp: 1 }), "utf8")
|
|
731
|
+
.toString("base64")
|
|
732
|
+
.replace(/=+$/g, "")
|
|
733
|
+
const accessToken = `a.${payload}.c`
|
|
734
|
+
|
|
735
|
+
ctx.host.sqlite.query.mockImplementation((db, sql) => {
|
|
736
|
+
if (String(sql).includes("cursorAuth/accessToken")) {
|
|
737
|
+
return JSON.stringify([{ value: accessToken }])
|
|
738
|
+
}
|
|
739
|
+
if (String(sql).includes("cursorAuth/refreshToken")) {
|
|
740
|
+
return JSON.stringify([{ value: "refresh" }])
|
|
741
|
+
}
|
|
742
|
+
return JSON.stringify([])
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
746
|
+
if (String(opts.url).includes("/oauth/token")) {
|
|
747
|
+
// Force refresh to throw string error.
|
|
748
|
+
return { status: 401, bodyText: JSON.stringify({ shouldLogout: true }) }
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
status: 200,
|
|
752
|
+
bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }),
|
|
753
|
+
}
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
const plugin = await loadPlugin()
|
|
757
|
+
expect(() => plugin.probe(ctx)).not.toThrow()
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
it("handles invalid sqlite JSON for access token when refresh token is available", async () => {
|
|
761
|
+
const ctx = makeCtx()
|
|
762
|
+
const refreshedToken = makeJwt({ sub: "google-oauth2|user_abc123", exp: 9999999999 })
|
|
763
|
+
|
|
764
|
+
ctx.host.sqlite.query.mockImplementation((db, sql) => {
|
|
765
|
+
if (String(sql).includes("cursorAuth/accessToken")) return "{}"
|
|
766
|
+
if (String(sql).includes("cursorAuth/refreshToken")) return JSON.stringify([{ value: "refresh" }])
|
|
767
|
+
return JSON.stringify([])
|
|
768
|
+
})
|
|
769
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
770
|
+
if (String(opts.url).includes("/oauth/token")) {
|
|
771
|
+
return { status: 200, bodyText: JSON.stringify({ access_token: refreshedToken }) }
|
|
772
|
+
}
|
|
773
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
774
|
+
return { status: 200, bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }) }
|
|
775
|
+
}
|
|
776
|
+
return { status: 200, bodyText: "{}" }
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
const plugin = await loadPlugin()
|
|
780
|
+
const result = plugin.probe(ctx)
|
|
781
|
+
expect(result.lines.find((line) => line.label === "Total usage")).toBeTruthy()
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
it("throws not logged in when only refresh token exists but refresh returns no access token", async () => {
|
|
785
|
+
const ctx = makeCtx()
|
|
786
|
+
ctx.host.sqlite.query.mockImplementation((db, sql) => {
|
|
787
|
+
if (String(sql).includes("cursorAuth/accessToken")) return JSON.stringify([])
|
|
788
|
+
if (String(sql).includes("cursorAuth/refreshToken")) return JSON.stringify([{ value: "refresh" }])
|
|
789
|
+
return JSON.stringify([])
|
|
790
|
+
})
|
|
791
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
792
|
+
if (String(opts.url).includes("/oauth/token")) {
|
|
793
|
+
return { status: 200, bodyText: JSON.stringify({}) }
|
|
794
|
+
}
|
|
795
|
+
return { status: 500, bodyText: "" }
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
const plugin = await loadPlugin()
|
|
799
|
+
expect(() => plugin.probe(ctx)).toThrow("Not logged in")
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
it("throws token expired when usage remains unauthorized after refresh retry", async () => {
|
|
803
|
+
const ctx = makeCtx()
|
|
804
|
+
ctx.host.sqlite.query.mockImplementation((db, sql) => {
|
|
805
|
+
if (String(sql).includes("cursorAuth/accessToken")) {
|
|
806
|
+
return JSON.stringify([{ value: makeJwt({ sub: "google-oauth2|u", exp: 9999999999 }) }])
|
|
807
|
+
}
|
|
808
|
+
if (String(sql).includes("cursorAuth/refreshToken")) return JSON.stringify([{ value: "refresh" }])
|
|
809
|
+
return JSON.stringify([])
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
let usageCalls = 0
|
|
813
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
814
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
815
|
+
usageCalls += 1
|
|
816
|
+
if (usageCalls === 1) return { status: 401, bodyText: "" }
|
|
817
|
+
return { status: 403, bodyText: "" }
|
|
818
|
+
}
|
|
819
|
+
if (String(opts.url).includes("/oauth/token")) {
|
|
820
|
+
return { status: 200, bodyText: JSON.stringify({ access_token: makeJwt({ sub: "google-oauth2|u", exp: 9999999999 }) }) }
|
|
821
|
+
}
|
|
822
|
+
return { status: 200, bodyText: "{}" }
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
const plugin = await loadPlugin()
|
|
826
|
+
expect(() => plugin.probe(ctx)).toThrow("Token expired")
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
it("throws usage request failed after refresh when retried usage request errors", async () => {
|
|
830
|
+
const ctx = makeCtx()
|
|
831
|
+
ctx.host.sqlite.query.mockImplementation((db, sql) => {
|
|
832
|
+
if (String(sql).includes("cursorAuth/accessToken")) {
|
|
833
|
+
return JSON.stringify([{ value: makeJwt({ sub: "google-oauth2|u", exp: 9999999999 }) }])
|
|
834
|
+
}
|
|
835
|
+
if (String(sql).includes("cursorAuth/refreshToken")) return JSON.stringify([{ value: "refresh" }])
|
|
836
|
+
return JSON.stringify([])
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
let usageCalls = 0
|
|
840
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
841
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
842
|
+
usageCalls += 1
|
|
843
|
+
if (usageCalls === 1) return { status: 401, bodyText: "" }
|
|
844
|
+
throw new Error("boom")
|
|
845
|
+
}
|
|
846
|
+
if (String(opts.url).includes("/oauth/token")) {
|
|
847
|
+
return { status: 200, bodyText: JSON.stringify({ access_token: makeJwt({ sub: "google-oauth2|u", exp: 9999999999 }) }) }
|
|
848
|
+
}
|
|
849
|
+
return { status: 200, bodyText: "{}" }
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
const plugin = await loadPlugin()
|
|
853
|
+
expect(() => plugin.probe(ctx)).toThrow("Usage request failed after refresh")
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
it("throws enterprise unavailable when token payload has no sub", async () => {
|
|
857
|
+
const ctx = makeCtx()
|
|
858
|
+
const accessToken = makeJwt({ exp: 9999999999 })
|
|
859
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: accessToken }]))
|
|
860
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
861
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
862
|
+
return { status: 200, bodyText: JSON.stringify({ billingCycleStart: "1770539602363" }) }
|
|
863
|
+
}
|
|
864
|
+
if (String(opts.url).includes("GetPlanInfo")) {
|
|
865
|
+
return { status: 200, bodyText: JSON.stringify({ planInfo: { planName: "Enterprise" } }) }
|
|
866
|
+
}
|
|
867
|
+
return { status: 200, bodyText: "{}" }
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
const plugin = await loadPlugin()
|
|
871
|
+
expect(() => plugin.probe(ctx)).toThrow("Enterprise usage data unavailable")
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
it("supports enterprise JWT sub values without provider prefix", async () => {
|
|
875
|
+
const ctx = makeCtx()
|
|
876
|
+
const accessToken = makeJwt({ sub: "user_abc123", exp: 9999999999 })
|
|
877
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: accessToken }]))
|
|
878
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
879
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
880
|
+
return {
|
|
881
|
+
status: 200,
|
|
882
|
+
bodyText: JSON.stringify({
|
|
883
|
+
billingCycleStart: "1770539602363",
|
|
884
|
+
billingCycleEnd: "1770539602363",
|
|
885
|
+
}),
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (String(opts.url).includes("GetPlanInfo")) {
|
|
889
|
+
return {
|
|
890
|
+
status: 200,
|
|
891
|
+
bodyText: JSON.stringify({
|
|
892
|
+
planInfo: { planName: "Enterprise" },
|
|
893
|
+
}),
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (String(opts.url).includes("cursor.com/api/usage")) {
|
|
897
|
+
return {
|
|
898
|
+
status: 200,
|
|
899
|
+
bodyText: JSON.stringify({
|
|
900
|
+
"gpt-4": {
|
|
901
|
+
numRequests: 3,
|
|
902
|
+
maxRequestUsage: 10,
|
|
903
|
+
},
|
|
904
|
+
}),
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return { status: 200, bodyText: "{}" }
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
const plugin = await loadPlugin()
|
|
911
|
+
const result = plugin.probe(ctx)
|
|
912
|
+
expect(result.plan).toBe("Enterprise")
|
|
913
|
+
const reqLine = result.lines.find((l) => l.label === "Requests")
|
|
914
|
+
expect(reqLine).toBeTruthy()
|
|
915
|
+
expect(reqLine.used).toBe(3)
|
|
916
|
+
expect(reqLine.limit).toBe(10)
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
it("uses zero default for missing remaining and omits zero on-demand limits", async () => {
|
|
920
|
+
const ctx = makeCtx()
|
|
921
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
922
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
923
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
924
|
+
return {
|
|
925
|
+
status: 200,
|
|
926
|
+
bodyText: JSON.stringify({
|
|
927
|
+
enabled: true,
|
|
928
|
+
planUsage: { limit: 2400 },
|
|
929
|
+
spendLimitUsage: {},
|
|
930
|
+
}),
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return { status: 200, bodyText: "{}" }
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
const plugin = await loadPlugin()
|
|
937
|
+
const result = plugin.probe(ctx)
|
|
938
|
+
const planLine = result.lines.find((line) => line.label === "Total usage")
|
|
939
|
+
expect(planLine).toBeTruthy()
|
|
940
|
+
expect(planLine.used).toBe(100)
|
|
941
|
+
expect(result.lines.find((line) => line.label === "On-demand")).toBeUndefined()
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
it("rethrows string errors from retry wrapper", async () => {
|
|
945
|
+
const ctx = makeCtx()
|
|
946
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
947
|
+
ctx.util.retryOnceOnAuth = vi.fn(() => {
|
|
948
|
+
throw "retry failed"
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
const plugin = await loadPlugin()
|
|
952
|
+
expect(() => plugin.probe(ctx)).toThrow("retry failed")
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
it("skips malformed credit grants payload and still returns total usage", async () => {
|
|
956
|
+
const ctx = makeCtx()
|
|
957
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
958
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
959
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
960
|
+
return {
|
|
961
|
+
status: 200,
|
|
962
|
+
bodyText: JSON.stringify({
|
|
963
|
+
enabled: true,
|
|
964
|
+
planUsage: { totalSpend: 1200, limit: 2400 },
|
|
965
|
+
}),
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (String(opts.url).includes("GetCreditGrantsBalance")) {
|
|
969
|
+
return {
|
|
970
|
+
status: 200,
|
|
971
|
+
bodyText: JSON.stringify({
|
|
972
|
+
hasCreditGrants: true,
|
|
973
|
+
totalCents: "oops",
|
|
974
|
+
usedCents: "10",
|
|
975
|
+
}),
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
return { status: 200, bodyText: "{}" }
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
const plugin = await loadPlugin()
|
|
982
|
+
const result = plugin.probe(ctx)
|
|
983
|
+
expect(result.lines.find((line) => line.label === "Credits")).toBeUndefined()
|
|
984
|
+
expect(result.lines.find((line) => line.label === "Total usage")).toBeTruthy()
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
it("uses expired access token when refresh token is missing", async () => {
|
|
988
|
+
const ctx = makeCtx()
|
|
989
|
+
const expiredToken = makeJwt({ sub: "google-oauth2|user_abc123", exp: 1 })
|
|
990
|
+
ctx.host.sqlite.query.mockImplementation((db, sql) => {
|
|
991
|
+
if (String(sql).includes("cursorAuth/accessToken")) {
|
|
992
|
+
return JSON.stringify([{ value: expiredToken }])
|
|
993
|
+
}
|
|
994
|
+
if (String(sql).includes("cursorAuth/refreshToken")) return JSON.stringify([])
|
|
995
|
+
return JSON.stringify([])
|
|
996
|
+
})
|
|
997
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
998
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
999
|
+
return {
|
|
1000
|
+
status: 200,
|
|
1001
|
+
bodyText: JSON.stringify({
|
|
1002
|
+
enabled: true,
|
|
1003
|
+
planUsage: { totalSpend: 0, limit: 100 },
|
|
1004
|
+
}),
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return { status: 200, bodyText: "{}" }
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
const plugin = await loadPlugin()
|
|
1011
|
+
expect(() => plugin.probe(ctx)).not.toThrow()
|
|
1012
|
+
})
|
|
1013
|
+
|
|
1014
|
+
it("throws enterprise unavailable when sub resolves to empty user id", async () => {
|
|
1015
|
+
const ctx = makeCtx()
|
|
1016
|
+
const accessToken = makeJwt({ sub: "google-oauth2|", exp: 9999999999 })
|
|
1017
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: accessToken }]))
|
|
1018
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1019
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
1020
|
+
return { status: 200, bodyText: JSON.stringify({ billingCycleStart: "1770539602363" }) }
|
|
1021
|
+
}
|
|
1022
|
+
if (String(opts.url).includes("GetPlanInfo")) {
|
|
1023
|
+
return { status: 200, bodyText: JSON.stringify({ planInfo: { planName: "Enterprise" } }) }
|
|
1024
|
+
}
|
|
1025
|
+
return { status: 200, bodyText: "{}" }
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
const plugin = await loadPlugin()
|
|
1029
|
+
expect(() => plugin.probe(ctx)).toThrow("Enterprise usage data unavailable")
|
|
1030
|
+
})
|
|
1031
|
+
|
|
1032
|
+
it("uses zero included requests when enterprise usage omits numRequests", async () => {
|
|
1033
|
+
const ctx = makeCtx()
|
|
1034
|
+
const accessToken = makeJwt({ sub: "google-oauth2|user_abc123", exp: 9999999999 })
|
|
1035
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: accessToken }]))
|
|
1036
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1037
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
1038
|
+
return {
|
|
1039
|
+
status: 200,
|
|
1040
|
+
bodyText: JSON.stringify({
|
|
1041
|
+
billingCycleStart: "1770539602363",
|
|
1042
|
+
billingCycleEnd: "1770539602363",
|
|
1043
|
+
}),
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
if (String(opts.url).includes("GetPlanInfo")) {
|
|
1047
|
+
return {
|
|
1048
|
+
status: 200,
|
|
1049
|
+
bodyText: JSON.stringify({
|
|
1050
|
+
planInfo: { planName: "Enterprise" },
|
|
1051
|
+
}),
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
if (String(opts.url).includes("cursor.com/api/usage")) {
|
|
1055
|
+
return {
|
|
1056
|
+
status: 200,
|
|
1057
|
+
bodyText: JSON.stringify({
|
|
1058
|
+
"gpt-4": {
|
|
1059
|
+
maxRequestUsage: 10,
|
|
1060
|
+
},
|
|
1061
|
+
}),
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return { status: 200, bodyText: "{}" }
|
|
1065
|
+
})
|
|
1066
|
+
|
|
1067
|
+
const plugin = await loadPlugin()
|
|
1068
|
+
const result = plugin.probe(ctx)
|
|
1069
|
+
const reqLine = result.lines.find((line) => line.label === "Requests")
|
|
1070
|
+
expect(reqLine).toBeTruthy()
|
|
1071
|
+
expect(reqLine.used).toBe(0)
|
|
1072
|
+
expect(reqLine.limit).toBe(10)
|
|
1073
|
+
})
|
|
1074
|
+
|
|
1075
|
+
it("throws enterprise unavailable when gpt-4 request limit is not positive", async () => {
|
|
1076
|
+
const ctx = makeCtx()
|
|
1077
|
+
const accessToken = makeJwt({ sub: "google-oauth2|user_abc123", exp: 9999999999 })
|
|
1078
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: accessToken }]))
|
|
1079
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1080
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
1081
|
+
return {
|
|
1082
|
+
status: 200,
|
|
1083
|
+
bodyText: JSON.stringify({
|
|
1084
|
+
billingCycleStart: "1770539602363",
|
|
1085
|
+
billingCycleEnd: "1770539602363",
|
|
1086
|
+
}),
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (String(opts.url).includes("GetPlanInfo")) {
|
|
1090
|
+
return {
|
|
1091
|
+
status: 200,
|
|
1092
|
+
bodyText: JSON.stringify({
|
|
1093
|
+
planInfo: { planName: "Enterprise" },
|
|
1094
|
+
}),
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
if (String(opts.url).includes("cursor.com/api/usage")) {
|
|
1098
|
+
return {
|
|
1099
|
+
status: 200,
|
|
1100
|
+
bodyText: JSON.stringify({
|
|
1101
|
+
"gpt-4": {
|
|
1102
|
+
numRequests: 42,
|
|
1103
|
+
maxRequestUsage: 0,
|
|
1104
|
+
},
|
|
1105
|
+
}),
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
return { status: 200, bodyText: "{}" }
|
|
1109
|
+
})
|
|
1110
|
+
|
|
1111
|
+
const plugin = await loadPlugin()
|
|
1112
|
+
expect(() => plugin.probe(ctx)).toThrow("Enterprise usage data unavailable")
|
|
1113
|
+
})
|
|
1114
|
+
|
|
1115
|
+
it("omits enterprise plan label when formatter returns null", async () => {
|
|
1116
|
+
const ctx = makeCtx()
|
|
1117
|
+
const accessToken = makeJwt({ sub: "google-oauth2|user_abc123", exp: 9999999999 })
|
|
1118
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: accessToken }]))
|
|
1119
|
+
ctx.fmt.planLabel = vi.fn(() => null)
|
|
1120
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1121
|
+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
|
|
1122
|
+
return {
|
|
1123
|
+
status: 200,
|
|
1124
|
+
bodyText: JSON.stringify({
|
|
1125
|
+
billingCycleStart: "1770539602363",
|
|
1126
|
+
billingCycleEnd: "1770539602363",
|
|
1127
|
+
}),
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
if (String(opts.url).includes("GetPlanInfo")) {
|
|
1131
|
+
return {
|
|
1132
|
+
status: 200,
|
|
1133
|
+
bodyText: JSON.stringify({
|
|
1134
|
+
planInfo: { planName: "Enterprise" },
|
|
1135
|
+
}),
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
if (String(opts.url).includes("cursor.com/api/usage")) {
|
|
1139
|
+
return {
|
|
1140
|
+
status: 200,
|
|
1141
|
+
bodyText: JSON.stringify({
|
|
1142
|
+
"gpt-4": {
|
|
1143
|
+
numRequests: 3,
|
|
1144
|
+
maxRequestUsage: 10,
|
|
1145
|
+
},
|
|
1146
|
+
}),
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
return { status: 200, bodyText: "{}" }
|
|
1150
|
+
})
|
|
1151
|
+
|
|
1152
|
+
const plugin = await loadPlugin()
|
|
1153
|
+
const result = plugin.probe(ctx)
|
|
1154
|
+
expect(result.plan).toBeNull()
|
|
1155
|
+
expect(result.lines.find((line) => line.label === "Requests")).toBeTruthy()
|
|
1156
|
+
})
|
|
1157
|
+
|
|
1158
|
+
it("wraps non-string retry wrapper errors as usage request failure", async () => {
|
|
1159
|
+
const ctx = makeCtx()
|
|
1160
|
+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
|
|
1161
|
+
ctx.util.retryOnceOnAuth = vi.fn(() => {
|
|
1162
|
+
throw new Error("wrapper blew up")
|
|
1163
|
+
})
|
|
1164
|
+
|
|
1165
|
+
const plugin = await loadPlugin()
|
|
1166
|
+
expect(() => plugin.probe(ctx)).toThrow("Usage request failed. Check your connection.")
|
|
1167
|
+
})
|
|
1168
|
+
})
|