@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +88 -88
  2. package/dist/opencode-anthropic-auth-cli.mjs +804 -507
  3. package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
  4. package/package.json +67 -59
  5. package/src/__tests__/billing-edge-cases.test.ts +59 -59
  6. package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
  7. package/src/__tests__/cc-comparison.test.ts +87 -87
  8. package/src/__tests__/cc-credentials.test.ts +254 -250
  9. package/src/__tests__/cch-drift-checker.test.ts +51 -51
  10. package/src/__tests__/cch-native-style.test.ts +56 -56
  11. package/src/__tests__/debug-gating.test.ts +42 -42
  12. package/src/__tests__/decomposition-smoke.test.ts +68 -68
  13. package/src/__tests__/fingerprint-regression.test.ts +575 -566
  14. package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
  15. package/src/__tests__/helpers/conversation-history.ts +119 -119
  16. package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
  17. package/src/__tests__/helpers/deferred.ts +69 -69
  18. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
  19. package/src/__tests__/helpers/in-memory-storage.ts +88 -88
  20. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
  21. package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
  22. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
  23. package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
  24. package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
  25. package/src/__tests__/helpers/sse.ts +209 -209
  26. package/src/__tests__/index.parallel.test.ts +605 -595
  27. package/src/__tests__/sanitization-regex.test.ts +112 -112
  28. package/src/__tests__/state-bounds.test.ts +90 -90
  29. package/src/account-identity.test.ts +197 -192
  30. package/src/account-identity.ts +69 -67
  31. package/src/account-state.test.ts +86 -86
  32. package/src/account-state.ts +25 -25
  33. package/src/accounts/matching.test.ts +335 -0
  34. package/src/accounts/matching.ts +167 -0
  35. package/src/accounts/persistence.test.ts +345 -0
  36. package/src/accounts/persistence.ts +432 -0
  37. package/src/accounts/repair.test.ts +276 -0
  38. package/src/accounts/repair.ts +407 -0
  39. package/src/accounts.dedup.test.ts +621 -621
  40. package/src/accounts.test.ts +933 -929
  41. package/src/accounts.ts +633 -989
  42. package/src/backoff.test.ts +345 -345
  43. package/src/backoff.ts +219 -219
  44. package/src/betas.ts +124 -124
  45. package/src/bun-fetch.test.ts +345 -342
  46. package/src/bun-fetch.ts +424 -424
  47. package/src/bun-proxy.test.ts +25 -25
  48. package/src/bun-proxy.ts +209 -209
  49. package/src/cc-credentials.ts +111 -111
  50. package/src/circuit-breaker.test.ts +184 -184
  51. package/src/circuit-breaker.ts +169 -169
  52. package/src/cli/commands/auth.ts +963 -0
  53. package/src/cli/commands/config.ts +547 -0
  54. package/src/cli/formatting.test.ts +406 -0
  55. package/src/cli/formatting.ts +219 -0
  56. package/src/cli.ts +255 -2022
  57. package/src/commands/handlers/betas.ts +100 -0
  58. package/src/commands/handlers/config.ts +99 -0
  59. package/src/commands/handlers/files.ts +375 -0
  60. package/src/commands/oauth-flow.ts +181 -166
  61. package/src/commands/prompts.ts +61 -61
  62. package/src/commands/router.test.ts +421 -0
  63. package/src/commands/router.ts +143 -635
  64. package/src/config.test.ts +482 -482
  65. package/src/config.ts +412 -404
  66. package/src/constants.ts +48 -48
  67. package/src/drift/cch-constants.ts +95 -95
  68. package/src/env.ts +111 -105
  69. package/src/headers/billing.ts +33 -33
  70. package/src/headers/builder.ts +130 -130
  71. package/src/headers/cch.ts +75 -75
  72. package/src/headers/stainless.ts +25 -25
  73. package/src/headers/user-agent.ts +23 -23
  74. package/src/index.ts +436 -828
  75. package/src/models.ts +27 -27
  76. package/src/oauth.test.ts +102 -102
  77. package/src/oauth.ts +178 -178
  78. package/src/parent-pid-watcher.test.ts +148 -148
  79. package/src/parent-pid-watcher.ts +69 -69
  80. package/src/plugin-helpers.ts +82 -82
  81. package/src/refresh-helpers.ts +145 -139
  82. package/src/refresh-lock.test.ts +94 -94
  83. package/src/refresh-lock.ts +93 -93
  84. package/src/request/body.history.test.ts +579 -571
  85. package/src/request/body.ts +255 -255
  86. package/src/request/metadata.ts +65 -65
  87. package/src/request/retry.test.ts +156 -156
  88. package/src/request/retry.ts +67 -67
  89. package/src/request/url.ts +21 -21
  90. package/src/request-orchestration-helpers.ts +648 -0
  91. package/src/response/index.ts +5 -5
  92. package/src/response/mcp.ts +58 -58
  93. package/src/response/streaming.test.ts +313 -311
  94. package/src/response/streaming.ts +412 -410
  95. package/src/rotation.test.ts +304 -301
  96. package/src/rotation.ts +205 -205
  97. package/src/storage.test.ts +547 -547
  98. package/src/storage.ts +315 -291
  99. package/src/system-prompt/builder.ts +38 -38
  100. package/src/system-prompt/index.ts +5 -5
  101. package/src/system-prompt/normalize.ts +60 -60
  102. package/src/system-prompt/sanitize.ts +30 -30
  103. package/src/thinking.ts +21 -20
  104. package/src/token-refresh.test.ts +265 -265
  105. package/src/token-refresh.ts +219 -214
  106. package/src/types.ts +30 -30
  107. package/dist/bun-proxy.mjs +0 -291
@@ -1,36 +1,36 @@
1
1
  import { appendFileSync, existsSync, promises as fs, readFileSync, writeFileSync } from "node:fs";
2
2
  import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
3
3
  import {
4
- clearAccounts,
5
- createDefaultStats,
6
- deduplicateByRefreshToken,
7
- ensureGitignore,
8
- getStoragePath,
9
- loadAccounts,
10
- saveAccounts,
4
+ clearAccounts,
5
+ createDefaultStats,
6
+ deduplicateByRefreshToken,
7
+ ensureGitignore,
8
+ getStoragePath,
9
+ loadAccounts,
10
+ saveAccounts,
11
11
  } from "./storage.js";
12
12
  import type { AccountMetadata, AccountStorage } from "./storage.js";
13
13
 
14
14
  // Mock fs modules
