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,1071 @@
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("codex plugin", () => {
10
+ beforeEach(() => {
11
+ delete globalThis.__openusage_plugin
12
+ vi.resetModules()
13
+ })
14
+
15
+ it("throws when auth missing", async () => {
16
+ const ctx = makeCtx()
17
+ const plugin = await loadPlugin()
18
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
19
+ })
20
+
21
+ it("loads auth from keychain when auth file is missing", async () => {
22
+ const ctx = makeCtx()
23
+ ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({
24
+ tokens: { access_token: "keychain-token" },
25
+ last_refresh: new Date().toISOString(),
26
+ }))
27
+ ctx.host.http.request.mockImplementation((opts) => {
28
+ expect(opts.headers.Authorization).toBe("Bearer keychain-token")
29
+ return { status: 200, headers: {}, bodyText: JSON.stringify({}) }
30
+ })
31
+
32
+ const plugin = await loadPlugin()
33
+ plugin.probe(ctx)
34
+ })
35
+
36
+ it("uses CODEX_HOME auth path when env var is set", async () => {
37
+ const ctx = makeCtx()
38
+ ctx.host.env.get.mockImplementation((name) => (name === "CODEX_HOME" ? "/tmp/codex-home" : null))
39
+ ctx.host.fs.writeText("/tmp/codex-home/auth.json", JSON.stringify({
40
+ tokens: { access_token: "env-token" },
41
+ last_refresh: new Date().toISOString(),
42
+ }))
43
+ ctx.host.fs.writeText("~/.config/codex/auth.json", JSON.stringify({
44
+ tokens: { access_token: "config-token" },
45
+ last_refresh: new Date().toISOString(),
46
+ }))
47
+ ctx.host.http.request.mockImplementation((opts) => {
48
+ expect(opts.headers.Authorization).toBe("Bearer env-token")
49
+ return { status: 200, headers: {}, bodyText: JSON.stringify({}) }
50
+ })
51
+
52
+ const plugin = await loadPlugin()
53
+ plugin.probe(ctx)
54
+ })
55
+
56
+ it("uses ~/.config/codex/auth.json before ~/.codex/auth.json when env is not set", async () => {
57
+ const ctx = makeCtx()
58
+ ctx.host.fs.writeText("~/.config/codex/auth.json", JSON.stringify({
59
+ tokens: { access_token: "config-token" },
60
+ last_refresh: new Date().toISOString(),
61
+ }))
62
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
63
+ tokens: { access_token: "legacy-token" },
64
+ last_refresh: new Date().toISOString(),
65
+ }))
66
+ ctx.host.http.request.mockImplementation((opts) => {
67
+ expect(opts.headers.Authorization).toBe("Bearer config-token")
68
+ return { status: 200, headers: {}, bodyText: JSON.stringify({}) }
69
+ })
70
+
71
+ const plugin = await loadPlugin()
72
+ plugin.probe(ctx)
73
+ })
74
+
75
+ it("does not fall back when CODEX_HOME is set but missing auth file", async () => {
76
+ const ctx = makeCtx()
77
+ ctx.host.env.get.mockImplementation((name) => (name === "CODEX_HOME" ? "/tmp/missing-codex-home" : null))
78
+ ctx.host.fs.writeText("~/.config/codex/auth.json", JSON.stringify({
79
+ tokens: { access_token: "config-token" },
80
+ last_refresh: new Date().toISOString(),
81
+ }))
82
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
83
+ tokens: { access_token: "legacy-token" },
84
+ last_refresh: new Date().toISOString(),
85
+ }))
86
+ const plugin = await loadPlugin()
87
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
88
+ })
89
+
90
+ it("throws when auth json is invalid", async () => {
91
+ const ctx = makeCtx()
92
+ ctx.host.fs.writeText("~/.codex/auth.json", "{bad")
93
+ const plugin = await loadPlugin()
94
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
95
+ })
96
+
97
+ it("falls back to keychain when auth file is invalid", async () => {
98
+ const ctx = makeCtx()
99
+ ctx.host.fs.writeText("~/.codex/auth.json", "{bad")
100
+ ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({
101
+ tokens: { access_token: "keychain-token" },
102
+ last_refresh: new Date().toISOString(),
103
+ }))
104
+ ctx.host.http.request.mockImplementation((opts) => {
105
+ expect(opts.headers.Authorization).toBe("Bearer keychain-token")
106
+ return { status: 200, headers: {}, bodyText: JSON.stringify({}) }
107
+ })
108
+
109
+ const plugin = await loadPlugin()
110
+ plugin.probe(ctx)
111
+ })
112
+
113
+ it("supports hex-encoded keychain auth payload", async () => {
114
+ const ctx = makeCtx()
115
+ const raw = JSON.stringify({
116
+ tokens: { access_token: "hex-token" },
117
+ last_refresh: new Date().toISOString(),
118
+ })
119
+ const hex = Buffer.from(raw, "utf8").toString("hex")
120
+ ctx.host.keychain.readGenericPassword.mockReturnValue(hex)
121
+ ctx.host.http.request.mockImplementation((opts) => {
122
+ expect(opts.headers.Authorization).toBe("Bearer hex-token")
123
+ return { status: 200, headers: {}, bodyText: JSON.stringify({}) }
124
+ })
125
+
126
+ const plugin = await loadPlugin()
127
+ plugin.probe(ctx)
128
+ })
129
+
130
+ it("throws when auth lacks tokens and api key", async () => {
131
+ const ctx = makeCtx()
132
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ tokens: {} }))
133
+ const plugin = await loadPlugin()
134
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
135
+ })
136
+
137
+ it("refreshes token and formats usage", async () => {
138
+ const ctx = makeCtx()
139
+ const authPath = "~/.codex/auth.json"
140
+ ctx.host.fs.writeText(authPath, JSON.stringify({
141
+ tokens: { access_token: "old", refresh_token: "refresh", account_id: "acc" },
142
+ last_refresh: "2000-01-01T00:00:00.000Z",
143
+ }))
144
+ ctx.host.http.request.mockImplementation((opts) => {
145
+ if (String(opts.url).includes("oauth/token")) {
146
+ return { status: 200, bodyText: JSON.stringify({ access_token: "new" }) }
147
+ }
148
+ return {
149
+ status: 200,
150
+ headers: {
151
+ "x-codex-primary-used-percent": "25",
152
+ "x-codex-secondary-used-percent": "50",
153
+ "x-codex-credits-balance": "100",
154
+ },
155
+ bodyText: JSON.stringify({
156
+ plan_type: "pro",
157
+ rate_limit: {
158
+ primary_window: { reset_after_seconds: 60, used_percent: 10 },
159
+ secondary_window: { reset_after_seconds: 120, used_percent: 20 },
160
+ },
161
+ }),
162
+ }
163
+ })
164
+
165
+ const plugin = await loadPlugin()
166
+ const result = plugin.probe(ctx)
167
+ expect(result.plan).toBeTruthy()
168
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
169
+ expect(result.lines.find((line) => line.label === "Weekly")).toBeTruthy()
170
+ const credits = result.lines.find((line) => line.label === "Credits")
171
+ expect(credits).toBeTruthy()
172
+ expect(credits.used).toBe(900)
173
+ })
174
+
175
+ it("refreshes keychain auth and writes back to keychain", async () => {
176
+ const ctx = makeCtx()
177
+ ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({
178
+ tokens: { access_token: "old", refresh_token: "refresh", account_id: "acc" },
179
+ last_refresh: "2000-01-01T00:00:00.000Z",
180
+ }))
181
+ ctx.host.http.request.mockImplementation((opts) => {
182
+ if (String(opts.url).includes("oauth/token")) {
183
+ return { status: 200, bodyText: JSON.stringify({ access_token: "new" }) }
184
+ }
185
+ return { status: 200, headers: {}, bodyText: JSON.stringify({}) }
186
+ })
187
+
188
+ const plugin = await loadPlugin()
189
+ plugin.probe(ctx)
190
+
191
+ expect(ctx.host.keychain.writeGenericPassword).toHaveBeenCalled()
192
+ const [service, payload] = ctx.host.keychain.writeGenericPassword.mock.calls[0]
193
+ expect(service).toBe("Codex Auth")
194
+ expect(String(payload)).toContain("\"access_token\":\"new\"")
195
+ })
196
+
197
+ it("omits token lines when ccusage reports no_runner", async () => {
198
+ const ctx = makeCtx()
199
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
200
+ tokens: { access_token: "token" },
201
+ last_refresh: new Date().toISOString(),
202
+ }))
203
+ ctx.host.http.request.mockReturnValue({
204
+ status: 200,
205
+ headers: { "x-codex-primary-used-percent": "10" },
206
+ bodyText: JSON.stringify({}),
207
+ })
208
+ ctx.host.ccusage.query.mockReturnValue({ status: "no_runner" })
209
+
210
+ const plugin = await loadPlugin()
211
+ const result = plugin.probe(ctx)
212
+ expect(result.lines.find((l) => l.label === "Today")).toBeUndefined()
213
+ expect(result.lines.find((l) => l.label === "Yesterday")).toBeUndefined()
214
+ expect(result.lines.find((l) => l.label === "Last 30 Days")).toBeUndefined()
215
+ expect(result.lines.find((l) => l.label === "Session")).toBeTruthy()
216
+ })
217
+
218
+ it("adds token lines from codex ccusage format and passes codex provider", async () => {
219
+ vi.useFakeTimers()
220
+ vi.setSystemTime(new Date("2026-02-20T16:00:00.000Z"))
221
+
222
+ const ctx = makeCtx()
223
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
224
+ tokens: { access_token: "token" },
225
+ last_refresh: new Date().toISOString(),
226
+ }))
227
+ ctx.host.http.request.mockReturnValue({
228
+ status: 200,
229
+ headers: { "x-codex-primary-used-percent": "10" },
230
+ bodyText: JSON.stringify({}),
231
+ })
232
+ const now = new Date()
233
+ const month = now.toLocaleString("en-US", { month: "short" })
234
+ const day = String(now.getDate()).padStart(2, "0")
235
+ const year = now.getFullYear()
236
+ const todayKey = month + " " + day + ", " + year
237
+ ctx.host.ccusage.query.mockReturnValue({
238
+ status: "ok",
239
+ data: {
240
+ daily: [
241
+ { date: todayKey, totalTokens: 150, costUSD: 0.75 },
242
+ { date: "Feb 01, 2026", totalTokens: 300, costUSD: 1.0 },
243
+ ],
244
+ },
245
+ })
246
+
247
+ try {
248
+ const plugin = await loadPlugin()
249
+ const result = plugin.probe(ctx)
250
+
251
+ const today = result.lines.find((l) => l.label === "Today")
252
+ expect(today).toBeTruthy()
253
+ expect(today.value).toContain("150 tokens")
254
+ expect(today.value).toContain("$0.75")
255
+
256
+ const last30 = result.lines.find((l) => l.label === "Last 30 Days")
257
+ expect(last30).toBeTruthy()
258
+ expect(last30.value).toContain("450 tokens")
259
+ expect(last30.value).toContain("$1.75")
260
+
261
+ expect(ctx.host.ccusage.query).toHaveBeenCalled()
262
+ const firstCall = ctx.host.ccusage.query.mock.calls[0][0]
263
+ expect(firstCall.provider).toBe("codex")
264
+ const since = new Date()
265
+ since.setDate(since.getDate() - 30)
266
+ const sinceYear = String(since.getFullYear())
267
+ const sinceMonth = String(since.getMonth() + 1).padStart(2, "0")
268
+ const sinceDay = String(since.getDate()).padStart(2, "0")
269
+ expect(firstCall.since).toBe(sinceYear + sinceMonth + sinceDay)
270
+ } finally {
271
+ vi.useRealTimers()
272
+ }
273
+ })
274
+
275
+ it("passes CODEX_HOME to ccusage via homePath", async () => {
276
+ const ctx = makeCtx()
277
+ ctx.host.env.get.mockImplementation((name) => (name === "CODEX_HOME" ? "/tmp/codex-home" : null))
278
+ ctx.host.fs.writeText("/tmp/codex-home/auth.json", JSON.stringify({
279
+ tokens: { access_token: "token" },
280
+ last_refresh: new Date().toISOString(),
281
+ }))
282
+ ctx.host.http.request.mockReturnValue({
283
+ status: 200,
284
+ headers: { "x-codex-primary-used-percent": "10" },
285
+ bodyText: JSON.stringify({}),
286
+ })
287
+ ctx.host.ccusage.query.mockReturnValue({ status: "ok", data: { daily: [] } })
288
+
289
+ const plugin = await loadPlugin()
290
+ plugin.probe(ctx)
291
+
292
+ expect(ctx.host.ccusage.query).toHaveBeenCalled()
293
+ const firstCall = ctx.host.ccusage.query.mock.calls[0][0]
294
+ expect(firstCall.homePath).toBe("/tmp/codex-home")
295
+ })
296
+
297
+ it("queries ccusage on each probe", async () => {
298
+ const ctx = makeCtx()
299
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
300
+ tokens: { access_token: "token" },
301
+ last_refresh: new Date().toISOString(),
302
+ }))
303
+ ctx.host.http.request.mockReturnValue({
304
+ status: 200,
305
+ headers: { "x-codex-primary-used-percent": "10" },
306
+ bodyText: JSON.stringify({}),
307
+ })
308
+ ctx.host.ccusage.query.mockReturnValue({
309
+ status: "ok",
310
+ data: { daily: [{ date: "2026-02-01", totalTokens: 100, totalCost: 0.5 }] },
311
+ })
312
+
313
+ const plugin = await loadPlugin()
314
+ plugin.probe(ctx)
315
+ plugin.probe(ctx)
316
+
317
+ expect(ctx.host.ccusage.query).toHaveBeenCalledTimes(2)
318
+ })
319
+
320
+ it("shows empty Today state when ccusage returns ok with empty daily array", async () => {
321
+ const ctx = makeCtx()
322
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
323
+ tokens: { access_token: "token" },
324
+ last_refresh: new Date().toISOString(),
325
+ }))
326
+ ctx.host.http.request.mockReturnValue({
327
+ status: 200,
328
+ headers: { "x-codex-primary-used-percent": "10" },
329
+ bodyText: JSON.stringify({}),
330
+ })
331
+ ctx.host.ccusage.query.mockReturnValue({ status: "ok", data: { daily: [] } })
332
+
333
+ const plugin = await loadPlugin()
334
+ const result = plugin.probe(ctx)
335
+
336
+ const todayLine = result.lines.find((l) => l.label === "Today")
337
+ expect(todayLine).toBeTruthy()
338
+ expect(todayLine.value).toContain("$0.00")
339
+ expect(todayLine.value).toContain("0 tokens")
340
+ const yesterdayLine = result.lines.find((l) => l.label === "Yesterday")
341
+ expect(yesterdayLine).toBeTruthy()
342
+ expect(yesterdayLine.value).toContain("$0.00")
343
+ expect(yesterdayLine.value).toContain("0 tokens")
344
+ expect(result.lines.find((l) => l.label === "Last 30 Days")).toBeUndefined()
345
+ })
346
+
347
+ it("shows empty Yesterday state when yesterday's totals are zero (regression)", async () => {
348
+ const ctx = makeCtx()
349
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
350
+ tokens: { access_token: "token" },
351
+ last_refresh: new Date().toISOString(),
352
+ }))
353
+ ctx.host.http.request.mockReturnValue({
354
+ status: 200,
355
+ headers: { "x-codex-primary-used-percent": "10" },
356
+ bodyText: JSON.stringify({}),
357
+ })
358
+ const yesterday = new Date()
359
+ yesterday.setDate(yesterday.getDate() - 1)
360
+ const month = yesterday.toLocaleString("en-US", { month: "short" })
361
+ const day = String(yesterday.getDate()).padStart(2, "0")
362
+ const year = yesterday.getFullYear()
363
+ const yesterdayKey = month + " " + day + ", " + year
364
+ ctx.host.ccusage.query.mockReturnValue({
365
+ status: "ok",
366
+ data: {
367
+ daily: [
368
+ { date: yesterdayKey, totalTokens: 0, costUSD: 0 },
369
+ ],
370
+ },
371
+ })
372
+
373
+ const plugin = await loadPlugin()
374
+ const result = plugin.probe(ctx)
375
+ const yesterdayLine = result.lines.find((l) => l.label === "Yesterday")
376
+ expect(yesterdayLine).toBeTruthy()
377
+ expect(yesterdayLine.value).toContain("$0.00")
378
+ expect(yesterdayLine.value).toContain("0 tokens")
379
+ })
380
+
381
+ it("shows empty Today when history exists but today is missing (regression)", async () => {
382
+ const ctx = makeCtx()
383
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
384
+ tokens: { access_token: "token" },
385
+ last_refresh: new Date().toISOString(),
386
+ }))
387
+ ctx.host.http.request.mockReturnValue({
388
+ status: 200,
389
+ headers: { "x-codex-primary-used-percent": "10" },
390
+ bodyText: JSON.stringify({}),
391
+ })
392
+ ctx.host.ccusage.query.mockReturnValue({
393
+ status: "ok",
394
+ data: {
395
+ daily: [
396
+ { date: "Feb 01, 2026", totalTokens: 300, costUSD: 1.0 },
397
+ ],
398
+ },
399
+ })
400
+
401
+ const plugin = await loadPlugin()
402
+ const result = plugin.probe(ctx)
403
+
404
+ const todayLine = result.lines.find((l) => l.label === "Today")
405
+ expect(todayLine).toBeTruthy()
406
+ expect(todayLine.value).toContain("$0.00")
407
+ expect(todayLine.value).toContain("0 tokens")
408
+ const yesterdayLine = result.lines.find((l) => l.label === "Yesterday")
409
+ expect(yesterdayLine).toBeTruthy()
410
+ expect(yesterdayLine.value).toContain("$0.00")
411
+ expect(yesterdayLine.value).toContain("0 tokens")
412
+
413
+ const last30 = result.lines.find((l) => l.label === "Last 30 Days")
414
+ expect(last30).toBeTruthy()
415
+ expect(last30.value).toContain("300 tokens")
416
+ expect(last30.value).toContain("$1.00")
417
+ })
418
+
419
+ it("adds Yesterday line from codex ccusage format", async () => {
420
+ const ctx = makeCtx()
421
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
422
+ tokens: { access_token: "token" },
423
+ last_refresh: new Date().toISOString(),
424
+ }))
425
+ ctx.host.http.request.mockReturnValue({
426
+ status: 200,
427
+ headers: { "x-codex-primary-used-percent": "10" },
428
+ bodyText: JSON.stringify({}),
429
+ })
430
+ const yesterday = new Date()
431
+ yesterday.setDate(yesterday.getDate() - 1)
432
+ const month = yesterday.toLocaleString("en-US", { month: "short" })
433
+ const day = String(yesterday.getDate()).padStart(2, "0")
434
+ const year = yesterday.getFullYear()
435
+ const yesterdayKey = month + " " + day + ", " + year
436
+ ctx.host.ccusage.query.mockReturnValue({
437
+ status: "ok",
438
+ data: {
439
+ daily: [
440
+ { date: yesterdayKey, totalTokens: 220, costUSD: 1.1 },
441
+ ],
442
+ },
443
+ })
444
+
445
+ const plugin = await loadPlugin()
446
+ const result = plugin.probe(ctx)
447
+ const yesterdayLine = result.lines.find((l) => l.label === "Yesterday")
448
+ expect(yesterdayLine).toBeTruthy()
449
+ expect(yesterdayLine.value).toContain("220 tokens")
450
+ expect(yesterdayLine.value).toContain("$1.10")
451
+ })
452
+
453
+ it("throws token expired when refresh fails", async () => {
454
+ const ctx = makeCtx()
455
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
456
+ tokens: { access_token: "old" },
457
+ last_refresh: "2000-01-01T00:00:00.000Z",
458
+ }))
459
+ ctx.host.http.request.mockReturnValue({ status: 401, headers: {}, bodyText: "{}" })
460
+ const plugin = await loadPlugin()
461
+ expect(() => plugin.probe(ctx)).toThrow("Token expired")
462
+ })
463
+
464
+ it("throws token conflict when refresh token is reused", async () => {
465
+ const ctx = makeCtx()
466
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
467
+ tokens: { access_token: "old", refresh_token: "refresh" },
468
+ last_refresh: "2000-01-01T00:00:00.000Z",
469
+ }))
470
+ ctx.host.http.request.mockReturnValue({
471
+ status: 400,
472
+ headers: {},
473
+ bodyText: JSON.stringify({ error: { code: "refresh_token_reused" } }),
474
+ })
475
+ const plugin = await loadPlugin()
476
+ expect(() => plugin.probe(ctx)).toThrow("Token conflict")
477
+ })
478
+
479
+ it("throws for api key auth", async () => {
480
+ const ctx = makeCtx()
481
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
482
+ OPENAI_API_KEY: "key",
483
+ }))
484
+ const plugin = await loadPlugin()
485
+ expect(() => plugin.probe(ctx)).toThrow("Usage not available for API key")
486
+ })
487
+
488
+ it("falls back to rate_limit data and review window", async () => {
489
+ const ctx = makeCtx()
490
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
491
+ tokens: { access_token: "token" },
492
+ last_refresh: new Date().toISOString(),
493
+ }))
494
+ ctx.host.http.request.mockReturnValue({
495
+ status: 200,
496
+ headers: {},
497
+ bodyText: JSON.stringify({
498
+ rate_limit: {
499
+ primary_window: { used_percent: 10, reset_after_seconds: 60 },
500
+ secondary_window: { used_percent: 20, reset_after_seconds: 120 },
501
+ },
502
+ code_review_rate_limit: {
503
+ primary_window: { used_percent: 15, reset_after_seconds: 90 },
504
+ },
505
+ credits: { balance: 500 },
506
+ }),
507
+ })
508
+ const plugin = await loadPlugin()
509
+ const result = plugin.probe(ctx)
510
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
511
+ expect(result.lines.find((line) => line.label === "Reviews")).toBeTruthy()
512
+ const credits = result.lines.find((line) => line.label === "Credits")
513
+ expect(credits).toBeTruthy()
514
+ expect(credits.used).toBe(500)
515
+ })
516
+
517
+ it("omits resetsAt when window lacks reset info", async () => {
518
+ const ctx = makeCtx()
519
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
520
+ tokens: { access_token: "token" },
521
+ last_refresh: new Date().toISOString(),
522
+ }))
523
+ ctx.host.http.request.mockReturnValue({
524
+ status: 200,
525
+ headers: { "x-codex-primary-used-percent": "10" },
526
+ bodyText: JSON.stringify({
527
+ rate_limit: {
528
+ primary_window: { used_percent: 10 },
529
+ },
530
+ }),
531
+ })
532
+ const plugin = await loadPlugin()
533
+ const result = plugin.probe(ctx)
534
+ const sessionLine = result.lines.find((line) => line.label === "Session")
535
+ expect(sessionLine).toBeTruthy()
536
+ expect(sessionLine.resetsAt).toBeUndefined()
537
+ })
538
+
539
+ it("uses reset_at when present for resetsAt", async () => {
540
+ const ctx = makeCtx()
541
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
542
+ tokens: { access_token: "token" },
543
+ last_refresh: new Date().toISOString(),
544
+ }))
545
+ const now = 1_700_000_000_000
546
+ const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now)
547
+ const nowSec = Math.floor(now / 1000)
548
+ const resetsAtExpected = new Date((nowSec + 60) * 1000).toISOString()
549
+
550
+ ctx.host.http.request.mockReturnValue({
551
+ status: 200,
552
+ headers: { "x-codex-primary-used-percent": "10" },
553
+ bodyText: JSON.stringify({
554
+ rate_limit: {
555
+ primary_window: { used_percent: 10, reset_at: nowSec + 60 },
556
+ },
557
+ }),
558
+ })
559
+
560
+ const plugin = await loadPlugin()
561
+ const result = plugin.probe(ctx)
562
+ const session = result.lines.find((line) => line.label === "Session")
563
+ expect(session).toBeTruthy()
564
+ expect(session.resetsAt).toBe(resetsAtExpected)
565
+ nowSpy.mockRestore()
566
+ })
567
+
568
+ it("throws on http and parse errors", async () => {
569
+ const ctx = makeCtx()
570
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
571
+ tokens: { access_token: "token" },
572
+ last_refresh: new Date().toISOString(),
573
+ }))
574
+ ctx.host.http.request.mockReturnValueOnce({ status: 500, headers: {}, bodyText: "" })
575
+ const plugin = await loadPlugin()
576
+ expect(() => plugin.probe(ctx)).toThrow("HTTP 500")
577
+
578
+ ctx.host.http.request.mockReturnValueOnce({ status: 200, headers: {}, bodyText: "bad" })
579
+ expect(() => plugin.probe(ctx)).toThrow("Usage response invalid")
580
+ })
581
+
582
+ it("shows status badge when no usage data and ccusage failed", async () => {
583
+ const ctx = makeCtx()
584
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
585
+ tokens: { access_token: "token" },
586
+ last_refresh: new Date().toISOString(),
587
+ }))
588
+ ctx.host.http.request.mockReturnValue({
589
+ status: 200,
590
+ headers: {},
591
+ bodyText: JSON.stringify({}),
592
+ })
593
+ ctx.host.ccusage.query.mockReturnValue({ status: "runner_failed" })
594
+ const plugin = await loadPlugin()
595
+ const result = plugin.probe(ctx)
596
+ expect(result.lines.find((l) => l.label === "Today")).toBeUndefined()
597
+ expect(result.lines.find((l) => l.label === "Yesterday")).toBeUndefined()
598
+ expect(result.lines.find((l) => l.label === "Last 30 Days")).toBeUndefined()
599
+ const statusLine = result.lines.find((l) => l.label === "Status")
600
+ expect(statusLine).toBeTruthy()
601
+ expect(statusLine.text).toBe("No usage data")
602
+ })
603
+
604
+ it("throws on usage request failures", async () => {
605
+ const ctx = makeCtx()
606
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
607
+ tokens: { access_token: "token" },
608
+ last_refresh: new Date().toISOString(),
609
+ }))
610
+ ctx.host.http.request.mockImplementation(() => {
611
+ throw new Error("boom")
612
+ })
613
+ const plugin = await loadPlugin()
614
+ expect(() => plugin.probe(ctx)).toThrow("Usage request failed")
615
+ })
616
+
617
+ it("throws on usage request failure after refresh", async () => {
618
+ const ctx = makeCtx()
619
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
620
+ tokens: { access_token: "token", refresh_token: "refresh" },
621
+ last_refresh: new Date().toISOString(),
622
+ }))
623
+ let usageCalls = 0
624
+ ctx.host.http.request.mockImplementation((opts) => {
625
+ if (String(opts.url).includes("oauth/token")) {
626
+ return { status: 200, bodyText: JSON.stringify({ access_token: "new" }) }
627
+ }
628
+ usageCalls += 1
629
+ if (usageCalls === 1) {
630
+ return { status: 401, headers: {}, bodyText: "" }
631
+ }
632
+ throw new Error("boom")
633
+ })
634
+ const plugin = await loadPlugin()
635
+ expect(() => plugin.probe(ctx)).toThrow("Usage request failed after refresh")
636
+ })
637
+
638
+ it("surfaces additional_rate_limits as Spark lines", async () => {
639
+ const ctx = makeCtx()
640
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
641
+ tokens: { access_token: "token" },
642
+ last_refresh: new Date().toISOString(),
643
+ }))
644
+ const now = 1_700_000_000_000
645
+ const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now)
646
+ const nowSec = Math.floor(now / 1000)
647
+
648
+ ctx.host.http.request.mockReturnValue({
649
+ status: 200,
650
+ headers: {},
651
+ bodyText: JSON.stringify({
652
+ rate_limit: {
653
+ primary_window: { used_percent: 5, reset_after_seconds: 60 },
654
+ secondary_window: { used_percent: 10, reset_after_seconds: 120 },
655
+ },
656
+ additional_rate_limits: [
657
+ {
658
+ limit_name: "GPT-5.3-Codex-Spark",
659
+ metered_feature: "codex_bengalfox",
660
+ rate_limit: {
661
+ primary_window: {
662
+ used_percent: 25,
663
+ limit_window_seconds: 18000,
664
+ reset_after_seconds: 3600,
665
+ reset_at: nowSec + 3600,
666
+ },
667
+ secondary_window: {
668
+ used_percent: 40,
669
+ limit_window_seconds: 604800,
670
+ reset_after_seconds: 86400,
671
+ reset_at: nowSec + 86400,
672
+ },
673
+ },
674
+ },
675
+ ],
676
+ }),
677
+ })
678
+
679
+ const plugin = await loadPlugin()
680
+ const result = plugin.probe(ctx)
681
+
682
+ const spark = result.lines.find((l) => l.label === "Spark")
683
+ expect(spark).toBeTruthy()
684
+ expect(spark.used).toBe(25)
685
+ expect(spark.limit).toBe(100)
686
+ expect(spark.periodDurationMs).toBe(18000000)
687
+ expect(spark.resetsAt).toBe(new Date((nowSec + 3600) * 1000).toISOString())
688
+
689
+ const sparkWeekly = result.lines.find((l) => l.label === "Spark Weekly")
690
+ expect(sparkWeekly).toBeTruthy()
691
+ expect(sparkWeekly.used).toBe(40)
692
+ expect(sparkWeekly.limit).toBe(100)
693
+ expect(sparkWeekly.periodDurationMs).toBe(604800000)
694
+ expect(sparkWeekly.resetsAt).toBe(new Date((nowSec + 86400) * 1000).toISOString())
695
+
696
+ nowSpy.mockRestore()
697
+ })
698
+
699
+ it("handles additional_rate_limits with missing fields and fallback labels", async () => {
700
+ const ctx = makeCtx()
701
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
702
+ tokens: { access_token: "token" },
703
+ last_refresh: new Date().toISOString(),
704
+ }))
705
+ ctx.host.http.request.mockReturnValue({
706
+ status: 200,
707
+ headers: {},
708
+ bodyText: JSON.stringify({
709
+ additional_rate_limits: [
710
+ // Entry with no limit_name, no limit_window_seconds, no secondary
711
+ {
712
+ limit_name: "",
713
+ rate_limit: {
714
+ primary_window: { used_percent: 10, reset_after_seconds: 60 },
715
+ secondary_window: null,
716
+ },
717
+ },
718
+ // Malformed entry (no rate_limit)
719
+ { limit_name: "Bad" },
720
+ // Null entry
721
+ null,
722
+ ],
723
+ }),
724
+ })
725
+ const plugin = await loadPlugin()
726
+ const result = plugin.probe(ctx)
727
+ const modelLine = result.lines.find((l) => l.label === "Model")
728
+ expect(modelLine).toBeTruthy()
729
+ expect(modelLine.used).toBe(10)
730
+ expect(modelLine.periodDurationMs).toBe(5 * 60 * 60 * 1000) // fallback PERIOD_SESSION_MS
731
+ // No weekly line for this entry since secondary_window is null
732
+ expect(result.lines.find((l) => l.label === "Model Weekly")).toBeUndefined()
733
+ // Malformed and null entries should be skipped
734
+ expect(result.lines.find((l) => l.label === "Bad")).toBeUndefined()
735
+ })
736
+
737
+ it("handles missing or empty additional_rate_limits gracefully", async () => {
738
+ const ctx = makeCtx()
739
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
740
+ tokens: { access_token: "token" },
741
+ last_refresh: new Date().toISOString(),
742
+ }))
743
+
744
+ // Missing field
745
+ ctx.host.http.request.mockReturnValueOnce({
746
+ status: 200,
747
+ headers: {},
748
+ bodyText: JSON.stringify({
749
+ rate_limit: {
750
+ primary_window: { used_percent: 5, reset_after_seconds: 60 },
751
+ },
752
+ }),
753
+ })
754
+ const plugin = await loadPlugin()
755
+ const result1 = plugin.probe(ctx)
756
+ expect(result1.lines.find((l) => l.label === "Spark")).toBeUndefined()
757
+
758
+ // Empty array
759
+ ctx.host.http.request.mockReturnValueOnce({
760
+ status: 200,
761
+ headers: {},
762
+ bodyText: JSON.stringify({
763
+ rate_limit: {
764
+ primary_window: { used_percent: 5, reset_after_seconds: 60 },
765
+ },
766
+ additional_rate_limits: [],
767
+ }),
768
+ })
769
+ const result2 = plugin.probe(ctx)
770
+ expect(result2.lines.find((l) => l.label === "Spark")).toBeUndefined()
771
+ })
772
+
773
+ it("throws token expired when refresh retry is unauthorized", async () => {
774
+ const ctx = makeCtx()
775
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
776
+ tokens: { access_token: "token", refresh_token: "refresh" },
777
+ last_refresh: new Date().toISOString(),
778
+ }))
779
+ let usageCalls = 0
780
+ ctx.host.http.request.mockImplementation((opts) => {
781
+ if (String(opts.url).includes("oauth/token")) {
782
+ return { status: 200, bodyText: JSON.stringify({ access_token: "new" }) }
783
+ }
784
+ usageCalls += 1
785
+ if (usageCalls === 1) {
786
+ return { status: 401, headers: {}, bodyText: "" }
787
+ }
788
+ return { status: 403, headers: {}, bodyText: "" }
789
+ })
790
+ const plugin = await loadPlugin()
791
+ expect(() => plugin.probe(ctx)).toThrow("Token expired")
792
+ })
793
+
794
+ it("loads keychain auth when env object is unavailable", async () => {
795
+ const ctx = makeCtx()
796
+ ctx.host.env = null
797
+ ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({
798
+ tokens: { access_token: "keychain-token" },
799
+ last_refresh: new Date().toISOString(),
800
+ }))
801
+ ctx.host.http.request.mockImplementation((opts) => {
802
+ expect(opts.headers.Authorization).toBe("Bearer keychain-token")
803
+ return { status: 200, headers: {}, bodyText: JSON.stringify({}) }
804
+ })
805
+
806
+ const plugin = await loadPlugin()
807
+ plugin.probe(ctx)
808
+ })
809
+
810
+ it("ignores blank CODEX_HOME and uses default auth file paths", async () => {
811
+ const ctx = makeCtx()
812
+ ctx.host.env.get.mockImplementation((name) => (name === "CODEX_HOME" ? " " : null))
813
+ ctx.host.fs.writeText("~/.config/codex/auth.json", JSON.stringify({
814
+ tokens: { access_token: "config-token" },
815
+ last_refresh: new Date().toISOString(),
816
+ }))
817
+ ctx.host.http.request.mockImplementation((opts) => {
818
+ expect(opts.headers.Authorization).toBe("Bearer config-token")
819
+ return { status: 200, headers: {}, bodyText: JSON.stringify({}) }
820
+ })
821
+
822
+ const plugin = await loadPlugin()
823
+ plugin.probe(ctx)
824
+ })
825
+
826
+ it("supports uppercase 0X-prefixed keychain hex payload", async () => {
827
+ const ctx = makeCtx()
828
+ const raw = JSON.stringify({
829
+ tokens: { access_token: "hex-token" },
830
+ last_refresh: new Date().toISOString(),
831
+ })
832
+ const hex = "0X" + Buffer.from(raw, "utf8").toString("hex").toUpperCase()
833
+ ctx.host.keychain.readGenericPassword.mockReturnValue(hex)
834
+ const originalTextDecoder = globalThis.TextDecoder
835
+ // Force fallback decode path used in hosts without TextDecoder.
836
+ globalThis.TextDecoder = undefined
837
+ try {
838
+ ctx.host.http.request.mockImplementation((opts) => {
839
+ expect(opts.headers.Authorization).toBe("Bearer hex-token")
840
+ return { status: 200, headers: {}, bodyText: JSON.stringify({}) }
841
+ })
842
+ const plugin = await loadPlugin()
843
+ plugin.probe(ctx)
844
+ } finally {
845
+ globalThis.TextDecoder = originalTextDecoder
846
+ }
847
+ })
848
+
849
+ it("throws token messages for refresh_token_expired and invalidated", async () => {
850
+ const ctx = makeCtx()
851
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
852
+ tokens: { access_token: "old", refresh_token: "refresh" },
853
+ last_refresh: "2000-01-01T00:00:00.000Z",
854
+ }))
855
+ ctx.host.http.request.mockReturnValueOnce({
856
+ status: 400,
857
+ headers: {},
858
+ bodyText: JSON.stringify({ error: { code: "refresh_token_expired" } }),
859
+ })
860
+ let plugin = await loadPlugin()
861
+ expect(() => plugin.probe(ctx)).toThrow("Session expired")
862
+
863
+ ctx.host.http.request.mockReset()
864
+ ctx.host.http.request.mockReturnValueOnce({
865
+ status: 400,
866
+ headers: {},
867
+ bodyText: JSON.stringify({ error: { code: "refresh_token_invalidated" } }),
868
+ })
869
+ delete globalThis.__openusage_plugin
870
+ vi.resetModules()
871
+ plugin = await loadPlugin()
872
+ expect(() => plugin.probe(ctx)).toThrow("Token revoked")
873
+ })
874
+
875
+ it("falls back to existing token when refresh cannot produce new access token", async () => {
876
+ const baseAuth = {
877
+ tokens: { access_token: "existing", refresh_token: "refresh" },
878
+ last_refresh: "2000-01-01T00:00:00.000Z",
879
+ }
880
+
881
+ const runCase = async (refreshResp) => {
882
+ const ctx = makeCtx()
883
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify(baseAuth))
884
+ ctx.host.http.request.mockImplementation((opts) => {
885
+ if (String(opts.url).includes("oauth/token")) return refreshResp
886
+ expect(opts.headers.Authorization).toBe("Bearer existing")
887
+ return {
888
+ status: 200,
889
+ headers: { "x-codex-primary-used-percent": "5" },
890
+ bodyText: JSON.stringify({}),
891
+ }
892
+ })
893
+
894
+ delete globalThis.__openusage_plugin
895
+ vi.resetModules()
896
+ const plugin = await loadPlugin()
897
+ const result = plugin.probe(ctx)
898
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
899
+ }
900
+
901
+ await runCase({ status: 500, headers: {}, bodyText: "" })
902
+ await runCase({ status: 200, headers: {}, bodyText: "not-json" })
903
+ await runCase({ status: 200, headers: {}, bodyText: JSON.stringify({}) })
904
+ })
905
+
906
+ it("throws when refresh body is malformed and auth endpoint is unauthorized", async () => {
907
+ const ctx = makeCtx()
908
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
909
+ tokens: { access_token: "old", refresh_token: "refresh" },
910
+ last_refresh: "2000-01-01T00:00:00.000Z",
911
+ }))
912
+ ctx.host.http.request.mockReturnValue({
913
+ status: 401,
914
+ headers: {},
915
+ bodyText: "{bad",
916
+ })
917
+ const plugin = await loadPlugin()
918
+ expect(() => plugin.probe(ctx)).toThrow("Token expired")
919
+ })
920
+
921
+ it("uses no_runner when ccusage host API is unavailable", async () => {
922
+ const ctx = makeCtx()
923
+ ctx.host.ccusage = null
924
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
925
+ tokens: { access_token: "token" },
926
+ last_refresh: new Date().toISOString(),
927
+ }))
928
+ ctx.host.http.request.mockReturnValue({
929
+ status: 200,
930
+ headers: { "x-codex-primary-used-percent": "10" },
931
+ bodyText: JSON.stringify({}),
932
+ })
933
+
934
+ const plugin = await loadPlugin()
935
+ const result = plugin.probe(ctx)
936
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
937
+ expect(result.lines.find((line) => line.label === "Today")).toBeUndefined()
938
+ })
939
+
940
+ it("handles malformed ccusage result payload as runner_failed", async () => {
941
+ const ctx = makeCtx()
942
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
943
+ tokens: { access_token: "token" },
944
+ last_refresh: new Date().toISOString(),
945
+ }))
946
+ ctx.host.http.request.mockReturnValue({
947
+ status: 200,
948
+ headers: { "x-codex-primary-used-percent": "10" },
949
+ bodyText: JSON.stringify({}),
950
+ })
951
+ ctx.host.ccusage.query.mockReturnValue({ status: "ok", data: {} })
952
+
953
+ const plugin = await loadPlugin()
954
+ const result = plugin.probe(ctx)
955
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
956
+ expect(result.lines.find((line) => line.label === "Today")).toBeUndefined()
957
+ })
958
+
959
+ it("formats large token totals using compact units", async () => {
960
+ vi.useFakeTimers()
961
+ vi.setSystemTime(new Date("2026-12-15T12:00:00.000Z"))
962
+ try {
963
+ const ctx = makeCtx()
964
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
965
+ tokens: { access_token: "token" },
966
+ last_refresh: new Date().toISOString(),
967
+ }))
968
+ ctx.host.http.request.mockReturnValue({
969
+ status: 200,
970
+ headers: { "x-codex-primary-used-percent": "10" },
971
+ bodyText: JSON.stringify({}),
972
+ })
973
+
974
+ const now = new Date()
975
+ const month = now.toLocaleString("en-US", { month: "short" })
976
+ const day = String(now.getDate()).padStart(2, "0")
977
+ const year = now.getFullYear()
978
+ const todayKey = month + " " + day + ", " + year
979
+ ctx.host.ccusage.query.mockReturnValue({
980
+ status: "ok",
981
+ data: {
982
+ daily: [
983
+ { date: todayKey, totalTokens: 1_250_000, totalCost: 12.5 },
984
+ { date: "20261214", totalTokens: 25_000_000, costUSD: 50.0 },
985
+ { date: "bad-date", totalTokens: "n/a", costUSD: "n/a" },
986
+ ],
987
+ },
988
+ })
989
+
990
+ const plugin = await loadPlugin()
991
+ const result = plugin.probe(ctx)
992
+ const today = result.lines.find((line) => line.label === "Today")
993
+ const last30 = result.lines.find((line) => line.label === "Last 30 Days")
994
+ expect(today && today.value).toContain("1.3M tokens")
995
+ expect(last30 && last30.value).toContain("26M tokens")
996
+ } finally {
997
+ vi.useRealTimers()
998
+ }
999
+ })
1000
+
1001
+ it("handles non-string retry wrapper exceptions", async () => {
1002
+ const ctx = makeCtx()
1003
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
1004
+ tokens: { access_token: "token" },
1005
+ last_refresh: new Date().toISOString(),
1006
+ }))
1007
+ ctx.util.retryOnceOnAuth = () => {
1008
+ throw new Error("boom")
1009
+ }
1010
+
1011
+ const plugin = await loadPlugin()
1012
+ expect(() => plugin.probe(ctx)).toThrow("Usage request failed. Check your connection.")
1013
+ })
1014
+
1015
+ it("treats empty auth file payload as not logged in", async () => {
1016
+ const ctx = makeCtx()
1017
+ ctx.host.fs.writeText("~/.codex/auth.json", "")
1018
+ const plugin = await loadPlugin()
1019
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
1020
+ })
1021
+
1022
+ it("handles missing keychain read API", async () => {
1023
+ const ctx = makeCtx()
1024
+ ctx.host.keychain = {}
1025
+ const plugin = await loadPlugin()
1026
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
1027
+ })
1028
+
1029
+ it("ignores keychain payloads that are present but missing token-like auth", async () => {
1030
+ const ctx = makeCtx()
1031
+ ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({ user: "me" }))
1032
+ const plugin = await loadPlugin()
1033
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
1034
+ })
1035
+
1036
+ it("stores refresh and id tokens when refresh response includes them", async () => {
1037
+ const ctx = makeCtx()
1038
+ ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({
1039
+ tokens: { access_token: "old", refresh_token: "refresh" },
1040
+ last_refresh: "2000-01-01T00:00:00.000Z",
1041
+ }))
1042
+
1043
+ const idToken = "header.payload.signature"
1044
+ ctx.host.http.request.mockImplementation((opts) => {
1045
+ const url = String(opts.url)
1046
+ if (url.includes("oauth/token")) {
1047
+ return {
1048
+ status: 200,
1049
+ headers: {},
1050
+ bodyText: JSON.stringify({
1051
+ access_token: "new-token",
1052
+ refresh_token: "new-refresh",
1053
+ id_token: idToken,
1054
+ }),
1055
+ }
1056
+ }
1057
+ return {
1058
+ status: 200,
1059
+ headers: { "x-codex-primary-used-percent": "1" },
1060
+ bodyText: JSON.stringify({}),
1061
+ }
1062
+ })
1063
+
1064
+ const plugin = await loadPlugin()
1065
+ plugin.probe(ctx)
1066
+
1067
+ const saved = JSON.parse(ctx.host.fs.readText("~/.codex/auth.json"))
1068
+ expect(saved.tokens.refresh_token).toBe("new-refresh")
1069
+ expect(saved.tokens.id_token).toBe(idToken)
1070
+ })
1071
+ })