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,529 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { makePluginTestContext } from "../test-helpers.js";
|
|
3
|
+
|
|
4
|
+
const loadPlugin = async () => {
|
|
5
|
+
await import("./plugin.js");
|
|
6
|
+
return globalThis.__openusage_plugin;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function makeUsageResponse(overrides = {}) {
|
|
10
|
+
return {
|
|
11
|
+
copilot_plan: "pro",
|
|
12
|
+
quota_reset_date: "2099-01-15T00:00:00Z",
|
|
13
|
+
quota_snapshots: {
|
|
14
|
+
premium_interactions: {
|
|
15
|
+
percent_remaining: 80,
|
|
16
|
+
entitlement: 300,
|
|
17
|
+
remaining: 240,
|
|
18
|
+
quota_id: "premium",
|
|
19
|
+
},
|
|
20
|
+
chat: {
|
|
21
|
+
percent_remaining: 95,
|
|
22
|
+
entitlement: 1000,
|
|
23
|
+
remaining: 950,
|
|
24
|
+
quota_id: "chat",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setKeychainToken(ctx, token) {
|
|
32
|
+
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
|
|
33
|
+
if (service === "OpenUsage-copilot") return JSON.stringify({ token });
|
|
34
|
+
return null;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setGhCliKeychain(ctx, value) {
|
|
39
|
+
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
|
|
40
|
+
if (service === "gh:github.com") return value;
|
|
41
|
+
return null;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setStateFileToken(ctx, token) {
|
|
46
|
+
ctx.host.fs.writeText(
|
|
47
|
+
ctx.app.pluginDataDir + "/auth.json",
|
|
48
|
+
JSON.stringify({ token }),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mockUsageOk(ctx, body) {
|
|
53
|
+
ctx.host.http.request.mockReturnValue({
|
|
54
|
+
status: 200,
|
|
55
|
+
bodyText: JSON.stringify(body || makeUsageResponse()),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("copilot plugin", () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
delete globalThis.__openusage_plugin;
|
|
62
|
+
if (vi.resetModules) vi.resetModules();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("throws when no token found", async () => {
|
|
66
|
+
const ctx = makePluginTestContext();
|
|
67
|
+
const plugin = await loadPlugin();
|
|
68
|
+
expect(() => plugin.probe(ctx)).toThrow("Not logged in. Run `gh auth login` first.");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("loads token from OpenUsage keychain", async () => {
|
|
72
|
+
const ctx = makePluginTestContext();
|
|
73
|
+
setKeychainToken(ctx, "ghu_keychain");
|
|
74
|
+
mockUsageOk(ctx);
|
|
75
|
+
const plugin = await loadPlugin();
|
|
76
|
+
const result = plugin.probe(ctx);
|
|
77
|
+
expect(result.lines.find((l) => l.label === "Premium")).toBeTruthy();
|
|
78
|
+
const call = ctx.host.http.request.mock.calls[0][0];
|
|
79
|
+
expect(call.headers.Authorization).toBe("token ghu_keychain");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("loads token from gh CLI keychain (plain)", async () => {
|
|
83
|
+
const ctx = makePluginTestContext();
|
|
84
|
+
setGhCliKeychain(ctx, "gho_plain_token");
|
|
85
|
+
mockUsageOk(ctx);
|
|
86
|
+
const plugin = await loadPlugin();
|
|
87
|
+
const result = plugin.probe(ctx);
|
|
88
|
+
expect(result.lines.find((l) => l.label === "Premium")).toBeTruthy();
|
|
89
|
+
const call = ctx.host.http.request.mock.calls[0][0];
|
|
90
|
+
expect(call.headers.Authorization).toBe("token gho_plain_token");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("loads token from gh CLI keychain (base64-encoded)", async () => {
|
|
94
|
+
const ctx = makePluginTestContext();
|
|
95
|
+
const encoded = ctx.base64.encode("gho_encoded_token");
|
|
96
|
+
setGhCliKeychain(ctx, "go-keyring-base64:" + encoded);
|
|
97
|
+
mockUsageOk(ctx);
|
|
98
|
+
const plugin = await loadPlugin();
|
|
99
|
+
const result = plugin.probe(ctx);
|
|
100
|
+
expect(result.lines.find((l) => l.label === "Premium")).toBeTruthy();
|
|
101
|
+
const call = ctx.host.http.request.mock.calls[0][0];
|
|
102
|
+
expect(call.headers.Authorization).toBe("token gho_encoded_token");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("loads token from state file", async () => {
|
|
106
|
+
const ctx = makePluginTestContext();
|
|
107
|
+
setStateFileToken(ctx, "ghu_state");
|
|
108
|
+
mockUsageOk(ctx);
|
|
109
|
+
const plugin = await loadPlugin();
|
|
110
|
+
const result = plugin.probe(ctx);
|
|
111
|
+
expect(result.lines.find((l) => l.label === "Premium")).toBeTruthy();
|
|
112
|
+
const call = ctx.host.http.request.mock.calls[0][0];
|
|
113
|
+
expect(call.headers.Authorization).toBe("token ghu_state");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("prefers keychain over gh-cli", async () => {
|
|
117
|
+
const ctx = makePluginTestContext();
|
|
118
|
+
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
|
|
119
|
+
if (service === "OpenUsage-copilot")
|
|
120
|
+
return JSON.stringify({ token: "ghu_keychain" });
|
|
121
|
+
if (service === "gh:github.com") return "gho_ghcli";
|
|
122
|
+
return null;
|
|
123
|
+
});
|
|
124
|
+
mockUsageOk(ctx);
|
|
125
|
+
const plugin = await loadPlugin();
|
|
126
|
+
plugin.probe(ctx);
|
|
127
|
+
const call = ctx.host.http.request.mock.calls[0][0];
|
|
128
|
+
expect(call.headers.Authorization).toBe("token ghu_keychain");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("prefers keychain over state file", async () => {
|
|
132
|
+
const ctx = makePluginTestContext();
|
|
133
|
+
setKeychainToken(ctx, "ghu_keychain");
|
|
134
|
+
setStateFileToken(ctx, "ghu_state");
|
|
135
|
+
mockUsageOk(ctx);
|
|
136
|
+
const plugin = await loadPlugin();
|
|
137
|
+
plugin.probe(ctx);
|
|
138
|
+
const call = ctx.host.http.request.mock.calls[0][0];
|
|
139
|
+
expect(call.headers.Authorization).toBe("token ghu_keychain");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("persists token from gh-cli to keychain and state file", async () => {
|
|
143
|
+
const ctx = makePluginTestContext();
|
|
144
|
+
setGhCliKeychain(ctx, "gho_persist");
|
|
145
|
+
mockUsageOk(ctx);
|
|
146
|
+
const plugin = await loadPlugin();
|
|
147
|
+
plugin.probe(ctx);
|
|
148
|
+
expect(ctx.host.keychain.writeGenericPassword).toHaveBeenCalledWith(
|
|
149
|
+
"OpenUsage-copilot",
|
|
150
|
+
JSON.stringify({ token: "gho_persist" }),
|
|
151
|
+
);
|
|
152
|
+
const stateFile = ctx.host.fs.readText(
|
|
153
|
+
ctx.app.pluginDataDir + "/auth.json",
|
|
154
|
+
);
|
|
155
|
+
expect(JSON.parse(stateFile).token).toBe("gho_persist");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("does not persist token loaded from OpenUsage keychain", async () => {
|
|
159
|
+
const ctx = makePluginTestContext();
|
|
160
|
+
setKeychainToken(ctx, "ghu_already");
|
|
161
|
+
mockUsageOk(ctx);
|
|
162
|
+
const plugin = await loadPlugin();
|
|
163
|
+
plugin.probe(ctx);
|
|
164
|
+
expect(ctx.host.keychain.writeGenericPassword).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("renders both Premium and Chat lines for paid tier", async () => {
|
|
168
|
+
const ctx = makePluginTestContext();
|
|
169
|
+
setKeychainToken(ctx, "tok");
|
|
170
|
+
mockUsageOk(ctx);
|
|
171
|
+
const plugin = await loadPlugin();
|
|
172
|
+
const result = plugin.probe(ctx);
|
|
173
|
+
const premium = result.lines.find((l) => l.label === "Premium");
|
|
174
|
+
const chat = result.lines.find((l) => l.label === "Chat");
|
|
175
|
+
expect(premium).toBeTruthy();
|
|
176
|
+
expect(premium.used).toBe(20); // 100 - 80
|
|
177
|
+
expect(premium.limit).toBe(100);
|
|
178
|
+
expect(chat).toBeTruthy();
|
|
179
|
+
expect(chat.used).toBe(5); // 100 - 95
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("renders only Premium when Chat is missing", async () => {
|
|
183
|
+
const ctx = makePluginTestContext();
|
|
184
|
+
setKeychainToken(ctx, "tok");
|
|
185
|
+
ctx.host.http.request.mockReturnValue({
|
|
186
|
+
status: 200,
|
|
187
|
+
bodyText: JSON.stringify(
|
|
188
|
+
makeUsageResponse({
|
|
189
|
+
quota_snapshots: {
|
|
190
|
+
premium_interactions: {
|
|
191
|
+
percent_remaining: 50,
|
|
192
|
+
entitlement: 300,
|
|
193
|
+
remaining: 150,
|
|
194
|
+
quota_id: "premium",
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
),
|
|
199
|
+
});
|
|
200
|
+
const plugin = await loadPlugin();
|
|
201
|
+
const result = plugin.probe(ctx);
|
|
202
|
+
expect(result.lines.find((l) => l.label === "Premium")).toBeTruthy();
|
|
203
|
+
expect(result.lines.find((l) => l.label === "Chat")).toBeFalsy();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("shows 'No usage data' when both snapshots missing", async () => {
|
|
207
|
+
const ctx = makePluginTestContext();
|
|
208
|
+
setKeychainToken(ctx, "tok");
|
|
209
|
+
ctx.host.http.request.mockReturnValue({
|
|
210
|
+
status: 200,
|
|
211
|
+
bodyText: JSON.stringify({ copilot_plan: "free" }),
|
|
212
|
+
});
|
|
213
|
+
const plugin = await loadPlugin();
|
|
214
|
+
const result = plugin.probe(ctx);
|
|
215
|
+
expect(result.lines[0].text).toBe("No usage data");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("returns plan label from copilot_plan", async () => {
|
|
219
|
+
const ctx = makePluginTestContext();
|
|
220
|
+
setKeychainToken(ctx, "tok");
|
|
221
|
+
mockUsageOk(ctx);
|
|
222
|
+
const plugin = await loadPlugin();
|
|
223
|
+
const result = plugin.probe(ctx);
|
|
224
|
+
expect(result.plan).toBe("Pro");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("capitalizes multi-word plan labels", async () => {
|
|
228
|
+
const ctx = makePluginTestContext();
|
|
229
|
+
setKeychainToken(ctx, "tok");
|
|
230
|
+
ctx.host.http.request.mockReturnValue({
|
|
231
|
+
status: 200,
|
|
232
|
+
bodyText: JSON.stringify(
|
|
233
|
+
makeUsageResponse({ copilot_plan: "business plus" }),
|
|
234
|
+
),
|
|
235
|
+
});
|
|
236
|
+
const plugin = await loadPlugin();
|
|
237
|
+
const result = plugin.probe(ctx);
|
|
238
|
+
expect(result.plan).toBe("Business Plus");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("propagates resetsAt from quota_reset_date", async () => {
|
|
242
|
+
const ctx = makePluginTestContext();
|
|
243
|
+
setKeychainToken(ctx, "tok");
|
|
244
|
+
mockUsageOk(ctx);
|
|
245
|
+
const plugin = await loadPlugin();
|
|
246
|
+
const result = plugin.probe(ctx);
|
|
247
|
+
const premium = result.lines.find((l) => l.label === "Premium");
|
|
248
|
+
expect(premium.resetsAt).toBe("2099-01-15T00:00:00.000Z");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("clamps usedPercent to 0 when percent_remaining > 100", async () => {
|
|
252
|
+
const ctx = makePluginTestContext();
|
|
253
|
+
setKeychainToken(ctx, "tok");
|
|
254
|
+
ctx.host.http.request.mockReturnValue({
|
|
255
|
+
status: 200,
|
|
256
|
+
bodyText: JSON.stringify(
|
|
257
|
+
makeUsageResponse({
|
|
258
|
+
quota_snapshots: {
|
|
259
|
+
premium_interactions: {
|
|
260
|
+
percent_remaining: 120,
|
|
261
|
+
entitlement: 300,
|
|
262
|
+
remaining: 360,
|
|
263
|
+
quota_id: "premium",
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
}),
|
|
267
|
+
),
|
|
268
|
+
});
|
|
269
|
+
const plugin = await loadPlugin();
|
|
270
|
+
const result = plugin.probe(ctx);
|
|
271
|
+
expect(result.lines.find((l) => l.label === "Premium").used).toBe(0);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("throws on 401", async () => {
|
|
275
|
+
const ctx = makePluginTestContext();
|
|
276
|
+
setKeychainToken(ctx, "tok");
|
|
277
|
+
ctx.host.http.request.mockReturnValue({ status: 401, bodyText: "" });
|
|
278
|
+
const plugin = await loadPlugin();
|
|
279
|
+
expect(() => plugin.probe(ctx)).toThrow("Token invalid. Run `gh auth login` to re-authenticate.");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("throws on 403", async () => {
|
|
283
|
+
const ctx = makePluginTestContext();
|
|
284
|
+
setKeychainToken(ctx, "tok");
|
|
285
|
+
ctx.host.http.request.mockReturnValue({ status: 403, bodyText: "" });
|
|
286
|
+
const plugin = await loadPlugin();
|
|
287
|
+
expect(() => plugin.probe(ctx)).toThrow("Token invalid. Run `gh auth login` to re-authenticate.");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("throws on HTTP 500", async () => {
|
|
291
|
+
const ctx = makePluginTestContext();
|
|
292
|
+
setKeychainToken(ctx, "tok");
|
|
293
|
+
ctx.host.http.request.mockReturnValue({ status: 500, bodyText: "" });
|
|
294
|
+
const plugin = await loadPlugin();
|
|
295
|
+
expect(() => plugin.probe(ctx)).toThrow(
|
|
296
|
+
"Usage request failed (HTTP 500). Try again later.",
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("throws on network error", async () => {
|
|
301
|
+
const ctx = makePluginTestContext();
|
|
302
|
+
setKeychainToken(ctx, "tok");
|
|
303
|
+
ctx.host.http.request.mockImplementation(() => {
|
|
304
|
+
throw new Error("ECONNREFUSED");
|
|
305
|
+
});
|
|
306
|
+
const plugin = await loadPlugin();
|
|
307
|
+
expect(() => plugin.probe(ctx)).toThrow(
|
|
308
|
+
"Usage request failed. Check your connection.",
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("throws on invalid JSON response", async () => {
|
|
313
|
+
const ctx = makePluginTestContext();
|
|
314
|
+
setKeychainToken(ctx, "tok");
|
|
315
|
+
ctx.host.http.request.mockReturnValue({
|
|
316
|
+
status: 200,
|
|
317
|
+
bodyText: "not-json",
|
|
318
|
+
});
|
|
319
|
+
const plugin = await loadPlugin();
|
|
320
|
+
expect(() => plugin.probe(ctx)).toThrow(
|
|
321
|
+
"Usage response invalid. Try again later.",
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("uses 'token' auth header format (not 'Bearer')", async () => {
|
|
326
|
+
const ctx = makePluginTestContext();
|
|
327
|
+
setKeychainToken(ctx, "ghu_format");
|
|
328
|
+
mockUsageOk(ctx);
|
|
329
|
+
const plugin = await loadPlugin();
|
|
330
|
+
plugin.probe(ctx);
|
|
331
|
+
const call = ctx.host.http.request.mock.calls[0][0];
|
|
332
|
+
expect(call.headers.Authorization).toMatch(/^token /);
|
|
333
|
+
expect(call.headers.Authorization).not.toMatch(/^Bearer /);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("includes correct User-Agent and editor headers", async () => {
|
|
337
|
+
const ctx = makePluginTestContext();
|
|
338
|
+
setKeychainToken(ctx, "tok");
|
|
339
|
+
mockUsageOk(ctx);
|
|
340
|
+
const plugin = await loadPlugin();
|
|
341
|
+
plugin.probe(ctx);
|
|
342
|
+
const call = ctx.host.http.request.mock.calls[0][0];
|
|
343
|
+
expect(call.headers["User-Agent"]).toBe("GitHubCopilotChat/0.26.7");
|
|
344
|
+
expect(call.headers["Editor-Version"]).toBe("vscode/1.96.2");
|
|
345
|
+
expect(call.headers["X-Github-Api-Version"]).toBe("2025-04-01");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("includes periodDurationMs on paid tier progress lines", async () => {
|
|
349
|
+
const ctx = makePluginTestContext();
|
|
350
|
+
setKeychainToken(ctx, "tok");
|
|
351
|
+
mockUsageOk(ctx);
|
|
352
|
+
const plugin = await loadPlugin();
|
|
353
|
+
const result = plugin.probe(ctx);
|
|
354
|
+
const premium = result.lines.find((l) => l.label === "Premium");
|
|
355
|
+
const chat = result.lines.find((l) => l.label === "Chat");
|
|
356
|
+
expect(premium.periodDurationMs).toBe(30 * 24 * 60 * 60 * 1000);
|
|
357
|
+
expect(chat.periodDurationMs).toBe(30 * 24 * 60 * 60 * 1000);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("renders Chat and Completions for free tier (limited_user_quotas)", async () => {
|
|
361
|
+
const ctx = makePluginTestContext();
|
|
362
|
+
setKeychainToken(ctx, "tok");
|
|
363
|
+
ctx.host.http.request.mockReturnValue({
|
|
364
|
+
status: 200,
|
|
365
|
+
bodyText: JSON.stringify({
|
|
366
|
+
copilot_plan: "individual",
|
|
367
|
+
access_type_sku: "free_limited_copilot",
|
|
368
|
+
limited_user_quotas: { chat: 410, completions: 4000 },
|
|
369
|
+
monthly_quotas: { chat: 500, completions: 4000 },
|
|
370
|
+
limited_user_reset_date: "2026-02-11",
|
|
371
|
+
}),
|
|
372
|
+
});
|
|
373
|
+
const plugin = await loadPlugin();
|
|
374
|
+
const result = plugin.probe(ctx);
|
|
375
|
+
const chat = result.lines.find((l) => l.label === "Chat");
|
|
376
|
+
const completions = result.lines.find((l) => l.label === "Completions");
|
|
377
|
+
expect(chat).toBeTruthy();
|
|
378
|
+
expect(chat.used).toBe(18); // (500 - 410) / 500 * 100 = 18%
|
|
379
|
+
expect(completions).toBeTruthy();
|
|
380
|
+
expect(completions.used).toBe(0); // (4000 - 4000) / 4000 * 100 = 0%
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("includes periodDurationMs on free tier progress lines", async () => {
|
|
384
|
+
const ctx = makePluginTestContext();
|
|
385
|
+
setKeychainToken(ctx, "tok");
|
|
386
|
+
ctx.host.http.request.mockReturnValue({
|
|
387
|
+
status: 200,
|
|
388
|
+
bodyText: JSON.stringify({
|
|
389
|
+
copilot_plan: "individual",
|
|
390
|
+
limited_user_quotas: { chat: 400, completions: 3000 },
|
|
391
|
+
monthly_quotas: { chat: 500, completions: 4000 },
|
|
392
|
+
limited_user_reset_date: "2026-02-11",
|
|
393
|
+
}),
|
|
394
|
+
});
|
|
395
|
+
const plugin = await loadPlugin();
|
|
396
|
+
const result = plugin.probe(ctx);
|
|
397
|
+
const chat = result.lines.find((l) => l.label === "Chat");
|
|
398
|
+
const completions = result.lines.find((l) => l.label === "Completions");
|
|
399
|
+
expect(chat.periodDurationMs).toBe(30 * 24 * 60 * 60 * 1000);
|
|
400
|
+
expect(completions.periodDurationMs).toBe(30 * 24 * 60 * 60 * 1000);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("propagates resetsAt from limited_user_reset_date for free tier", async () => {
|
|
404
|
+
const ctx = makePluginTestContext();
|
|
405
|
+
setKeychainToken(ctx, "tok");
|
|
406
|
+
ctx.host.http.request.mockReturnValue({
|
|
407
|
+
status: 200,
|
|
408
|
+
bodyText: JSON.stringify({
|
|
409
|
+
copilot_plan: "individual",
|
|
410
|
+
limited_user_quotas: { chat: 450, completions: 3500 },
|
|
411
|
+
monthly_quotas: { chat: 500, completions: 4000 },
|
|
412
|
+
limited_user_reset_date: "2026-02-11",
|
|
413
|
+
}),
|
|
414
|
+
});
|
|
415
|
+
const plugin = await loadPlugin();
|
|
416
|
+
const result = plugin.probe(ctx);
|
|
417
|
+
const chat = result.lines.find((l) => l.label === "Chat");
|
|
418
|
+
expect(chat.resetsAt).toBe("2026-02-11T00:00:00.000Z");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("handles free tier with partially used quotas", async () => {
|
|
422
|
+
const ctx = makePluginTestContext();
|
|
423
|
+
setKeychainToken(ctx, "tok");
|
|
424
|
+
ctx.host.http.request.mockReturnValue({
|
|
425
|
+
status: 200,
|
|
426
|
+
bodyText: JSON.stringify({
|
|
427
|
+
copilot_plan: "individual",
|
|
428
|
+
limited_user_quotas: { chat: 250, completions: 2000 },
|
|
429
|
+
monthly_quotas: { chat: 500, completions: 4000 },
|
|
430
|
+
limited_user_reset_date: "2026-02-15",
|
|
431
|
+
}),
|
|
432
|
+
});
|
|
433
|
+
const plugin = await loadPlugin();
|
|
434
|
+
const result = plugin.probe(ctx);
|
|
435
|
+
const chat = result.lines.find((l) => l.label === "Chat");
|
|
436
|
+
const completions = result.lines.find((l) => l.label === "Completions");
|
|
437
|
+
expect(chat.used).toBe(50); // 50% used
|
|
438
|
+
expect(completions.used).toBe(50); // 50% used
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("handles graceful keychain write failure", async () => {
|
|
442
|
+
const ctx = makePluginTestContext();
|
|
443
|
+
setGhCliKeychain(ctx, "gho_tok");
|
|
444
|
+
mockUsageOk(ctx);
|
|
445
|
+
ctx.host.keychain.writeGenericPassword.mockImplementation(() => {
|
|
446
|
+
throw new Error("keychain locked");
|
|
447
|
+
});
|
|
448
|
+
const plugin = await loadPlugin();
|
|
449
|
+
expect(() => plugin.probe(ctx)).not.toThrow();
|
|
450
|
+
expect(ctx.host.log.warn).toHaveBeenCalled();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("retries with gh-cli token when cached keychain token is stale", async () => {
|
|
454
|
+
const ctx = makePluginTestContext();
|
|
455
|
+
let callCount = 0;
|
|
456
|
+
// First call returns stale keychain token, second call returns fresh gh-cli token
|
|
457
|
+
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
|
|
458
|
+
if (service === "OpenUsage-copilot") {
|
|
459
|
+
return JSON.stringify({ token: "stale_token" });
|
|
460
|
+
}
|
|
461
|
+
if (service === "gh:github.com") {
|
|
462
|
+
return "fresh_gh_token";
|
|
463
|
+
}
|
|
464
|
+
return null;
|
|
465
|
+
});
|
|
466
|
+
// First request with stale token returns 401, second with fresh token succeeds
|
|
467
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
468
|
+
callCount++;
|
|
469
|
+
if (opts.headers.Authorization === "token stale_token") {
|
|
470
|
+
return { status: 401, bodyText: "" };
|
|
471
|
+
}
|
|
472
|
+
return { status: 200, bodyText: JSON.stringify(makeUsageResponse()) };
|
|
473
|
+
});
|
|
474
|
+
const plugin = await loadPlugin();
|
|
475
|
+
const result = plugin.probe(ctx);
|
|
476
|
+
expect(result.lines.find((l) => l.label === "Premium")).toBeTruthy();
|
|
477
|
+
expect(callCount).toBe(2);
|
|
478
|
+
// Should have cleared the stale token
|
|
479
|
+
expect(ctx.host.keychain.deleteGenericPassword).toHaveBeenCalledWith("OpenUsage-copilot");
|
|
480
|
+
// Should have saved the fresh token
|
|
481
|
+
expect(ctx.host.keychain.writeGenericPassword).toHaveBeenCalled();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("throws when stale keychain token and no fallback available", async () => {
|
|
485
|
+
const ctx = makePluginTestContext();
|
|
486
|
+
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
|
|
487
|
+
if (service === "OpenUsage-copilot") {
|
|
488
|
+
return JSON.stringify({ token: "stale_token" });
|
|
489
|
+
}
|
|
490
|
+
return null; // No gh-cli fallback
|
|
491
|
+
});
|
|
492
|
+
ctx.host.http.request.mockReturnValue({ status: 401, bodyText: "" });
|
|
493
|
+
const plugin = await loadPlugin();
|
|
494
|
+
expect(() => plugin.probe(ctx)).toThrow("Token invalid");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("falls back when OpenUsage keychain payload lacks token field", async () => {
|
|
498
|
+
const ctx = makePluginTestContext();
|
|
499
|
+
ctx.host.keychain.readGenericPassword.mockImplementation((service) => {
|
|
500
|
+
if (service === "OpenUsage-copilot") return JSON.stringify({ notToken: "x" });
|
|
501
|
+
if (service === "gh:github.com") return "gho_fallback";
|
|
502
|
+
return null;
|
|
503
|
+
});
|
|
504
|
+
mockUsageOk(ctx, makeUsageResponse({ copilot_plan: null }));
|
|
505
|
+
|
|
506
|
+
const plugin = await loadPlugin();
|
|
507
|
+
const result = plugin.probe(ctx);
|
|
508
|
+
expect(result.plan).toBeNull();
|
|
509
|
+
expect(result.lines.find((l) => l.label === "Premium")).toBeTruthy();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("shows status badge when free-tier quotas are present but invalid", async () => {
|
|
513
|
+
const ctx = makePluginTestContext();
|
|
514
|
+
setKeychainToken(ctx, "tok");
|
|
515
|
+
ctx.host.http.request.mockReturnValue({
|
|
516
|
+
status: 200,
|
|
517
|
+
bodyText: JSON.stringify({
|
|
518
|
+
limited_user_quotas: { chat: 10, completions: "x" },
|
|
519
|
+
monthly_quotas: { chat: 0, completions: 0 },
|
|
520
|
+
limited_user_reset_date: "2026-02-11",
|
|
521
|
+
}),
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const plugin = await loadPlugin();
|
|
525
|
+
const result = plugin.probe(ctx);
|
|
526
|
+
expect(result.lines).toHaveLength(1);
|
|
527
|
+
expect(result.lines[0].text).toBe("No usage data");
|
|
528
|
+
});
|
|
529
|
+
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M84.0704 28.9353L51.9066 10.4454C50.8738 9.85153 49.5994 9.85153 48.5666 10.4454L16.4043 28.9353C15.536 29.4345 15 30.3576 15 31.3575V68.6425C15 69.6424 15.536 70.5655 16.4043 71.0647L48.5681 89.5546C49.6009 90.1485 50.8753 90.1485 51.9081 89.5546L84.0719 71.0647C84.9402 70.5655 85.4762 69.6424 85.4762 68.6425V31.3575C85.4762 30.3576 84.9402 29.4345 84.0719 28.9353H84.0704ZM82.0501 32.8519L51.0006 86.4003C50.7907 86.7611 50.2366 86.6138 50.2366 86.1958V51.1329C50.2366 50.4322 49.8606 49.7842 49.2506 49.4324L18.7553 31.9017C18.3929 31.6927 18.5409 31.141 18.9606 31.141H81.0595C81.9414 31.141 82.4925 32.0927 82.0516 32.8534H82.0501V32.8519Z" fill="currentColor"/>
|
|
3
|
+
</svg>
|