15
15
  vi.mock("node:fs", () => ({
16
- existsSync: vi.fn(),
17
- readFileSync: vi.fn(),
18
- appendFileSync: vi.fn(),
19
- writeFileSync: vi.fn(),
20
- promises: {
21
- readFile: vi.fn(),
22
- writeFile: vi.fn(),
23
- rename: vi.fn(),
24
- chmod: vi.fn(),
25
- unlink: vi.fn(),
26
- mkdir: vi.fn(),
27
- },
16
+ existsSync: vi.fn(),
17
+ readFileSync: vi.fn(),
18
+ appendFileSync: vi.fn(),
19
+ writeFileSync: vi.fn(),
20
+ promises: {
21
+ readFile: vi.fn(),
22
+ writeFile: vi.fn(),
23
+ rename: vi.fn(),
24
+ chmod: vi.fn(),
25
+ unlink: vi.fn(),
26
+ mkdir: vi.fn(),
27
+ },
28
28
  }));
29
29
 
30
30
  vi.mock("node:crypto", () => ({
31
- randomBytes: vi.fn(() => ({
32
- toString: () => "abcdef123456",
33
- })),
31
+ randomBytes: vi.fn(() => ({
32
+ toString: () => "abcdef123456",
33
+ })),
34
34
  }));
35
35
 
36
36
  const mockExistsSync = existsSync as Mock;
@@ -43,32 +43,32 @@ const mockFsChmod = fs.chmod as Mock;
43
43
  const mockFsUnlink = fs.unlink as Mock;
44
44
 
45
45
  function makeAccount(overrides: Partial<AccountMetadata> & Pick<AccountMetadata, "refreshToken">): AccountMetadata {
46
- const addedAt = overrides.addedAt ?? 1000;
47
-
48
- return {
49
- id: overrides.id ?? `${addedAt}:${overrides.refreshToken.slice(0, 12)}`,
50
- refreshToken: overrides.refreshToken,
51
- token_updated_at: overrides.token_updated_at ?? addedAt,
52
- addedAt,
53
- lastUsed: overrides.lastUsed ?? 0,
54
- enabled: overrides.enabled ?? true,
55
- rateLimitResetTimes: overrides.rateLimitResetTimes ?? {},
56
- consecutiveFailures: overrides.consecutiveFailures ?? 0,
57
- lastFailureTime: overrides.lastFailureTime ?? null,
58
- stats: overrides.stats ?? createDefaultStats(addedAt),
59
- email: overrides.email,
60
- identity: overrides.identity,
61
- label: overrides.label,
62
- access: overrides.access,
63
- expires: overrides.expires,
64
- lastSwitchReason: overrides.lastSwitchReason,
65
- source: overrides.source,
66
- };
46
+ const addedAt = overrides.addedAt ?? 1000;
47
+
48
+ return {
49
+ id: overrides.id ?? `${addedAt}:${overrides.refreshToken.slice(0, 12)}`,
50
+ refreshToken: overrides.refreshToken,
51
+ token_updated_at: overrides.token_updated_at ?? addedAt,
52
+ addedAt,
53
+ lastUsed: overrides.lastUsed ?? 0,
54
+ enabled: overrides.enabled ?? true,
55
+ rateLimitResetTimes: overrides.rateLimitResetTimes ?? {},
56
+ consecutiveFailures: overrides.consecutiveFailures ?? 0,
57
+ lastFailureTime: overrides.lastFailureTime ?? null,
58
+ stats: overrides.stats ?? createDefaultStats(addedAt),
59
+ email: overrides.email,
60
+ identity: overrides.identity,
61
+ label: overrides.label,
62
+ access: overrides.access,
63
+ expires: overrides.expires,
64
+ lastSwitchReason: overrides.lastSwitchReason,
65
+ source: overrides.source,
66
+ };
67
67
  }
68
68
 
69
69
  function expectLoaded(result: AccountStorage | null): AccountStorage {
70
- expect(result).not.toBeNull();
71
- return result as AccountStorage;
70
+ expect(result).not.toBeNull();
71
+ return result as AccountStorage;
72
72
  }
73
73
 
74
74
  // ---------------------------------------------------------------------------
