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.
Files changed (58) hide show
  1. package/bin/openusage +91 -0
  2. package/package.json +33 -0
  3. package/plugins/amp/icon.svg +6 -0
  4. package/plugins/amp/plugin.js +175 -0
  5. package/plugins/amp/plugin.json +20 -0
  6. package/plugins/amp/plugin.test.js +365 -0
  7. package/plugins/antigravity/icon.svg +3 -0
  8. package/plugins/antigravity/plugin.js +484 -0
  9. package/plugins/antigravity/plugin.json +17 -0
  10. package/plugins/antigravity/plugin.test.js +1356 -0
  11. package/plugins/claude/icon.svg +3 -0
  12. package/plugins/claude/plugin.js +565 -0
  13. package/plugins/claude/plugin.json +28 -0
  14. package/plugins/claude/plugin.test.js +1012 -0
  15. package/plugins/codex/icon.svg +3 -0
  16. package/plugins/codex/plugin.js +673 -0
  17. package/plugins/codex/plugin.json +30 -0
  18. package/plugins/codex/plugin.test.js +1071 -0
  19. package/plugins/copilot/icon.svg +3 -0
  20. package/plugins/copilot/plugin.js +264 -0
  21. package/plugins/copilot/plugin.json +20 -0
  22. package/plugins/copilot/plugin.test.js +529 -0
  23. package/plugins/cursor/icon.svg +3 -0
  24. package/plugins/cursor/plugin.js +526 -0
  25. package/plugins/cursor/plugin.json +24 -0
  26. package/plugins/cursor/plugin.test.js +1168 -0
  27. package/plugins/factory/icon.svg +1 -0
  28. package/plugins/factory/plugin.js +407 -0
  29. package/plugins/factory/plugin.json +19 -0
  30. package/plugins/factory/plugin.test.js +833 -0
  31. package/plugins/gemini/icon.svg +4 -0
  32. package/plugins/gemini/plugin.js +413 -0
  33. package/plugins/gemini/plugin.json +20 -0
  34. package/plugins/gemini/plugin.test.js +735 -0
  35. package/plugins/jetbrains-ai-assistant/icon.svg +3 -0
  36. package/plugins/jetbrains-ai-assistant/plugin.js +357 -0
  37. package/plugins/jetbrains-ai-assistant/plugin.json +17 -0
  38. package/plugins/jetbrains-ai-assistant/plugin.test.js +338 -0
  39. package/plugins/kimi/icon.svg +3 -0
  40. package/plugins/kimi/plugin.js +358 -0
  41. package/plugins/kimi/plugin.json +19 -0
  42. package/plugins/kimi/plugin.test.js +619 -0
  43. package/plugins/minimax/icon.svg +4 -0
  44. package/plugins/minimax/plugin.js +388 -0
  45. package/plugins/minimax/plugin.json +17 -0
  46. package/plugins/minimax/plugin.test.js +943 -0
  47. package/plugins/perplexity/icon.svg +1 -0
  48. package/plugins/perplexity/plugin.js +378 -0
  49. package/plugins/perplexity/plugin.json +15 -0
  50. package/plugins/perplexity/plugin.test.js +602 -0
  51. package/plugins/windsurf/icon.svg +3 -0
  52. package/plugins/windsurf/plugin.js +218 -0
  53. package/plugins/windsurf/plugin.json +16 -0
  54. package/plugins/windsurf/plugin.test.js +455 -0
  55. package/plugins/zai/icon.svg +5 -0
  56. package/plugins/zai/plugin.js +156 -0
  57. package/plugins/zai/plugin.json +18 -0
  58. package/plugins/zai/plugin.test.js +396 -0
