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