@@ -76,41 +76,41 @@ function expectLoaded(result: AccountStorage | null): AccountStorage {
76
76
  // ---------------------------------------------------------------------------
77
77
 
78
78
  describe("deduplicateByRefreshToken", () => {
79
- it("returns empty array for empty input", () => {
80
- expect(deduplicateByRefreshToken([])).toEqual([]);
81
- });
82
-
83
- it("returns single account unchanged", () => {
84
- const accounts = [makeAccount({ refreshToken: "token1", addedAt: 1000, lastUsed: 2000 })];
85
- const result = deduplicateByRefreshToken(accounts);
86
- expect(result).toHaveLength(1);
87
- expect(result[0].refreshToken).toBe("token1");
88
- });
89
-
90
- it("keeps most recently used when duplicates exist", () => {
91
- const accounts = [
92
- makeAccount({ refreshToken: "token1", addedAt: 1000, lastUsed: 1000 }),
93
- makeAccount({ refreshToken: "token1", addedAt: 2000, lastUsed: 5000 }),
94
- ];
95
- const result = deduplicateByRefreshToken(accounts);
96
- expect(result).toHaveLength(1);
97
- expect(result[0].lastUsed).toBe(5000);
98
- });
99
-
100
- it("keeps different tokens as separate accounts", () => {
101
- const accounts = [
102
- makeAccount({ refreshToken: "token1", addedAt: 1000, lastUsed: 1000 }),
103
- makeAccount({ refreshToken: "token2", addedAt: 2000, lastUsed: 2000 }),
104
- ];
105
- const result = deduplicateByRefreshToken(accounts);
106
- expect(result).toHaveLength(2);
107
- });
108
-
109
- it("skips accounts without refreshToken", () => {
110
- const accounts = [makeAccount({ refreshToken: "", addedAt: 1000, lastUsed: 1000 })];
111
- const result = deduplicateByRefreshToken(accounts);
112
- expect(result).toHaveLength(0);
113
- });
79
+ it("returns empty array for empty input", () => {
80
+ expect(deduplicateByRefreshToken([])).toEqual([]);
81
+ });
82
+
83
+ it("returns single account unchanged", () => {
84
+ const accounts = [makeAccount({ refreshToken: "token1", addedAt: 1000, lastUsed: 2000 })];
85
+ const result = deduplicateByRefreshToken(accounts);
86
+ expect(result).toHaveLength(1);
87
+ expect(result[0].refreshToken).toBe("token1");
88
+ });
89
+
90
+ it("keeps most recently used when duplicates exist", () => {
91
+ const accounts = [
92
+ makeAccount({ refreshToken: "token1", addedAt: 1000, lastUsed: 1000 }),
93
+ makeAccount({ refreshToken: "token1", addedAt: 2000, lastUsed: 5000 }),
94
+ ];
95
+ const result = deduplicateByRefreshToken(accounts);
96
+ expect(result).toHaveLength(1);
97
+ expect(result[0].lastUsed).toBe(5000);
98
+ });
99
+
100
+ it("keeps different tokens as separate accounts", () => {
101
+ const accounts = [
102
+ makeAccount({ refreshToken: "token1", addedAt: 1000, lastUsed: 1000 }),
103
+ makeAccount({ refreshToken: "token2", addedAt: 2000, lastUsed: 2000 }),
104
+ ];
105
+ const result = deduplicateByRefreshToken(accounts);
106
+ expect(result).toHaveLength(2);
107
+ });
108
+
109
+ it("skips accounts without refreshToken", () => {
110
+ const accounts = [makeAccount({ refreshToken: "", addedAt: 1000, lastUsed: 1000 })];
111
+ const result = deduplicateByRefreshToken(accounts);
112
+ expect(result).toHaveLength(0);
113
+ });
114
114
  });
115
115
 
116
116
  // ---------------------------------------------------------------------------
@@ -118,46 +118,46 @@ describe("deduplicateByRefreshToken", () => {
118
118
  // ---------------------------------------------------------------------------
119
119
 
120
120
  describe("ensureGitignore", () => {
121
- beforeEach(() => {
122
- vi.resetAllMocks();
123
- });
124
-
125
- it("creates new .gitignore when none exists", () => {
126
- mockExistsSync.mockReturnValue(false);
127
- ensureGitignore("/config/dir");
128
- expect(writeFileSync).toHaveBeenCalledWith(
129
- expect.stringMatching(/[\\/]config[\\/]dir[\\/]\.gitignore$/),
130
- expect.stringContaining("anthropic-accounts.json"),
131
- "utf-8",
132
- );
133
- });
134
-
135
- it("appends missing entries to existing .gitignore", () => {
136
- mockExistsSync.mockReturnValue(true);
137
- mockReadFileSync.mockReturnValue("some-other-file\n");
138
- ensureGitignore("/config/dir");
139
- expect(appendFileSync).toHaveBeenCalledWith(
140
- expect.stringMatching(/[\\/]config[\\/]dir[\\/]\.gitignore$/),
141
- expect.stringContaining("anthropic-accounts.json"),
142
- "utf-8",
143
- );
144
- });
145
-
146
- it("does nothing when all entries already present", () => {
147
- mockExistsSync.mockReturnValue(true);
148
- mockReadFileSync.mockReturnValue(".gitignore\nanthropic-accounts.json\nanthropic-accounts.json.*.tmp\n");
149
- ensureGitignore("/config/dir");
150
- expect(appendFileSync).not.toHaveBeenCalled();
151
- expect(writeFileSync).not.toHaveBeenCalled();
152
- });
153
-
154
- it("handles errors gracefully", () => {
155
- mockExistsSync.mockImplementation(() => {
156
- throw new Error("permission denied");
157
- });
158
- // Should not throw
159
- expect(() => ensureGitignore("/config/dir")).not.toThrow();
160
- });
121
+ beforeEach(() => {
122
+ vi.resetAllMocks();
123
+ });
124
+
125
+ it("creates new .gitignore when none exists", () => {
126
+ mockExistsSync.mockReturnValue(false);
127
+ ensureGitignore("/config/dir");
128
+ expect(writeFileSync).toHaveBeenCalledWith(
129
+ expect.stringMatching(/[\\/]config[\\/]dir[\\/]\.gitignore$/),
130
+ expect.stringContaining("anthropic-accounts.json"),
131
+ "utf-8",
132
+ );
133
+ });
134
+
135
+ it("appends missing entries to existing .gitignore", () => {
136
+ mockExistsSync.mockReturnValue(true);
137
+ mockReadFileSync.mockReturnValue("some-other-file\n");
138
+ ensureGitignore("/config/dir");
139
+ expect(appendFileSync).toHaveBeenCalledWith(
140
+ expect.stringMatching(/[\\/]config[\\/]dir[\\/]\.gitignore$/),
141
+ expect.stringContaining("anthropic-accounts.json"),
142
+ "utf-8",
143
+ );
144
+ });
145
+
146
+ it("does nothing when all entries already present", () => {
147
+ mockExistsSync.mockReturnValue(true);
148
+ mockReadFileSync.mockReturnValue(".gitignore\nanthropic-accounts.json\nanthropic-accounts.json.*.tmp\n");
149
+ ensureGitignore("/config/dir");
150
+ expect(appendFileSync).not.toHaveBeenCalled();
151
+ expect(writeFileSync).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it("handles errors gracefully", () => {
155
+ mockExistsSync.mockImplementation(() => {
156
+ throw new Error("permission denied");
157
+ });
158
+ // Should not throw
159
+ expect(() => ensureGitignore("/config/dir")).not.toThrow();
160
+ });
161
161
  });
162
162
 
163
163
  // ---------------------------------------------------------------------------
@@ -165,10 +165,10 @@ describe("ensureGitignore", () => {
165
165
  // ---------------------------------------------------------------------------
166
166
 
167
167
  describe("getStoragePath", () => {
168
- it("returns path ending with anthropic-accounts.json", () => {
169
- const path = getStoragePath();
170
- expect(path.endsWith("anthropic-accounts.json")).toBe(true);
171
- });
168
+ it("returns path ending with anthropic-accounts.json", () => {
169
+ const path = getStoragePath();
170
+ expect(path.endsWith("anthropic-accounts.json")).toBe(true);
171
+ });
172
172
  });
173
173
 
174
174
  // ---------------------------------------------------------------------------
@@ -176,160 +176,160 @@ describe("getStoragePath", () => {
176
176
  // ---------------------------------------------------------------------------
177
177
 
178
178
  describe("loadAccounts", () => {
179
- beforeEach(() => {
180
- vi.resetAllMocks();
181
- });
182
-
183
- it("returns null when file does not exist", async () => {
184
- mockFsReadFile.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
185
- const result = await loadAccounts();
186
- expect(result).toBeNull();
187
- });
188
-
189
- it("returns null for invalid JSON", async () => {
190
- mockFsReadFile.mockResolvedValue("not json");
191
- const result = await loadAccounts();
192
- expect(result).toBeNull();
193
- });
194
-
195
- it("warns and returns best-effort data for unknown version", async () => {
196
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
197
- mockFsReadFile.mockResolvedValue(
198
- JSON.stringify({
199
- version: 99,
200
- accounts: [{ refreshToken: "token1", enabled: false }],
201
- activeIndex: 0,
202
- }),
203
- );
204
-
205
- const result = await loadAccounts();
206
-
207
- expect(warn).toHaveBeenCalledWith("Storage version mismatch: 99 vs 1. Attempting best-effort migration.");
208
- expect(result).not.toBeNull();
209
- expect(result?.version).toBe(1);
210
- expect(result?.accounts).toHaveLength(1);
211
- expect(result?.accounts[0]?.refreshToken).toBe("token1");
212
- expect(result?.accounts[0]?.enabled).toBe(false);
213
-
214
- warn.mockRestore();
215
- });
216
-
217
- it("returns null when accounts is not an array", async () => {
218
- mockFsReadFile.mockResolvedValue(JSON.stringify({ version: 1, accounts: "not-array" }));
219
- const result = await loadAccounts();
220
- expect(result).toBeNull();
221
- });
222
-
223
- it("loads valid accounts", async () => {
224
- mockFsReadFile.mockResolvedValue(
225
- JSON.stringify({
226
- version: 1,
227
- accounts: [
228
- {
229
- refreshToken: "token1",
230
- access: "access1",
231
- expires: 9999999999,
232
- addedAt: 1000,
233
- lastUsed: 2000,
234
- enabled: true,
235
- rateLimitResetTimes: {},
236
- consecutiveFailures: 0,
237
- lastFailureTime: null,
238
- },
239
- ],
240
- activeIndex: 0,
241
- }),
242
- );
243
- const result = expectLoaded(await loadAccounts());
244
- expect(result.accounts).toHaveLength(1);
245
- expect(result.accounts[0].refreshToken).toBe("token1");
246
- expect(result.accounts[0].access).toBe("access1");
247
- expect(result.accounts[0].expires).toBe(9999999999);
248
- expect(result.activeIndex).toBe(0);
249
- });
250
-
251
- it("preserves stored source values and leaves missing source undefined", async () => {
252
- mockFsReadFile.mockResolvedValue(
253
- JSON.stringify({
254
- version: 1,
255
- accounts: [
256
- {
257
- refreshToken: "token1",
258
- source: "cc-file",
259
- label: "Imported Claude Code",
260
- },
261
- {
262
- refreshToken: "token2",
263
- },
264
- ],
265
- activeIndex: 0,
266
- }),
267
- );
268
-
269
- const result = expectLoaded(await loadAccounts());
270
-
271
- expect(result.accounts[0]?.source).toBe("cc-file");
272
- expect(result.accounts[0]?.label).toBe("Imported Claude Code");
273
- expect(result.accounts[1]?.source).toBeUndefined();
274
- });
275
-
276
- it("filters out invalid accounts (missing refreshToken)", async () => {
277
- mockFsReadFile.mockResolvedValue(
278
- JSON.stringify({
279
- version: 1,
280
- accounts: [{ refreshToken: "valid", addedAt: 1000 }, { email: "no-token" }, null],
281
- activeIndex: 0,
282
- }),
283
- );
284
- const result = expectLoaded(await loadAccounts());
285
- expect(result.accounts).toHaveLength(1);
286
- expect(result.accounts[0].refreshToken).toBe("valid");
287
- });
288
-
289
- it("clamps activeIndex to valid range", async () => {
290
- mockFsReadFile.mockResolvedValue(
291
- JSON.stringify({
292
- version: 1,
293
- accounts: [{ refreshToken: "token1" }],
294
- activeIndex: 99,
295
- }),
296
- );
297
- const result = expectLoaded(await loadAccounts());
298
- expect(result.activeIndex).toBe(0);
299
- });
300
-
301
- it("deduplicates accounts by refresh token", async () => {
302
- mockFsReadFile.mockResolvedValue(
303
- JSON.stringify({
304
- version: 1,
305
- accounts: [
306
- { refreshToken: "token1", lastUsed: 1000 },
307
- { refreshToken: "token1", lastUsed: 5000 },
308
- ],
309
- activeIndex: 0,
310
- }),
311
- );
312
- const result = expectLoaded(await loadAccounts());
313
- expect(result.accounts).toHaveLength(1);
314
- expect(result.accounts[0].lastUsed).toBe(5000);
315
- });
316
-
317
- it("applies defaults for missing fields", async () => {
318
- mockFsReadFile.mockResolvedValue(
319
- JSON.stringify({
320
- version: 1,
321
- accounts: [{ refreshToken: "token1" }],
322
- activeIndex: 0,
323
- }),
324
- );
325
- const result = expectLoaded(await loadAccounts());
326
- const acc = result.accounts[0]!;
327
- expect(acc.enabled).toBe(true);
328
- expect(acc.consecutiveFailures).toBe(0);
329
- expect(acc.lastFailureTime).toBeNull();
330
- expect(acc.rateLimitResetTimes).toEqual({});
331
- expect(acc.lastUsed).toBe(0);
332
- });
179
+ beforeEach(() => {
180
+ vi.resetAllMocks();
181
+ });
182
+
183
+ it("returns null when file does not exist", async () => {
184
+ mockFsReadFile.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
185
+ const result = await loadAccounts();
186
+ expect(result).toBeNull();
187
+ });
188
+
189
+ it("returns null for invalid JSON", async () => {
190
+ mockFsReadFile.mockResolvedValue("not json");
191
+ const result = await loadAccounts();
192
+ expect(result).toBeNull();
193
+ });
194
+
195
+ it("warns and returns best-effort data for unknown version", async () => {
196
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
197
+ mockFsReadFile.mockResolvedValue(
198
+ JSON.stringify({
199
+ version: 99,
200
+ accounts: [{ refreshToken: "token1", enabled: false }],
201
+ activeIndex: 0,
202
+ }),
203
+ );
204
+
205
+ const result = await loadAccounts();
206
+
207
+ expect(warn).toHaveBeenCalledWith("Storage version mismatch: 99 vs 1. Attempting best-effort migration.");
208
+ expect(result).not.toBeNull();
209
+ expect(result?.version).toBe(1);
210
+ expect(result?.accounts).toHaveLength(1);
211
+ expect(result?.accounts[0]?.refreshToken).toBe("token1");
212
+ expect(result?.accounts[0]?.enabled).toBe(false);
213
+
214
+ warn.mockRestore();
215
+ });
216
+
217
+ it("returns null when accounts is not an array", async () => {
218
+ mockFsReadFile.mockResolvedValue(JSON.stringify({ version: 1, accounts: "not-array" }));
219
+ const result = await loadAccounts();
220
+ expect(result).toBeNull();
221
+ });
222
+
223
+ it("loads valid accounts", async () => {
224
+ mockFsReadFile.mockResolvedValue(
225
+ JSON.stringify({
226
+ version: 1,
227
+ accounts: [
228
+ {
229
+ refreshToken: "token1",
230
+ access: "access1",
231
+ expires: 9999999999,
232
+ addedAt: 1000,
233
+ lastUsed: 2000,
234
+ enabled: true,
235
+ rateLimitResetTimes: {},
236
+ consecutiveFailures: 0,
237
+ lastFailureTime: null,
238
+ },
239
+ ],
240
+ activeIndex: 0,
241
+ }),
242
+ );
243
+ const result = expectLoaded(await loadAccounts());
244
+ expect(result.accounts).toHaveLength(1);
245
+ expect(result.accounts[0].refreshToken).toBe("token1");
246
+ expect(result.accounts[0].access).toBe("access1");
247
+ expect(result.accounts[0].expires).toBe(9999999999);
248
+ expect(result.activeIndex).toBe(0);
249
+ });
250
+
251
+ it("preserves stored source values and leaves missing source undefined", async () => {
252
+ mockFsReadFile.mockResolvedValue(
253
+ JSON.stringify({
254
+ version: 1,
255
+ accounts: [
256
+ {
257
+ refreshToken: "token1",
258
+ source: "cc-file",
259
+ label: "Imported Claude Code",
260
+ },
261
+ {
262
+ refreshToken: "token2",
263
+ },
264
+ ],
265
+ activeIndex: 0,
266
+ }),
267
+ );
268
+
269
+ const result = expectLoaded(await loadAccounts());
270
+
271
+ expect(result.accounts[0]?.source).toBe("cc-file");
272
+ expect(result.accounts[0]?.label).toBe("Imported Claude Code");
273
+ expect(result.accounts[1]?.source).toBeUndefined();
274
+ });
275
+
276
+ it("filters out invalid accounts (missing refreshToken)", async () => {
277
+ mockFsReadFile.mockResolvedValue(
278
+ JSON.stringify({
279
+ version: 1,
280
+ accounts: [{ refreshToken: "valid", addedAt: 1000 }, { email: "no-token" }, null],
281
+ activeIndex: 0,
282
+ }),
283
+ );
284
+ const result = expectLoaded(await loadAccounts());
285
+ expect(result.accounts).toHaveLength(1);
286
+ expect(result.accounts[0].refreshToken).toBe("valid");
287
+ });
288
+
289
+ it("clamps activeIndex to valid range", async () => {
290
+ mockFsReadFile.mockResolvedValue(
291
+ JSON.stringify({
292
+ version: 1,
293
+ accounts: [{ refreshToken: "token1" }],
294
+ activeIndex: 99,
295
+ }),
296
+ );
297
+ const result = expectLoaded(await loadAccounts());
298
+ expect(result.activeIndex).toBe(0);
299
+ });
300
+
301
+ it("deduplicates accounts by refresh token", async () => {
302
+ mockFsReadFile.mockResolvedValue(
303
+ JSON.stringify({
304
+ version: 1,
305
+ accounts: [
306
+ { refreshToken: "token1", lastUsed: 1000 },
307
+ { refreshToken: "token1", lastUsed: 5000 },
308
+ ],
309
+ activeIndex: 0,
310
+ }),
311
+ );
312
+ const result = expectLoaded(await loadAccounts());
313
+ expect(result.accounts).toHaveLength(1);
314
+ expect(result.accounts[0].lastUsed).toBe(5000);
315
+ });
316
+
317
+ it("applies defaults for missing fields", async () => {
318
+ mockFsReadFile.mockResolvedValue(
319
+ JSON.stringify({
320
+ version: 1,
321
+ accounts: [{ refreshToken: "token1" }],
322
+ activeIndex: 0,
323
+ }),
324
+ );
325
+ const result = expectLoaded(await loadAccounts());
326
+ const acc = result.accounts[0]!;
327
+ expect(acc.enabled).toBe(true);
328
+ expect(acc.consecutiveFailures).toBe(0);
329
+ expect(acc.lastFailureTime).toBeNull();
330
+ expect(acc.rateLimitResetTimes).toEqual({});
331
+ expect(acc.lastUsed).toBe(0);
332
+ });
333
333
  });
334
334
 
335
335
  // ---------------------------------------------------------------------------
@@ -337,180 +337,180 @@ describe("loadAccounts", () => {
337
337
  // ---------------------------------------------------------------------------
338
338
 
339
339
  describe("saveAccounts", () => {
340
- beforeEach(() => {
341
- vi.resetAllMocks();
342
- mockExistsSync.mockReturnValue(true);
343
- mockReadFileSync.mockReturnValue(".gitignore\nanthropic-accounts.json\nanthropic-accounts.json.*.tmp\n");
344
- mockFsMkdir.mockResolvedValue(undefined);
345
- mockFsWriteFile.mockResolvedValue(undefined);
346
- mockFsRename.mockResolvedValue(undefined);
347
- mockFsChmod.mockResolvedValue(undefined);
348
- });
349
-
350
- it("writes atomically via temp file + rename", async () => {
351
- const storage = {
352
- version: 1,
353
- accounts: [makeAccount({ refreshToken: "token1" })],
354
- activeIndex: 0,
355
- };
356
- await saveAccounts(storage);
357
-
358
- expect(mockFsWriteFile).toHaveBeenCalledWith(expect.stringContaining(".tmp"), expect.any(String), {
359
- encoding: "utf-8",
360
- mode: 0o600,
361
- });
362
- expect(mockFsRename).toHaveBeenCalled();
363
- });
364
-
365
- it("creates config directory if needed", async () => {
366
- const storage = { version: 1, accounts: [], activeIndex: 0 };
367
- await saveAccounts(storage);
368
- expect(mockFsMkdir).toHaveBeenCalledWith(expect.any(String), {
369
- recursive: true,
370
- });
371
- });
372
-
373
- it("cleans up temp file on write error", async () => {
374
- mockFsWriteFile.mockRejectedValue(new Error("disk full"));
375
- mockFsUnlink.mockResolvedValue(undefined);
376
-
377
- const storage = { version: 1, accounts: [], activeIndex: 0 };
378
- await expect(saveAccounts(storage)).rejects.toThrow("disk full");
379
- expect(mockFsUnlink).toHaveBeenCalledWith(expect.stringContaining(".tmp"));
380
- });
381
-
382
- it("merges auth fields from fresher disk state", async () => {
383
- mockFsReadFile.mockResolvedValue(
384
- JSON.stringify({
385
- version: 1,
386
- accounts: [
387
- {
388
- id: "a1",
389
- refreshToken: "disk-refresh",
390
- access: "disk-access",
391
- expires: 999999,
392
- token_updated_at: 2000,
393
- addedAt: 1000,
394
- lastUsed: 0,
395
- enabled: true,
396
- rateLimitResetTimes: {},
397
- consecutiveFailures: 0,
398
- lastFailureTime: null,
399
- stats: createDefaultStats(1000),
400
- },
401
- ],
402
- activeIndex: 0,
403
- }),
404
- );
405
-
406
- const storage = {
407
- version: 1,
408
- accounts: [
409
- {
410
- id: "a1",
411
- refreshToken: "mem-refresh",
412
- access: "mem-access",
413
- expires: 123,
414
- token_updated_at: 1000,
415
- addedAt: 1000,
416
- lastUsed: 0,
417
- enabled: true,
418
- rateLimitResetTimes: {},
419
- consecutiveFailures: 0,
420
- lastFailureTime: null,
421
- stats: createDefaultStats(1000),
422
- },
423
- ],
424
- activeIndex: 0,
425
- };
340
+ beforeEach(() => {
341
+ vi.resetAllMocks();
342
+ mockExistsSync.mockReturnValue(true);
343
+ mockReadFileSync.mockReturnValue(".gitignore\nanthropic-accounts.json\nanthropic-accounts.json.*.tmp\n");
344
+ mockFsMkdir.mockResolvedValue(undefined);
345
+ mockFsWriteFile.mockResolvedValue(undefined);
346
+ mockFsRename.mockResolvedValue(undefined);
347
+ mockFsChmod.mockResolvedValue(undefined);
348
+ });
426
349
 
427
- await saveAccounts(storage);
428
-
429
- const written = JSON.parse(mockFsWriteFile.mock.calls[0][1]);
430
- expect(written.accounts[0].refreshToken).toBe("disk-refresh");
431
- expect(written.accounts[0].access).toBe("disk-access");
432
- expect(written.accounts[0].expires).toBe(999999);
433
- expect(written.accounts[0].token_updated_at).toBe(2000);
434
- });
435
-
436
- it("matches id-less disk accounts by addedAt during freshness merge", async () => {
437
- mockFsReadFile.mockResolvedValue(
438
- JSON.stringify({
439
- version: 1,
440
- accounts: [
441
- {
442
- refreshToken: "disk-refresh-rotated",
443
- access: "disk-access",
444
- expires: 888888,
445
- token_updated_at: 3000,
446
- addedAt: 1111,
447
- lastUsed: 0,
448
- enabled: true,
449
- rateLimitResetTimes: {},
450
- consecutiveFailures: 0,
451
- lastFailureTime: null,
452
- stats: createDefaultStats(1111),
453
- },
454
- ],
455
- activeIndex: 0,
456
- }),
457
- );
458
-
459
- const storage = {
460
- version: 1,
461
- accounts: [
462
- {
463
- id: "legacy-a1",
464
- refreshToken: "old-refresh",
465
- access: "old-access",
466
- expires: 111,
467
- token_updated_at: 1000,
468
- addedAt: 1111,
469
- lastUsed: 0,
470
- enabled: true,
471
- rateLimitResetTimes: {},
472
- consecutiveFailures: 0,
473
- lastFailureTime: null,
474
- stats: createDefaultStats(1111),
475
- },
476
- ],
477
- activeIndex: 0,
478
- };
350
+ it("writes atomically via temp file + rename", async () => {
351
+ const storage = {
352
+ version: 1,
353
+ accounts: [makeAccount({ refreshToken: "token1" })],
354
+ activeIndex: 0,
355
+ };
356
+ await saveAccounts(storage);
357
+
358
+ expect(mockFsWriteFile).toHaveBeenCalledWith(expect.stringContaining(".tmp"), expect.any(String), {
359
+ encoding: "utf-8",
360
+ mode: 0o600,
361
+ });
362
+ expect(mockFsRename).toHaveBeenCalled();
363
+ });
364
+
365
+ it("creates config directory if needed", async () => {
366
+ const storage = { version: 1, accounts: [], activeIndex: 0 };
367
+ await saveAccounts(storage);
368
+ expect(mockFsMkdir).toHaveBeenCalledWith(expect.any(String), {
369
+ recursive: true,
370
+ });
371
+ });
372
+
373
+ it("cleans up temp file on write error", async () => {
374
+ mockFsWriteFile.mockRejectedValue(new Error("disk full"));
375
+ mockFsUnlink.mockResolvedValue(undefined);
376
+
377
+ const storage = { version: 1, accounts: [], activeIndex: 0 };
378
+ await expect(saveAccounts(storage)).rejects.toThrow("disk full");
379
+ expect(mockFsUnlink).toHaveBeenCalledWith(expect.stringContaining(".tmp"));
380
+ });
381
+
382
+ it("merges auth fields from fresher disk state", async () => {
383
+ mockFsReadFile.mockResolvedValue(
384
+ JSON.stringify({
385
+ version: 1,
386
+ accounts: [
387
+ {
388
+ id: "a1",
389
+ refreshToken: "disk-refresh",
390
+ access: "disk-access",
391
+ expires: 999999,
392
+ token_updated_at: 2000,
393
+ addedAt: 1000,
394
+ lastUsed: 0,
395
+ enabled: true,
396
+ rateLimitResetTimes: {},
397
+ consecutiveFailures: 0,
398
+ lastFailureTime: null,
399
+ stats: createDefaultStats(1000),
400
+ },
401
+ ],
402
+ activeIndex: 0,
403
+ }),
404
+ );
405
+
406
+ const storage = {
407
+ version: 1,
408
+ accounts: [
409
+ {
410
+ id: "a1",
411
+ refreshToken: "mem-refresh",
412
+ access: "mem-access",
413
+ expires: 123,
414
+ token_updated_at: 1000,
415
+ addedAt: 1000,
416
+ lastUsed: 0,
417
+ enabled: true,
418
+ rateLimitResetTimes: {},
419
+ consecutiveFailures: 0,
420
+ lastFailureTime: null,
421
+ stats: createDefaultStats(1000),
422
+ },
423
+ ],
424
+ activeIndex: 0,
425
+ };
426
+
427
+ await saveAccounts(storage);
428
+
429
+ const written = JSON.parse(mockFsWriteFile.mock.calls[0][1]);
430
+ expect(written.accounts[0].refreshToken).toBe("disk-refresh");
431
+ expect(written.accounts[0].access).toBe("disk-access");
432
+ expect(written.accounts[0].expires).toBe(999999);
433
+ expect(written.accounts[0].token_updated_at).toBe(2000);
434
+ });
435
+
436
+ it("matches id-less disk accounts by addedAt during freshness merge", async () => {
437
+ mockFsReadFile.mockResolvedValue(
438
+ JSON.stringify({
439
+ version: 1,
440
+ accounts: [
441
+ {
442
+ refreshToken: "disk-refresh-rotated",
443
+ access: "disk-access",
444
+ expires: 888888,
445
+ token_updated_at: 3000,
446
+ addedAt: 1111,
447
+ lastUsed: 0,
448
+ enabled: true,
449
+ rateLimitResetTimes: {},
450
+ consecutiveFailures: 0,
451
+ lastFailureTime: null,
452
+ stats: createDefaultStats(1111),
453
+ },
454
+ ],
455
+ activeIndex: 0,
456
+ }),
457
+ );
458
+
459
+ const storage = {
460
+ version: 1,
461
+ accounts: [
462
+ {
463
+ id: "legacy-a1",
464
+ refreshToken: "old-refresh",
465
+ access: "old-access",
466
+ expires: 111,
467
+ token_updated_at: 1000,
468
+ addedAt: 1111,
469
+ lastUsed: 0,
470
+ enabled: true,
471
+ rateLimitResetTimes: {},
472
+ consecutiveFailures: 0,
473
+ lastFailureTime: null,
474
+ stats: createDefaultStats(1111),
475
+ },
476
+ ],
477
+ activeIndex: 0,
478
+ };
479
+
480
+ await saveAccounts(storage);
481
+
482
+ const written = JSON.parse(mockFsWriteFile.mock.calls[0][1]);
483
+ expect(written.accounts[0].refreshToken).toBe("disk-refresh-rotated");
484
+ expect(written.accounts[0].token_updated_at).toBe(3000);
485
+ });
479
486
 
480
- await saveAccounts(storage);
481
-
482
- const written = JSON.parse(mockFsWriteFile.mock.calls[0][1]);
483
- expect(written.accounts[0].refreshToken).toBe("disk-refresh-rotated");
484
- expect(written.accounts[0].token_updated_at).toBe(3000);
485
- });
486
-
487
- it("does not resurrect accounts removed by caller", async () => {
488
- mockFsReadFile.mockResolvedValue(
489
- JSON.stringify({
490
- version: 1,
491
- accounts: [
492
- {
493
- id: "a1",
494
- refreshToken: "disk-refresh",
495
- token_updated_at: 2000,
496
- addedAt: 1000,
497
- lastUsed: 0,
498
- enabled: true,
499
- rateLimitResetTimes: {},
500
- consecutiveFailures: 0,
501
- lastFailureTime: null,
502
- stats: createDefaultStats(1000),
503
- },
504
- ],
505
- activeIndex: 0,
506
- }),
507
- );
508
-
509
- await saveAccounts({ version: 1, accounts: [], activeIndex: 0 });
510
-
511
- const written = JSON.parse(mockFsWriteFile.mock.calls[0][1]);
512
- expect(written.accounts).toEqual([]);
513
- });
487
+ it("does not resurrect accounts removed by caller", async () => {
488
+ mockFsReadFile.mockResolvedValue(
489
+ JSON.stringify({
490
+ version: 1,
491
+ accounts: [
492
+ {
493
+ id: "a1",
494
+ refreshToken: "disk-refresh",
495
+ token_updated_at: 2000,
496
+ addedAt: 1000,
497
+ lastUsed: 0,
498
+ enabled: true,
499
+ rateLimitResetTimes: {},
500
+ consecutiveFailures: 0,
501
+ lastFailureTime: null,
502
+ stats: createDefaultStats(1000),
503
+ },
504
+ ],
505
+ activeIndex: 0,
506
+ }),
507
+ );
508
+
509
+ await saveAccounts({ version: 1, accounts: [], activeIndex: 0 });
510
+
511
+ const written = JSON.parse(mockFsWriteFile.mock.calls[0][1]);
512
+ expect(written.accounts).toEqual([]);
513
+ });
514
514
  });
515
515
 
516
516
  // ---------------------------------------------------------------------------
@@ -518,25 +518,25 @@ describe("saveAccounts", () => {
518
518
  // ---------------------------------------------------------------------------
519
519
 
520
520
  describe("clearAccounts", () => {
521
- beforeEach(() => {
522
- vi.resetAllMocks();
523
- });
524
-
525
- it("deletes the storage file", async () => {
526
- mockFsUnlink.mockResolvedValue(undefined);
527
- await clearAccounts();
528
- expect(mockFsUnlink).toHaveBeenCalledWith(expect.stringContaining("anthropic-accounts.json"));
529
- });
530
-
531
- it("ignores ENOENT errors", async () => {
532
- mockFsUnlink.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
533
- await expect(clearAccounts()).resolves.toBeUndefined();
534
- });
535
-
536
- it("rethrows non-ENOENT errors", async () => {
537
- mockFsUnlink.mockRejectedValue(Object.assign(new Error("permission denied"), { code: "EACCES" }));
538
- await expect(clearAccounts()).rejects.toThrow("permission denied");
539
- });
521
+ beforeEach(() => {
522
+ vi.resetAllMocks();
523
+ });
524
+
525
+ it("deletes the storage file", async () => {
526
+ mockFsUnlink.mockResolvedValue(undefined);
527
+ await clearAccounts();
528
+ expect(mockFsUnlink).toHaveBeenCalledWith(expect.stringContaining("anthropic-accounts.json"));
529
+ });
530
+
531
+ it("ignores ENOENT errors", async () => {
532
+ mockFsUnlink.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
533
+ await expect(clearAccounts()).resolves.toBeUndefined();
534
+ });
535
+
536
+ it("rethrows non-ENOENT errors", async () => {
537
+ mockFsUnlink.mockRejectedValue(Object.assign(new Error("permission denied"), { code: "EACCES" }));
538
+ await expect(clearAccounts()).rejects.toThrow("permission denied");
539
+ });
540
540
  });
541
541
 
542
542
  // ---------------------------------------------------------------------------
@@ -544,86 +544,86 @@ describe("clearAccounts", () => {
544
544
  // ---------------------------------------------------------------------------
545
545
 
546
546
  describe("createDefaultStats", () => {
547
- it("creates zeroed stats with given timestamp", () => {
548
- const stats = createDefaultStats(1000);
549
- expect(stats).toEqual({
550
- requests: 0,
551
- inputTokens: 0,
552
- outputTokens: 0,
553
- cacheReadTokens: 0,
554
- cacheWriteTokens: 0,
555
- lastReset: 1000,
556
- });
557
- });
558
-
559
- it("uses Date.now() when no timestamp given", () => {
560
- const before = Date.now();
561
- const stats = createDefaultStats();
562
- expect(stats.lastReset).toBeGreaterThanOrEqual(before);
563
- expect(stats.lastReset).toBeLessThanOrEqual(Date.now());
564
- });
547
+ it("creates zeroed stats with given timestamp", () => {
548
+ const stats = createDefaultStats(1000);
549
+ expect(stats).toEqual({
550
+ requests: 0,
551
+ inputTokens: 0,
552
+ outputTokens: 0,
553
+ cacheReadTokens: 0,
554
+ cacheWriteTokens: 0,
555
+ lastReset: 1000,
556
+ });
557
+ });
558
+
559
+ it("uses Date.now() when no timestamp given", () => {
560
+ const before = Date.now();
561
+ const stats = createDefaultStats();
562
+ expect(stats.lastReset).toBeGreaterThanOrEqual(before);
563
+ expect(stats.lastReset).toBeLessThanOrEqual(Date.now());
564
+ });
565
565
  });
566
566
 
567
567
  describe("loadAccounts with stats", () => {
568
- it("loads accounts with stats fields preserved", async () => {
569
- const stored = {
570
- version: 1,
571
- accounts: [
572
- {
573
- refreshToken: "tok1",
574
- stats: {
575
- requests: 42,
576
- inputTokens: 10000,
577
- outputTokens: 5000,
578
- cacheReadTokens: 2000,
579
- cacheWriteTokens: 100,
580
- lastReset: 1700000000000,
581
- },
582
- },
583
- ],
584
- activeIndex: 0,
585
- };
586
- mockFsReadFile.mockResolvedValue(JSON.stringify(stored));
587
-
588
- const result = expectLoaded(await loadAccounts());
589
- expect(result.accounts[0].stats.requests).toBe(42);
590
- expect(result.accounts[0].stats.inputTokens).toBe(10000);
591
- expect(result.accounts[0].stats.outputTokens).toBe(5000);
592
- expect(result.accounts[0].stats.cacheReadTokens).toBe(2000);
593
- expect(result.accounts[0].stats.cacheWriteTokens).toBe(100);
594
- expect(result.accounts[0].stats.lastReset).toBe(1700000000000);
595
- });
596
-
597
- it("provides default stats when missing from stored data", async () => {
598
- const stored = {
599
- version: 1,
600
- accounts: [{ refreshToken: "tok1" }],
601
- activeIndex: 0,
602
- };
603
- mockFsReadFile.mockResolvedValue(JSON.stringify(stored));
604
-
605
- const result = expectLoaded(await loadAccounts());
606
- expect(result.accounts[0].stats.requests).toBe(0);
607
- expect(result.accounts[0].stats.inputTokens).toBe(0);
608
- expect(result.accounts[0].stats.outputTokens).toBe(0);
609
- });
610
-
611
- it("clamps negative stats values to 0", async () => {
612
- const stored = {
613
- version: 1,
614
- accounts: [
615
- {
616
- refreshToken: "tok1",
617
- stats: { requests: -5, inputTokens: -100, outputTokens: NaN },
618
- },
619
- ],
620
- activeIndex: 0,
621
- };
622
- mockFsReadFile.mockResolvedValue(JSON.stringify(stored));
568
+ it("loads accounts with stats fields preserved", async () => {
569
+ const stored = {
570
+ version: 1,
571
+ accounts: [
572
+ {
573
+ refreshToken: "tok1",
574
+ stats: {
575
+ requests: 42,
576
+ inputTokens: 10000,
577
+ outputTokens: 5000,
578
+ cacheReadTokens: 2000,
579
+ cacheWriteTokens: 100,
580
+ lastReset: 1700000000000,
581
+ },
582
+ },
583
+ ],
584
+ activeIndex: 0,
585
+ };
586
+ mockFsReadFile.mockResolvedValue(JSON.stringify(stored));
587
+
588
+ const result = expectLoaded(await loadAccounts());
589
+ expect(result.accounts[0].stats.requests).toBe(42);
590
+ expect(result.accounts[0].stats.inputTokens).toBe(10000);
591
+ expect(result.accounts[0].stats.outputTokens).toBe(5000);
592
+ expect(result.accounts[0].stats.cacheReadTokens).toBe(2000);
593
+ expect(result.accounts[0].stats.cacheWriteTokens).toBe(100);
594
+ expect(result.accounts[0].stats.lastReset).toBe(1700000000000);
595
+ });
596
+
597
+ it("provides default stats when missing from stored data", async () => {
598
+ const stored = {
599
+ version: 1,
600
+ accounts: [{ refreshToken: "tok1" }],
601
+ activeIndex: 0,
602
+ };
603
+ mockFsReadFile.mockResolvedValue(JSON.stringify(stored));
604
+
605
+ const result = expectLoaded(await loadAccounts());
606
+ expect(result.accounts[0].stats.requests).toBe(0);
607
+ expect(result.accounts[0].stats.inputTokens).toBe(0);
608
+ expect(result.accounts[0].stats.outputTokens).toBe(0);
609
+ });
623
610
 
624
- const result = expectLoaded(await loadAccounts());
625
- expect(result.accounts[0].stats.requests).toBe(0);
626
- expect(result.accounts[0].stats.inputTokens).toBe(0);
627
- expect(result.accounts[0].stats.outputTokens).toBe(0);
628
- });
611
+ it("clamps negative stats values to 0", async () => {
612
+ const stored = {
613
+ version: 1,
614
+ accounts: [
615
+ {
616
+ refreshToken: "tok1",
617
+ stats: { requests: -5, inputTokens: -100, outputTokens: NaN },
618
+ },
619
+ ],
620
+ activeIndex: 0,
621
+ };
622
+ mockFsReadFile.mockResolvedValue(JSON.stringify(stored));
623
+
624
+ const result = expectLoaded(await loadAccounts());
625
+ expect(result.accounts[0].stats.requests).toBe(0);
626
+ expect(result.accounts[0].stats.inputTokens).toBe(0);
627
+ expect(result.accounts[0].stats.outputTokens).toBe(0);
628
+ });
629
629
  });