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,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
|
+
})
|