@@ -0,0 +1,1012 @@
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
+ describe("claude plugin", () => {
10
+ beforeEach(() => {
11
+ delete globalThis.__openusage_plugin
12
+ vi.resetModules()
13
+ })
14
+
15
+ it("throws when no credentials", async () => {
16
+ const ctx = makeCtx()
17
+ const plugin = await loadPlugin()
18
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
19
+ })
20
+
21
+ it("throws when credentials are unreadable", async () => {
22
+ const ctx = makeCtx()
23
+ ctx.host.fs.exists = () => true
24
+ ctx.host.fs.readText = () => "{bad json"
25
+ ctx.host.keychain.readGenericPassword.mockReturnValue("{bad}")
26
+ const plugin = await loadPlugin()
27
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
28
+ })
29
+
30
+ it("falls back to keychain when credentials file is corrupt", async () => {
31
+ const ctx = makeCtx()
32
+ ctx.host.fs.exists = () => true
33
+ ctx.host.fs.readText = () => "{bad json"
34
+ ctx.host.keychain.readGenericPassword.mockReturnValue(
35
+ JSON.stringify({ claudeAiOauth: { accessToken: "token", subscriptionType: "pro" } })
36
+ )
37
+ ctx.host.http.request.mockReturnValue({
38
+ status: 200,
39
+ bodyText: JSON.stringify({
40
+ five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" },
41
+ }),
42
+ })
43
+ const plugin = await loadPlugin()
44
+ const result = plugin.probe(ctx)
45
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
46
+ })
47
+
48
+ it("renders usage lines from response", async () => {
49
+ const ctx = makeCtx()
50
+ ctx.host.fs.readText = () =>
51
+ JSON.stringify({ claudeAiOauth: { accessToken: "token", subscriptionType: "pro" } })
52
+ ctx.host.fs.exists = () => true
53
+ ctx.host.http.request.mockReturnValue({
54
+ status: 200,
55
+ bodyText: JSON.stringify({
56
+ five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" },
57
+ seven_day: { utilization: 20, resets_at: "2099-01-01T00:00:00.000Z" },
58
+ extra_usage: { is_enabled: true, used_credits: 500, monthly_limit: 1000 },
59
+ }),
60
+ })
61
+
62
+ const plugin = await loadPlugin()
63
+ const result = plugin.probe(ctx)
64
+ expect(result.plan).toBeTruthy()
65
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
66
+ expect(result.lines.find((line) => line.label === "Weekly")).toBeTruthy()
67
+ })
68
+
69
+ it("omits resetsAt when resets_at is missing", async () => {
70
+ const ctx = makeCtx()
71
+ ctx.host.fs.readText = () =>
72
+ JSON.stringify({ claudeAiOauth: { accessToken: "token", subscriptionType: "pro" } })
73
+ ctx.host.fs.exists = () => true
74
+ ctx.host.http.request.mockReturnValue({
75
+ status: 200,
76
+ bodyText: JSON.stringify({
77
+ five_hour: { utilization: 0 },
78
+ }),
79
+ })
80
+
81
+ const plugin = await loadPlugin()
82
+ const result = plugin.probe(ctx)
83
+ const sessionLine = result.lines.find((line) => line.label === "Session")
84
+ expect(sessionLine).toBeTruthy()
85
+ expect(sessionLine.resetsAt).toBeUndefined()
86
+ })
87
+
88
+ it("throws token expired on 401", async () => {
89
+ const ctx = makeCtx()
90
+ ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: { accessToken: "token" } })
91
+ ctx.host.fs.exists = () => true
92
+ ctx.host.http.request.mockReturnValue({ status: 401, bodyText: "" })
93
+ const plugin = await loadPlugin()
94
+ expect(() => plugin.probe(ctx)).toThrow("Token expired")
95
+ })
96
+
97
+ it("uses keychain credentials", async () => {
98
+ const ctx = makeCtx()
99
+ ctx.host.fs.exists = () => false
100
+ ctx.host.keychain.readGenericPassword.mockReturnValue(
101
+ JSON.stringify({ claudeAiOauth: { accessToken: "token", subscriptionType: "pro" } })
102
+ )
103
+ ctx.host.http.request.mockReturnValue({
104
+ status: 200,
105
+ bodyText: JSON.stringify({
106
+ seven_day_sonnet: { utilization: 5, resets_at: "2099-01-01T00:00:00.000Z" },
107
+ extra_usage: { is_enabled: true, used_credits: 250 },
108
+ }),
109
+ })
110
+ const plugin = await loadPlugin()
111
+ const result = plugin.probe(ctx)
112
+ expect(result.lines.find((line) => line.label === "Sonnet")).toBeTruthy()
113
+ expect(result.lines.find((line) => line.label === "Extra usage spent")).toBeTruthy()
114
+ })
115
+
116
+ it("uses keychain credentials when value is hex-encoded JSON", async () => {
117
+ const ctx = makeCtx()
118
+ ctx.host.fs.exists = () => false
119
+ const json = JSON.stringify({ claudeAiOauth: { accessToken: "token", subscriptionType: "pro" } }, null, 2)
120
+ const hex = Buffer.from(json, "utf8").toString("hex")
121
+ ctx.host.keychain.readGenericPassword.mockReturnValue(hex)
122
+ ctx.host.http.request.mockReturnValue({
123
+ status: 200,
124
+ bodyText: JSON.stringify({
125
+ five_hour: { utilization: 1, resets_at: "2099-01-01T00:00:00.000Z" },
126
+ }),
127
+ })
128
+ const plugin = await loadPlugin()
129
+ const result = plugin.probe(ctx)
130
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
131
+ })
132
+
133
+ it("accepts 0x-prefixed hex keychain credentials", async () => {
134
+ const ctx = makeCtx()
135
+ ctx.host.fs.exists = () => false
136
+ const json = JSON.stringify({ claudeAiOauth: { accessToken: "token", subscriptionType: "pro" } }, null, 2)
137
+ const hex = "0x" + Buffer.from(json, "utf8").toString("hex")
138
+ ctx.host.keychain.readGenericPassword.mockReturnValue(hex)
139
+ ctx.host.http.request.mockReturnValue({
140
+ status: 200,
141
+ bodyText: JSON.stringify({
142
+ five_hour: { utilization: 1, resets_at: "2099-01-01T00:00:00.000Z" },
143
+ }),
144
+ })
145
+ const plugin = await loadPlugin()
146
+ const result = plugin.probe(ctx)
147
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
148
+ })
149
+
150
+ it("decodes hex-encoded UTF-8 correctly (non-ascii json)", async () => {
151
+ const ctx = makeCtx()
152
+ ctx.host.fs.exists = () => false
153
+ const json = JSON.stringify({ claudeAiOauth: { accessToken: "token", subscriptionType: "pró" } }, null, 2)
154
+ const hex = Buffer.from(json, "utf8").toString("hex")
155
+ ctx.host.keychain.readGenericPassword.mockReturnValue(hex)
156
+ ctx.host.http.request.mockReturnValue({
157
+ status: 200,
158
+ bodyText: JSON.stringify({
159
+ five_hour: { utilization: 1, resets_at: "2099-01-01T00:00:00.000Z" },
160
+ }),
161
+ })
162
+ const plugin = await loadPlugin()
163
+ expect(() => plugin.probe(ctx)).not.toThrow()
164
+ })
165
+
166
+ it("decodes 3-byte and 4-byte UTF-8 in hex-encoded JSON", async () => {
167
+ const ctx = makeCtx()
168
+ ctx.host.fs.exists = () => false
169
+ const json = JSON.stringify(
170
+ { claudeAiOauth: { accessToken: "token", subscriptionType: "pro€🙂" } },
171
+ null,
172
+ 2
173
+ )
174
+ const hex = Buffer.from(json, "utf8").toString("hex")
175
+ ctx.host.keychain.readGenericPassword.mockReturnValue(hex)
176
+ ctx.host.http.request.mockReturnValue({
177
+ status: 200,
178
+ bodyText: JSON.stringify({
179
+ five_hour: { utilization: 1, resets_at: "2099-01-01T00:00:00.000Z" },
180
+ }),
181
+ })
182
+ const plugin = await loadPlugin()
183
+ expect(() => plugin.probe(ctx)).not.toThrow()
184
+ })
185
+
186
+ it("uses custom UTF-8 decoder when TextDecoder is unavailable", async () => {
187
+ const original = globalThis.TextDecoder
188
+ // Force plugin to use its fallback decoder.
189
+ // eslint-disable-next-line no-undef
190
+ delete globalThis.TextDecoder
191
+ try {
192
+ const ctx = makeCtx()
193
+ ctx.host.fs.exists = () => false
194
+ const json = JSON.stringify(
195
+ { claudeAiOauth: { accessToken: "token", subscriptionType: "pró€🙂" } },
196
+ null,
197
+ 2
198
+ )
199
+ const hex = Buffer.from(json, "utf8").toString("hex")
200
+ ctx.host.keychain.readGenericPassword.mockReturnValue(hex)
201
+ ctx.host.http.request.mockReturnValue({
202
+ status: 200,
203
+ bodyText: JSON.stringify({
204
+ five_hour: { utilization: 1, resets_at: "2099-01-01T00:00:00.000Z" },
205
+ }),
206
+ })
207
+ const plugin = await loadPlugin()
208
+ expect(() => plugin.probe(ctx)).not.toThrow()
209
+ } finally {
210
+ globalThis.TextDecoder = original
211
+ }
212
+ })
213
+
214
+ it("custom decoder tolerates invalid byte sequences", async () => {
215
+ const original = globalThis.TextDecoder
216
+ // eslint-disable-next-line no-undef
217
+ delete globalThis.TextDecoder
218
+ try {
219
+ const ctx = makeCtx()
220
+ ctx.host.fs.exists = () => false
221
+ // Invalid UTF-8 bytes (will produce replacement chars).
222
+ ctx.host.keychain.readGenericPassword.mockReturnValue("c200ff")
223
+ const plugin = await loadPlugin()
224
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
225
+ } finally {
226
+ globalThis.TextDecoder = original
227
+ }
228
+ })
229
+
230
+ it("treats invalid hex credentials as not logged in", async () => {
231
+ const ctx = makeCtx()
232
+ ctx.host.fs.exists = () => false
233
+ ctx.host.keychain.readGenericPassword.mockReturnValue("0x123") // odd length
234
+ const plugin = await loadPlugin()
235
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
236
+ })
237
+
238
+ it("throws on http errors and parse failures", async () => {
239
+ const ctx = makeCtx()
240
+ ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: { accessToken: "token" } })
241
+ ctx.host.fs.exists = () => true
242
+ ctx.host.http.request.mockReturnValueOnce({ status: 500, bodyText: "" })
243
+ const plugin = await loadPlugin()
244
+ expect(() => plugin.probe(ctx)).toThrow("HTTP 500")
245
+
246
+ ctx.host.http.request.mockReturnValueOnce({ status: 200, bodyText: "not-json" })
247
+ expect(() => plugin.probe(ctx)).toThrow("Usage response invalid")
248
+ })
249
+
250
+ it("throws on request errors", async () => {
251
+ const ctx = makeCtx()
252
+ ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: { accessToken: "token" } })
253
+ ctx.host.fs.exists = () => true
254
+ ctx.host.http.request.mockImplementation(() => {
255
+ throw new Error("boom")
256
+ })
257
+ const plugin = await loadPlugin()
258
+ expect(() => plugin.probe(ctx)).toThrow("Usage request failed")
259
+ })
260
+
261
+ it("shows status badge when no usage data and ccusage is unavailable", async () => {
262
+ const ctx = makeCtx()
263
+ ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: { accessToken: "token" } })
264
+ ctx.host.fs.exists = () => true
265
+ ctx.host.http.request.mockReturnValue({
266
+ status: 200,
267
+ bodyText: JSON.stringify({}),
268
+ })
269
+ const plugin = await loadPlugin()
270
+ const result = plugin.probe(ctx)
271
+ expect(result.lines.find((l) => l.label === "Today")).toBeUndefined()
272
+ expect(result.lines.find((l) => l.label === "Yesterday")).toBeUndefined()
273
+ expect(result.lines.find((l) => l.label === "Last 30 Days")).toBeUndefined()
274
+ const statusLine = result.lines.find((l) => l.label === "Status")
275
+ expect(statusLine).toBeTruthy()
276
+ expect(statusLine.text).toBe("No usage data")
277
+ })
278
+
279
+ it("passes resetsAt through as ISO when present", async () => {
280
+ const ctx = makeCtx()
281
+ ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: { accessToken: "token" } })
282
+ ctx.host.fs.exists = () => true
283
+ const now = new Date("2026-02-02T00:00:00.000Z").getTime()
284
+ const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now)
285
+ const fiveHourIso = new Date(now + 30_000).toISOString()
286
+ const sevenDayIso = new Date(now + 5 * 60_000).toISOString()
287
+ ctx.host.http.request.mockReturnValue({
288
+ status: 200,
289
+ bodyText: JSON.stringify({
290
+ five_hour: { utilization: 10, resets_at: fiveHourIso },
291
+ seven_day: { utilization: 20, resets_at: sevenDayIso },
292
+ }),
293
+ })
294
+ const plugin = await loadPlugin()
295
+ const result = plugin.probe(ctx)
296
+ expect(result.lines.find((line) => line.label === "Session")?.resetsAt).toBe(fiveHourIso)
297
+ expect(result.lines.find((line) => line.label === "Weekly")?.resetsAt).toBe(sevenDayIso)
298
+ nowSpy.mockRestore()
299
+ })
300
+
301
+ it("normalizes resets_at without timezone (microseconds) into ISO for resetsAt", async () => {
302
+ const ctx = makeCtx()
303
+ ctx.host.fs.readText = () =>
304
+ JSON.stringify({ claudeAiOauth: { accessToken: "token", subscriptionType: "pro" } })
305
+ ctx.host.fs.exists = () => true
306
+ ctx.host.http.request.mockReturnValue({
307
+ status: 200,
308
+ bodyText: JSON.stringify({
309
+ five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.123456" },
310
+ }),
311
+ })
312
+ const plugin = await loadPlugin()
313
+ const result = plugin.probe(ctx)
314
+ expect(result.lines.find((line) => line.label === "Session")?.resetsAt).toBe(
315
+ "2099-01-01T00:00:00.123Z"
316
+ )
317
+ })
318
+
319
+ it("refreshes token when expired and persists updated credentials", async () => {
320
+ const ctx = makeCtx()
321
+ ctx.host.fs.exists = () => true
322
+ ctx.host.fs.readText = () =>
323
+ JSON.stringify({
324
+ claudeAiOauth: {
325
+ accessToken: "old-token",
326
+ refreshToken: "refresh",
327
+ expiresAt: Date.now() - 1000,
328
+ subscriptionType: "pro",
329
+ },
330
+ })
331
+
332
+ ctx.host.http.request.mockImplementation((opts) => {
333
+ if (String(opts.url).includes("/v1/oauth/token")) {
334
+ return {
335
+ status: 200,
336
+ bodyText: JSON.stringify({ access_token: "new-token", expires_in: 3600, refresh_token: "refresh2" }),
337
+ }
338
+ }
339
+ return {
340
+ status: 200,
341
+ bodyText: JSON.stringify({
342
+ five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" },
343
+ }),
344
+ }
345
+ })
346
+
347
+ const plugin = await loadPlugin()
348
+ const result = plugin.probe(ctx)
349
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
350
+ expect(ctx.host.fs.writeText).toHaveBeenCalled()
351
+ })
352
+
353
+ it("refreshes keychain credentials and writes back to keychain", async () => {
354
+ const ctx = makeCtx()
355
+ ctx.host.fs.exists = () => false
356
+ ctx.host.keychain.readGenericPassword.mockReturnValue(
357
+ JSON.stringify({
358
+ claudeAiOauth: {
359
+ accessToken: "old-token",
360
+ refreshToken: "refresh",
361
+ expiresAt: Date.now() - 1000,
362
+ subscriptionType: "pro",
363
+ },
364
+ })
365
+ )
366
+
367
+ ctx.host.http.request.mockImplementation((opts) => {
368
+ if (String(opts.url).includes("/v1/oauth/token")) {
369
+ return {
370
+ status: 200,
371
+ bodyText: JSON.stringify({ access_token: "new-token", expires_in: 3600 }),
372
+ }
373
+ }
374
+ return {
375
+ status: 200,
376
+ bodyText: JSON.stringify({
377
+ five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" },
378
+ }),
379
+ }
380
+ })
381
+
382
+ const plugin = await loadPlugin()
383
+ expect(() => plugin.probe(ctx)).not.toThrow()
384
+ expect(ctx.host.keychain.writeGenericPassword).toHaveBeenCalled()
385
+ })
386
+
387
+ it("retries usage request after 401 by refreshing once", async () => {
388
+ const ctx = makeCtx()
389
+ ctx.host.fs.exists = () => true
390
+ ctx.host.fs.readText = () =>
391
+ JSON.stringify({
392
+ claudeAiOauth: {
393
+ accessToken: "token",
394
+ refreshToken: "refresh",
395
+ expiresAt: Date.now() + 60_000,
396
+ subscriptionType: "pro",
397
+ },
398
+ })
399
+
400
+ let usageCalls = 0
401
+ ctx.host.http.request.mockImplementation((opts) => {
402
+ if (String(opts.url).includes("/api/oauth/usage")) {
403
+ usageCalls += 1
404
+ if (usageCalls === 1) {
405
+ return { status: 401, bodyText: "" }
406
+ }
407
+ return {
408
+ status: 200,
409
+ bodyText: JSON.stringify({
410
+ five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" },
411
+ }),
412
+ }
413
+ }
414
+ // Refresh
415
+ return {
416
+ status: 200,
417
+ bodyText: JSON.stringify({ access_token: "token2", expires_in: 3600 }),
418
+ }
419
+ })
420
+
421
+ const plugin = await loadPlugin()
422
+ const result = plugin.probe(ctx)
423
+ expect(usageCalls).toBe(2)
424
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
425
+ })
426
+
427
+ it("throws session expired when refresh returns invalid_grant", async () => {
428
+ const ctx = makeCtx()
429
+ ctx.host.fs.exists = () => true
430
+ ctx.host.fs.readText = () =>
431
+ JSON.stringify({
432
+ claudeAiOauth: {
433
+ accessToken: "token",
434
+ refreshToken: "refresh",
435
+ expiresAt: Date.now() - 1,
436
+ },
437
+ })
438
+
439
+ ctx.host.http.request.mockImplementation((opts) => {
440
+ if (String(opts.url).includes("/v1/oauth/token")) {
441
+ return { status: 400, bodyText: JSON.stringify({ error: "invalid_grant" }) }
442
+ }
443
+ return { status: 500, bodyText: "" }
444
+ })
445
+
446
+ const plugin = await loadPlugin()
447
+ expect(() => plugin.probe(ctx)).toThrow("Session expired")
448
+ })
449
+
450
+ it("throws token expired when usage remains unauthorized after refresh", async () => {
451
+ const ctx = makeCtx()
452
+ ctx.host.fs.exists = () => true
453
+ ctx.host.fs.readText = () =>
454
+ JSON.stringify({
455
+ claudeAiOauth: {
456
+ accessToken: "token",
457
+ refreshToken: "refresh",
458
+ expiresAt: Date.now() + 60_000,
459
+ },
460
+ })
461
+
462
+ let usageCalls = 0
463
+ ctx.host.http.request.mockImplementation((opts) => {
464
+ if (String(opts.url).includes("/api/oauth/usage")) {
465
+ usageCalls += 1
466
+ if (usageCalls === 1) return { status: 401, bodyText: "" }
467
+ return { status: 403, bodyText: "" }
468
+ }
469
+ return { status: 200, bodyText: JSON.stringify({ access_token: "token2", expires_in: 3600 }) }
470
+ })
471
+
472
+ const plugin = await loadPlugin()
473
+ expect(() => plugin.probe(ctx)).toThrow("Token expired")
474
+ })
475
+
476
+ it("throws token expired when refresh is unauthorized", async () => {
477
+ const ctx = makeCtx()
478
+ ctx.host.fs.exists = () => true
479
+ ctx.host.fs.readText = () =>
480
+ JSON.stringify({
481
+ claudeAiOauth: {
482
+ accessToken: "token",
483
+ refreshToken: "refresh",
484
+ expiresAt: Date.now() - 1,
485
+ },
486
+ })
487
+
488
+ ctx.host.http.request.mockImplementation((opts) => {
489
+ if (String(opts.url).includes("/v1/oauth/token")) {
490
+ return { status: 401, bodyText: JSON.stringify({ error: "nope" }) }
491
+ }
492
+ return { status: 500, bodyText: "" }
493
+ })
494
+
495
+ const plugin = await loadPlugin()
496
+ expect(() => plugin.probe(ctx)).toThrow("Token expired")
497
+ })
498
+
499
+ it("logs when saving keychain credentials fails", async () => {
500
+ const ctx = makeCtx()
501
+ ctx.host.fs.exists = () => false
502
+ ctx.host.keychain.readGenericPassword.mockReturnValue(
503
+ JSON.stringify({
504
+ claudeAiOauth: {
505
+ accessToken: "old-token",
506
+ refreshToken: "refresh",
507
+ expiresAt: Date.now() - 1000,
508
+ },
509
+ })
510
+ )
511
+ ctx.host.keychain.writeGenericPassword.mockImplementation(() => {
512
+ throw new Error("write fail")
513
+ })
514
+ ctx.host.http.request.mockImplementation((opts) => {
515
+ if (String(opts.url).includes("/v1/oauth/token")) {
516
+ return { status: 200, bodyText: JSON.stringify({ access_token: "new-token", expires_in: 3600 }) }
517
+ }
518
+ return {
519
+ status: 200,
520
+ bodyText: JSON.stringify({
521
+ five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" },
522
+ }),
523
+ }
524
+ })
525
+ const plugin = await loadPlugin()
526
+ expect(() => plugin.probe(ctx)).not.toThrow()
527
+ expect(ctx.host.log.error).toHaveBeenCalled()
528
+ })
529
+
530
+ it("logs when saving credentials file fails", async () => {
531
+ const ctx = makeCtx()
532
+ ctx.host.fs.exists = () => true
533
+ ctx.host.fs.readText = () =>
534
+ JSON.stringify({
535
+ claudeAiOauth: {
536
+ accessToken: "old-token",
537
+ refreshToken: "refresh",
538
+ expiresAt: Date.now() - 1000,
539
+ },
540
+ })
541
+ ctx.host.fs.writeText.mockImplementation(() => {
542
+ throw new Error("disk full")
543
+ })
544
+ ctx.host.http.request.mockImplementation((opts) => {
545
+ if (String(opts.url).includes("/v1/oauth/token")) {
546
+ return { status: 200, bodyText: JSON.stringify({ access_token: "new-token", expires_in: 3600 }) }
547
+ }
548
+ return {
549
+ status: 200,
550
+ bodyText: JSON.stringify({
551
+ five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" },
552
+ }),
553
+ }
554
+ })
555
+ const plugin = await loadPlugin()
556
+ expect(() => plugin.probe(ctx)).not.toThrow()
557
+ expect(ctx.host.log.error).toHaveBeenCalled()
558
+ })
559
+
560
+ it("continues when refresh request throws non-string error (returns null)", async () => {
561
+ const ctx = makeCtx()
562
+ ctx.host.fs.exists = () => true
563
+ ctx.host.fs.readText = () =>
564
+ JSON.stringify({
565
+ claudeAiOauth: {
566
+ accessToken: "token",
567
+ refreshToken: "refresh",
568
+ expiresAt: Date.now() - 1,
569
+ },
570
+ })
571
+
572
+ ctx.host.http.request.mockImplementation((opts) => {
573
+ if (String(opts.url).includes("/v1/oauth/token")) {
574
+ throw new Error("network")
575
+ }
576
+ return {
577
+ status: 200,
578
+ bodyText: JSON.stringify({
579
+ five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" },
580
+ }),
581
+ }
582
+ })
583
+
584
+ const plugin = await loadPlugin()
585
+ expect(() => plugin.probe(ctx)).not.toThrow()
586
+ })
587
+
588
+ it("falls back to keychain when file oauth exists but has no access token", async () => {
589
+ const ctx = makeCtx()
590
+ ctx.host.fs.exists = () => true
591
+ ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: { refreshToken: "only-refresh" } })
592
+ ctx.host.keychain.readGenericPassword.mockReturnValue(
593
+ JSON.stringify({ claudeAiOauth: { accessToken: "keychain-token", subscriptionType: "pro" } })
594
+ )
595
+ ctx.host.http.request.mockReturnValue({
596
+ status: 200,
597
+ bodyText: JSON.stringify({
598
+ five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" },
599
+ }),
600
+ })
601
+
602
+ const plugin = await loadPlugin()
603
+ const result = plugin.probe(ctx)
604
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
605
+ })
606
+
607
+ it("treats keychain oauth without access token as not logged in", async () => {
608
+ const ctx = makeCtx()
609
+ ctx.host.fs.exists = () => false
610
+ ctx.host.keychain.readGenericPassword.mockReturnValue(
611
+ JSON.stringify({ claudeAiOauth: { refreshToken: "only-refresh" } })
612
+ )
613
+ const plugin = await loadPlugin()
614
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
615
+ })
616
+
617
+ it("continues with existing token when refresh cannot return a usable token", async () => {
618
+ const baseCreds = JSON.stringify({
619
+ claudeAiOauth: {
620
+ accessToken: "token",
621
+ refreshToken: "refresh",
622
+ expiresAt: Date.now() - 1,
623
+ },
624
+ })
625
+
626
+ const runCase = async (refreshResp) => {
627
+ const ctx = makeCtx()
628
+ ctx.host.fs.exists = () => true
629
+ ctx.host.fs.readText = () => baseCreds
630
+ ctx.host.http.request.mockImplementation((opts) => {
631
+ if (String(opts.url).includes("/v1/oauth/token")) return refreshResp
632
+ return {
633
+ status: 200,
634
+ bodyText: JSON.stringify({
635
+ five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" },
636
+ }),
637
+ }
638
+ })
639
+
640
+ delete globalThis.__openusage_plugin
641
+ vi.resetModules()
642
+ const plugin = await loadPlugin()
643
+ const result = plugin.probe(ctx)
644
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
645
+ }
646
+
647
+ await runCase({ status: 500, bodyText: "" })
648
+ await runCase({ status: 200, bodyText: "not-json" })
649
+ await runCase({ status: 200, bodyText: JSON.stringify({}) })
650
+ })
651
+
652
+ it("skips proactive refresh when token is not near expiry", async () => {
653
+ const ctx = makeCtx()
654
+ const now = 1_700_000_000_000
655
+ vi.spyOn(Date, "now").mockReturnValue(now)
656
+ ctx.host.fs.exists = () => true
657
+ ctx.host.fs.readText = () =>
658
+ JSON.stringify({
659
+ claudeAiOauth: {
660
+ accessToken: "token",
661
+ refreshToken: "refresh",
662
+ expiresAt: now + 24 * 60 * 60 * 1000,
663
+ subscriptionType: "pro",
664
+ },
665
+ })
666
+ ctx.host.http.request.mockReturnValue({
667
+ status: 200,
668
+ bodyText: JSON.stringify({
669
+ five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" },
670
+ }),
671
+ })
672
+
673
+ const plugin = await loadPlugin()
674
+ plugin.probe(ctx)
675
+ expect(
676
+ ctx.host.http.request.mock.calls.some((call) => String(call[0]?.url).includes("/v1/oauth/token"))
677
+ ).toBe(false)
678
+ })
679
+
680
+ it("handles malformed ccusage payload shape as runner_failed", async () => {
681
+ const ctx = makeCtx()
682
+ ctx.host.fs.exists = () => true
683
+ ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: { accessToken: "token", subscriptionType: " " } })
684
+ ctx.host.http.request.mockReturnValue({
685
+ status: 200,
686
+ bodyText: JSON.stringify({
687
+ five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" },
688
+ }),
689
+ })
690
+ ctx.host.ccusage.query = vi.fn(() => ({ status: "ok", data: {} }))
691
+
692
+ const plugin = await loadPlugin()
693
+ const result = plugin.probe(ctx)
694
+ expect(result.plan).toBeNull()
695
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
696
+ expect(result.lines.find((line) => line.label === "Today")).toBeUndefined()
697
+ })
698
+
699
+ it("throws usage request failed after refresh when retry errors", async () => {
700
+ const ctx = makeCtx()
701
+ ctx.host.fs.exists = () => true
702
+ ctx.host.fs.readText = () =>
703
+ JSON.stringify({
704
+ claudeAiOauth: {
705
+ accessToken: "token",
706
+ refreshToken: "refresh",
707
+ expiresAt: Date.now() + 60_000,
708
+ },
709
+ })
710
+
711
+ let usageCalls = 0
712
+ ctx.host.http.request.mockImplementation((opts) => {
713
+ if (String(opts.url).includes("/api/oauth/usage")) {
714
+ usageCalls += 1
715
+ if (usageCalls === 1) return { status: 401, bodyText: "" }
716
+ throw new Error("boom")
717
+ }
718
+ return { status: 200, bodyText: JSON.stringify({ access_token: "token2", expires_in: 3600 }) }
719
+ })
720
+
721
+ const plugin = await loadPlugin()
722
+ expect(() => plugin.probe(ctx)).toThrow("Usage request failed after refresh")
723
+ })
724
+
725
+ it("throws token expired when refresh response cannot be parsed", async () => {
726
+ const ctx = makeCtx()
727
+ ctx.host.fs.exists = () => true
728
+ ctx.host.fs.readText = () =>
729
+ JSON.stringify({
730
+ claudeAiOauth: {
731
+ accessToken: "token",
732
+ refreshToken: "refresh",
733
+ expiresAt: Date.now() - 1,
734
+ },
735
+ })
736
+
737
+ ctx.host.http.request.mockImplementation((opts) => {
738
+ if (String(opts.url).includes("/v1/oauth/token")) {
739
+ return { status: 400, bodyText: "not-json" }
740
+ }
741
+ return { status: 500, bodyText: "" }
742
+ })
743
+
744
+ const plugin = await loadPlugin()
745
+ expect(() => plugin.probe(ctx)).toThrow("Token expired")
746
+ })
747
+
748
+ describe("token usage: ccusage integration", () => {
749
+ const CRED_JSON = JSON.stringify({ claudeAiOauth: { accessToken: "tok", subscriptionType: "pro" } })
750
+ const USAGE_RESPONSE = JSON.stringify({
751
+ five_hour: { utilization: 30, resets_at: "2099-01-01T00:00:00.000Z" },
752
+ seven_day: { utilization: 50, resets_at: "2099-01-01T00:00:00.000Z" },
753
+ })
754
+
755
+ function makeProbeCtx({ ccusageResult = { status: "runner_failed" } } = {}) {
756
+ const ctx = makeCtx()
757
+ ctx.host.fs.exists = () => true
758
+ ctx.host.fs.readText = () => CRED_JSON
759
+ ctx.host.http.request.mockReturnValue({ status: 200, bodyText: USAGE_RESPONSE })
760
+ ctx.host.ccusage.query = vi.fn(() => ccusageResult)
761
+ return ctx
762
+ }
763
+
764
+ function okUsage(daily) {
765
+ return { status: "ok", data: { daily: daily } }
766
+ }
767
+
768
+ function localDayKey(date) {
769
+ const year = date.getFullYear()
770
+ const month = String(date.getMonth() + 1).padStart(2, "0")
771
+ const day = String(date.getDate()).padStart(2, "0")
772
+ return year + "-" + month + "-" + day
773
+ }
774
+
775
+ function localCompactDayKey(date) {
776
+ const year = String(date.getFullYear())
777
+ const month = String(date.getMonth() + 1).padStart(2, "0")
778
+ const day = String(date.getDate()).padStart(2, "0")
779
+ return year + month + day
780
+ }
781
+
782
+ it("omits token lines when ccusage reports no_runner", async () => {
783
+ const ctx = makeProbeCtx({ ccusageResult: { status: "no_runner" } })
784
+ const plugin = await loadPlugin()
785
+ const result = plugin.probe(ctx)
786
+ expect(result.lines.find((l) => l.label === "Today")).toBeUndefined()
787
+ expect(result.lines.find((l) => l.label === "Yesterday")).toBeUndefined()
788
+ expect(result.lines.find((l) => l.label === "Last 30 Days")).toBeUndefined()
789
+ })
790
+
791
+ it("rate-limit lines still appear when ccusage reports runner_failed", async () => {
792
+ const ctx = makeProbeCtx({ ccusageResult: { status: "runner_failed" } })
793
+ const plugin = await loadPlugin()
794
+ const result = plugin.probe(ctx)
795
+ expect(result.lines.find((l) => l.label === "Session")).toBeTruthy()
796
+ expect(result.lines.find((l) => l.label === "Today")).toBeUndefined()
797
+ expect(result.lines.find((l) => l.label === "Yesterday")).toBeUndefined()
798
+ })
799
+
800
+ it("adds Today line when ccusage returns today's data", async () => {
801
+ const todayKey = localDayKey(new Date())
802
+ const ctx = makeProbeCtx({
803
+ ccusageResult: okUsage([
804
+ { date: todayKey, inputTokens: 100, outputTokens: 50, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 150, totalCost: 0.75 },
805
+ ]),
806
+ })
807
+ const plugin = await loadPlugin()
808
+ const result = plugin.probe(ctx)
809
+ const todayLine = result.lines.find((l) => l.label === "Today")
810
+ expect(todayLine).toBeTruthy()
811
+ expect(todayLine.type).toBe("text")
812
+ expect(todayLine.value).toContain("150 tokens")
813
+ expect(todayLine.value).toContain("$0.75")
814
+ })
815
+
816
+ it("adds Yesterday line when ccusage returns yesterday's data", async () => {
817
+ const yesterday = new Date()
818
+ yesterday.setDate(yesterday.getDate() - 1)
819
+ const yesterdayKey = localDayKey(yesterday)
820
+ const ctx = makeProbeCtx({
821
+ ccusageResult: okUsage([
822
+ { date: yesterdayKey, inputTokens: 80, outputTokens: 40, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 120, totalCost: 0.6 },
823
+ ]),
824
+ })
825
+ const plugin = await loadPlugin()
826
+ const result = plugin.probe(ctx)
827
+ const yesterdayLine = result.lines.find((l) => l.label === "Yesterday")
828
+ expect(yesterdayLine).toBeTruthy()
829
+ expect(yesterdayLine.value).toContain("120 tokens")
830
+ expect(yesterdayLine.value).toContain("$0.60")
831
+ })
832
+
833
+ it("matches locale-formatted dates for today and yesterday (regression)", async () => {
834
+ const now = new Date()
835
+ const monthToday = now.toLocaleString("en-US", { month: "short" })
836
+ const dayToday = String(now.getDate()).padStart(2, "0")
837
+ const yearToday = now.getFullYear()
838
+ const todayLabel = monthToday + " " + dayToday + ", " + yearToday
839
+
840
+ const yesterday = new Date(now.getTime())
841
+ yesterday.setDate(yesterday.getDate() - 1)
842
+ const monthYesterday = yesterday.toLocaleString("en-US", { month: "short" })
843
+ const dayYesterday = String(yesterday.getDate()).padStart(2, "0")
844
+ const yearYesterday = yesterday.getFullYear()
845
+ const yesterdayLabel = monthYesterday + " " + dayYesterday + ", " + yearYesterday
846
+
847
+ const ctx = makeProbeCtx({
848
+ ccusageResult: okUsage([
849
+ { date: todayLabel, inputTokens: 100, outputTokens: 50, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 150, totalCost: 0.75 },
850
+ { date: yesterdayLabel, inputTokens: 80, outputTokens: 40, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 120, totalCost: 0.6 },
851
+ ]),
852
+ })
853
+
854
+ const plugin = await loadPlugin()
855
+ const result = plugin.probe(ctx)
856
+
857
+ const todayLine = result.lines.find((l) => l.label === "Today")
858
+ expect(todayLine).toBeTruthy()
859
+ expect(todayLine.value).toContain("150 tokens")
860
+ expect(todayLine.value).toContain("$0.75")
861
+
862
+ const yesterdayLine = result.lines.find((l) => l.label === "Yesterday")
863
+ expect(yesterdayLine).toBeTruthy()
864
+ expect(yesterdayLine.value).toContain("120 tokens")
865
+ expect(yesterdayLine.value).toContain("$0.60")
866
+ })
867
+
868
+ it("adds Last 30 Days line summing all daily entries", async () => {
869
+ const todayKey = localDayKey(new Date())
870
+ const ctx = makeProbeCtx({
871
+ ccusageResult: okUsage([
872
+ { date: todayKey, inputTokens: 100, outputTokens: 50, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 150, totalCost: 0.5 },
873
+ { date: "2026-02-01", inputTokens: 200, outputTokens: 100, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 300, totalCost: 1.0 },
874
+ ]),
875
+ })
876
+ const plugin = await loadPlugin()
877
+ const result = plugin.probe(ctx)
878
+ const last30 = result.lines.find((l) => l.label === "Last 30 Days")
879
+ expect(last30).toBeTruthy()
880
+ expect(last30.value).toContain("450 tokens")
881
+ expect(last30.value).toContain("$1.50")
882
+ })
883
+
884
+ it("shows empty Today/Yesterday and Last 30 Days when today has no entry", async () => {
885
+ const ctx = makeProbeCtx({
886
+ ccusageResult: okUsage([
887
+ { date: "2026-02-01", inputTokens: 500, outputTokens: 100, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 600, totalCost: 2.0 },
888
+ ]),
889
+ })
890
+ const plugin = await loadPlugin()
891
+ const result = plugin.probe(ctx)
892
+ const todayLine = result.lines.find((l) => l.label === "Today")
893
+ expect(todayLine).toBeTruthy()
894
+ expect(todayLine.value).toContain("$0.00")
895
+ expect(todayLine.value).toContain("0 tokens")
896
+ const yesterdayLine = result.lines.find((l) => l.label === "Yesterday")
897
+ expect(yesterdayLine).toBeTruthy()
898
+ expect(yesterdayLine.value).toContain("$0.00")
899
+ expect(yesterdayLine.value).toContain("0 tokens")
900
+ const last30 = result.lines.find((l) => l.label === "Last 30 Days")
901
+ expect(last30).toBeTruthy()
902
+ expect(last30.value).toContain("600 tokens")
903
+ })
904
+
905
+ it("shows empty Today state when ccusage returns ok with empty daily array", async () => {
906
+ const ctx = makeProbeCtx({ ccusageResult: okUsage([]) })
907
+ const plugin = await loadPlugin()
908
+ const result = plugin.probe(ctx)
909
+ const todayLine = result.lines.find((l) => l.label === "Today")
910
+ expect(todayLine).toBeTruthy()
911
+ expect(todayLine.value).toContain("$0.00")
912
+ expect(todayLine.value).toContain("0 tokens")
913
+ const yesterdayLine = result.lines.find((l) => l.label === "Yesterday")
914
+ expect(yesterdayLine).toBeTruthy()
915
+ expect(yesterdayLine.value).toContain("$0.00")
916
+ expect(yesterdayLine.value).toContain("0 tokens")
917
+ expect(result.lines.find((l) => l.label === "Last 30 Days")).toBeUndefined()
918
+ })
919
+
920
+ it("omits cost when totalCost is null", async () => {
921
+ const todayKey = localDayKey(new Date())
922
+ const ctx = makeProbeCtx({
923
+ ccusageResult: okUsage([
924
+ { date: todayKey, inputTokens: 500, outputTokens: 100, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 600, totalCost: null },
925
+ ]),
926
+ })
927
+ const plugin = await loadPlugin()
928
+ const result = plugin.probe(ctx)
929
+ const todayLine = result.lines.find((l) => l.label === "Today")
930
+ expect(todayLine).toBeTruthy()
931
+ expect(todayLine.value).not.toContain("$")
932
+ expect(todayLine.value).toContain("600 tokens")
933
+ })
934
+
935
+ it("shows empty Today state when today's totals are zero (regression)", async () => {
936
+ const todayKey = localDayKey(new Date())
937
+ const ctx = makeProbeCtx({
938
+ ccusageResult: okUsage([
939
+ { date: todayKey, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, totalCost: 0 },
940
+ ]),
941
+ })
942
+ const plugin = await loadPlugin()
943
+ const result = plugin.probe(ctx)
944
+ const todayLine = result.lines.find((l) => l.label === "Today")
945
+ expect(todayLine).toBeTruthy()
946
+ expect(todayLine.value).toContain("$0.00")
947
+ expect(todayLine.value).toContain("0 tokens")
948
+ })
949
+
950
+ it("shows empty Yesterday state when yesterday's totals are zero (regression)", async () => {
951
+ const yesterday = new Date()
952
+ yesterday.setDate(yesterday.getDate() - 1)
953
+ const yesterdayKey = localDayKey(yesterday)
954
+ const ctx = makeProbeCtx({
955
+ ccusageResult: okUsage([
956
+ { date: yesterdayKey, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, totalCost: 0 },
957
+ ]),
958
+ })
959
+ const plugin = await loadPlugin()
960
+ const result = plugin.probe(ctx)
961
+ const yesterdayLine = result.lines.find((l) => l.label === "Yesterday")
962
+ expect(yesterdayLine).toBeTruthy()
963
+ expect(yesterdayLine.value).toContain("$0.00")
964
+ expect(yesterdayLine.value).toContain("0 tokens")
965
+ })
966
+
967
+ it("queries ccusage on each probe", async () => {
968
+ const todayKey = localDayKey(new Date())
969
+ const ctx = makeProbeCtx({
970
+ ccusageResult: okUsage([
971
+ { date: todayKey, inputTokens: 100, outputTokens: 50, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 150, totalCost: 0.5 },
972
+ ]),
973
+ })
974
+ const plugin = await loadPlugin()
975
+ plugin.probe(ctx)
976
+ plugin.probe(ctx)
977
+ expect(ctx.host.ccusage.query).toHaveBeenCalledTimes(2)
978
+ })
979
+
980
+ it("queries ccusage with a 31-day inclusive since window", async () => {
981
+ vi.useFakeTimers()
982
+ vi.setSystemTime(new Date("2026-02-20T16:00:00.000Z"))
983
+ try {
984
+ const ctx = makeProbeCtx({ ccusageResult: okUsage([]) })
985
+ const plugin = await loadPlugin()
986
+ plugin.probe(ctx)
987
+ expect(ctx.host.ccusage.query).toHaveBeenCalled()
988
+
989
+ const firstCall = ctx.host.ccusage.query.mock.calls[0][0]
990
+ const since = new Date()
991
+ since.setDate(since.getDate() - 30)
992
+ expect(firstCall.since).toBe(localCompactDayKey(since))
993
+ } finally {
994
+ vi.useRealTimers()
995
+ }
996
+ })
997
+
998
+ it("includes cache tokens in total", async () => {
999
+ const todayKey = localDayKey(new Date())
1000
+ const ctx = makeProbeCtx({
1001
+ ccusageResult: okUsage([
1002
+ { date: todayKey, inputTokens: 100, outputTokens: 50, cacheCreationTokens: 200, cacheReadTokens: 300, totalTokens: 650, totalCost: 1.0 },
1003
+ ]),
1004
+ })
1005
+ const plugin = await loadPlugin()
1006
+ const result = plugin.probe(ctx)
1007
+ const todayLine = result.lines.find((l) => l.label === "Today")
1008
+ expect(todayLine).toBeTruthy()
1009
+ expect(todayLine.value).toContain("650 tokens")
1010
+ })
1011
+ })
1012
+ })