@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.
- package/README.md +88 -88
- package/dist/opencode-anthropic-auth-cli.mjs +804 -507
- package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
- package/package.json +67 -59
- package/src/__tests__/billing-edge-cases.test.ts +59 -59
- package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
- package/src/__tests__/cc-comparison.test.ts +87 -87
- package/src/__tests__/cc-credentials.test.ts +254 -250
- package/src/__tests__/cch-drift-checker.test.ts +51 -51
- package/src/__tests__/cch-native-style.test.ts +56 -56
- package/src/__tests__/debug-gating.test.ts +42 -42
- package/src/__tests__/decomposition-smoke.test.ts +68 -68
- package/src/__tests__/fingerprint-regression.test.ts +575 -566
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
- package/src/__tests__/helpers/conversation-history.ts +119 -119
- package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
- package/src/__tests__/helpers/deferred.ts +69 -69
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
- package/src/__tests__/helpers/in-memory-storage.ts +88 -88
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
- package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
- package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
- package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
- package/src/__tests__/helpers/sse.ts +209 -209
- package/src/__tests__/index.parallel.test.ts +605 -595
- package/src/__tests__/sanitization-regex.test.ts +112 -112
- package/src/__tests__/state-bounds.test.ts +90 -90
- package/src/account-identity.test.ts +197 -192
- package/src/account-identity.ts +69 -67
- package/src/account-state.test.ts +86 -86
- package/src/account-state.ts +25 -25
- package/src/accounts/matching.test.ts +335 -0
- package/src/accounts/matching.ts +167 -0
- package/src/accounts/persistence.test.ts +345 -0
- package/src/accounts/persistence.ts +432 -0
- package/src/accounts/repair.test.ts +276 -0
- package/src/accounts/repair.ts +407 -0
- package/src/accounts.dedup.test.ts +621 -621
- package/src/accounts.test.ts +933 -929
- package/src/accounts.ts +633 -989
- package/src/backoff.test.ts +345 -345
- package/src/backoff.ts +219 -219
- package/src/betas.ts +124 -124
- package/src/bun-fetch.test.ts +345 -342
- package/src/bun-fetch.ts +424 -424
- package/src/bun-proxy.test.ts +25 -25
- package/src/bun-proxy.ts +209 -209
- package/src/cc-credentials.ts +111 -111
- package/src/circuit-breaker.test.ts +184 -184
- package/src/circuit-breaker.ts +169 -169
- package/src/cli/commands/auth.ts +963 -0
- package/src/cli/commands/config.ts +547 -0
- package/src/cli/formatting.test.ts +406 -0
- package/src/cli/formatting.ts +219 -0
- package/src/cli.ts +255 -2022
- package/src/commands/handlers/betas.ts +100 -0
- package/src/commands/handlers/config.ts +99 -0
- package/src/commands/handlers/files.ts +375 -0
- package/src/commands/oauth-flow.ts +181 -166
- package/src/commands/prompts.ts +61 -61
- package/src/commands/router.test.ts +421 -0
- package/src/commands/router.ts +143 -635
- package/src/config.test.ts +482 -482
- package/src/config.ts +412 -404
- package/src/constants.ts +48 -48
- package/src/drift/cch-constants.ts +95 -95
- package/src/env.ts +111 -105
- package/src/headers/billing.ts +33 -33
- package/src/headers/builder.ts +130 -130
- package/src/headers/cch.ts +75 -75
- package/src/headers/stainless.ts +25 -25
- package/src/headers/user-agent.ts +23 -23
- package/src/index.ts +436 -828
- package/src/models.ts +27 -27
- package/src/oauth.test.ts +102 -102
- package/src/oauth.ts +178 -178
- package/src/parent-pid-watcher.test.ts +148 -148
- package/src/parent-pid-watcher.ts +69 -69
- package/src/plugin-helpers.ts +82 -82
- package/src/refresh-helpers.ts +145 -139
- package/src/refresh-lock.test.ts +94 -94
- package/src/refresh-lock.ts +93 -93
- package/src/request/body.history.test.ts +579 -571
- package/src/request/body.ts +255 -255
- package/src/request/metadata.ts +65 -65
- package/src/request/retry.test.ts +156 -156
- package/src/request/retry.ts +67 -67
- package/src/request/url.ts +21 -21
- package/src/request-orchestration-helpers.ts +648 -0
- package/src/response/index.ts +5 -5
- package/src/response/mcp.ts +58 -58
- package/src/response/streaming.test.ts +313 -311
- package/src/response/streaming.ts +412 -410
- package/src/rotation.test.ts +304 -301
- package/src/rotation.ts +205 -205
- package/src/storage.test.ts +547 -547
- package/src/storage.ts +315 -291
- package/src/system-prompt/builder.ts +38 -38
- package/src/system-prompt/index.ts +5 -5
- package/src/system-prompt/normalize.ts +60 -60
- package/src/system-prompt/sanitize.ts +30 -30
- package/src/thinking.ts +21 -20
- package/src/token-refresh.test.ts +265 -265
- package/src/token-refresh.ts +219 -214
- package/src/types.ts +30 -30
- package/dist/bun-proxy.mjs +0 -291
package/src/accounts.test.ts
CHANGED
|
@@ -3,20 +3,20 @@ import { AccountManager } from "./accounts.js";
|
|
|
3
3
|
import { DEFAULT_CONFIG } from "./config.js";
|
|
4
4
|
|
|
5
5
|
vi.mock("./storage.js", () => ({
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
createDefaultStats: (now?: number) => ({
|
|
7
|
+
requests: 0,
|
|
8
|
+
inputTokens: 0,
|
|
9
|
+
outputTokens: 0,
|
|
10
|
+
cacheReadTokens: 0,
|
|
11
|
+
cacheWriteTokens: 0,
|
|
12
|
+
lastReset: now ?? Date.now(),
|
|
13
|
+
}),
|
|
14
|
+
loadAccounts: vi.fn(),
|
|
15
|
+
saveAccounts: vi.fn().mockResolvedValue(undefined),
|
|
16
16
|
}));
|
|
17
17
|
|
|
18
18
|
vi.mock("./cc-credentials.js", () => ({
|
|
19
|
-
|
|
19
|
+
readCCCredentials: vi.fn(),
|
|
20
20
|
}));
|
|
21
21
|
|
|
22
22
|
import type { ManagedAccount } from "./accounts.js";
|
|
@@ -28,52 +28,52 @@ const mockSaveAccounts = saveAccounts as Mock;
|
|
|
28
28
|
const mockReadCCredentials = readCCCredentials as Mock;
|
|
29
29
|
|
|
30
30
|
function expectAccount(account: ManagedAccount | null): ManagedAccount {
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
expect(account).not.toBeNull();
|
|
32
|
+
return account!;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** Build a stored account with sensible defaults; override any field. */
|
|
36
36
|
function makeStoredAccount(overrides: Record<string, unknown> = {}) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
37
|
+
return {
|
|
38
|
+
id: `acct-${Math.random().toString(36).slice(2, 8)}`,
|
|
39
|
+
refreshToken: "token1",
|
|
40
|
+
addedAt: 1000,
|
|
41
|
+
lastUsed: 0,
|
|
42
|
+
enabled: true,
|
|
43
|
+
rateLimitResetTimes: {},
|
|
44
|
+
consecutiveFailures: 0,
|
|
45
|
+
lastFailureTime: null,
|
|
46
|
+
token_updated_at: 0,
|
|
47
|
+
stats: createDefaultStats(0),
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/** Build a stored accounts payload from an array of per-account overrides. */
|
|
53
53
|
function makeAccountsData(overrides = [{}], extra = {}) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
54
|
+
return {
|
|
55
|
+
version: 1,
|
|
56
|
+
accounts: overrides.map((o, i) =>
|
|
57
|
+
makeStoredAccount({
|
|
58
|
+
refreshToken: `token${i + 1}`,
|
|
59
|
+
addedAt: (i + 1) * 1000,
|
|
60
|
+
...o,
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
activeIndex: 0,
|
|
64
|
+
...extra,
|
|
65
|
+
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
function makeCCCredential(overrides: Record<string, unknown> = {}) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
return {
|
|
70
|
+
accessToken: "cc-access-token",
|
|
71
|
+
refreshToken: "cc-refresh-token",
|
|
72
|
+
expiresAt: Date.now() + 3600_000,
|
|
73
|
+
source: "cc-keychain" as const,
|
|
74
|
+
label: "Claude Code-credentials:user@example.com",
|
|
75
|
+
...overrides,
|
|
76
|
+
};
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
// ---------------------------------------------------------------------------
|
|
@@ -81,176 +81,176 @@ function makeCCCredential(overrides: Record<string, unknown> = {}) {
|
|
|
81
81
|
// ---------------------------------------------------------------------------
|
|
82
82
|
|
|
83
83
|
describe("AccountManager.load", () => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
it("creates empty manager when no stored accounts and no fallback", async () => {
|
|
92
|
-
mockLoadAccounts.mockResolvedValue(null);
|
|
93
|
-
const manager = await AccountManager.load(DEFAULT_CONFIG, null);
|
|
94
|
-
expect(manager.getAccountCount()).toBe(0);
|
|
95
|
-
expect(manager.getTotalAccountCount()).toBe(0);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("bootstraps from auth fallback when no stored accounts", async () => {
|
|
99
|
-
mockLoadAccounts.mockResolvedValue(null);
|
|
100
|
-
const manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
101
|
-
refresh: "refresh-token-1",
|
|
102
|
-
access: "access-token-1",
|
|
103
|
-
expires: Date.now() + 3600_000,
|
|
104
|
-
});
|
|
105
|
-
expect(manager.getAccountCount()).toBe(1);
|
|
106
|
-
expect(manager.getTotalAccountCount()).toBe(1);
|
|
107
|
-
expect(manager.getCurrentIndex()).toBe(0);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("does not bootstrap from auth fallback when storage exists but is empty", async () => {
|
|
111
|
-
mockLoadAccounts.mockResolvedValue(makeAccountsData([], { activeIndex: 0 }));
|
|
112
|
-
const manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
113
|
-
refresh: "refresh-token-1",
|
|
114
|
-
access: "access-token-1",
|
|
115
|
-
expires: Date.now() + 3600_000,
|
|
116
|
-
});
|
|
117
|
-
expect(manager.getAccountCount()).toBe(0);
|
|
118
|
-
expect(manager.getTotalAccountCount()).toBe(0);
|
|
119
|
-
expect(manager.getCurrentIndex()).toBe(-1);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("loads stored accounts from disk", async () => {
|
|
123
|
-
mockLoadAccounts.mockResolvedValue(
|
|
124
|
-
makeAccountsData([{ lastUsed: 2000 }, { lastUsed: 4000 }], {
|
|
125
|
-
activeIndex: 1,
|
|
126
|
-
}),
|
|
127
|
-
);
|
|
128
|
-
const manager = await AccountManager.load(DEFAULT_CONFIG, null);
|
|
129
|
-
expect(manager.getAccountCount()).toBe(2);
|
|
130
|
-
expect(manager.getCurrentIndex()).toBe(1);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("auto-loads CC credentials on startup and keeps OAuth accounts available", async () => {
|
|
134
|
-
mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "oauth@example.com", source: "oauth" }]));
|
|
135
|
-
mockReadCCredentials.mockReturnValue([
|
|
136
|
-
makeCCCredential({
|
|
137
|
-
refreshToken: "cc-refresh-1",
|
|
138
|
-
accessToken: "cc-access-1",
|
|
139
|
-
source: "cc-keychain",
|
|
140
|
-
label: "Claude Code-credentials:oauth@example.com",
|
|
141
|
-
}),
|
|
142
|
-
makeCCCredential({
|
|
143
|
-
refreshToken: "cc-refresh-2",
|
|
144
|
-
accessToken: "cc-access-2",
|
|
145
|
-
source: "cc-file",
|
|
146
|
-
label: "/Users/test/.claude/.credentials.json",
|
|
147
|
-
}),
|
|
148
|
-
]);
|
|
149
|
-
|
|
150
|
-
const config = {
|
|
151
|
-
...DEFAULT_CONFIG,
|
|
152
|
-
cc_credential_reuse: {
|
|
153
|
-
...DEFAULT_CONFIG.cc_credential_reuse,
|
|
154
|
-
prefer_over_oauth: false,
|
|
155
|
-
},
|
|
156
|
-
};
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
vi.resetAllMocks();
|
|
86
|
+
vi.useFakeTimers();
|
|
87
|
+
vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
|
|
88
|
+
mockReadCCredentials.mockReturnValue([]);
|
|
89
|
+
});
|
|
157
90
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
91
|
+
it("creates empty manager when no stored accounts and no fallback", async () => {
|
|
92
|
+
mockLoadAccounts.mockResolvedValue(null);
|
|
93
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, null);
|
|
94
|
+
expect(manager.getAccountCount()).toBe(0);
|
|
95
|
+
expect(manager.getTotalAccountCount()).toBe(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("bootstraps from auth fallback when no stored accounts", async () => {
|
|
99
|
+
mockLoadAccounts.mockResolvedValue(null);
|
|
100
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
101
|
+
refresh: "refresh-token-1",
|
|
102
|
+
access: "access-token-1",
|
|
103
|
+
expires: Date.now() + 3600_000,
|
|
104
|
+
});
|
|
105
|
+
expect(manager.getAccountCount()).toBe(1);
|
|
106
|
+
expect(manager.getTotalAccountCount()).toBe(1);
|
|
107
|
+
expect(manager.getCurrentIndex()).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not bootstrap from auth fallback when storage exists but is empty", async () => {
|
|
111
|
+
mockLoadAccounts.mockResolvedValue(makeAccountsData([], { activeIndex: 0 }));
|
|
112
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
113
|
+
refresh: "refresh-token-1",
|
|
114
|
+
access: "access-token-1",
|
|
115
|
+
expires: Date.now() + 3600_000,
|
|
116
|
+
});
|
|
117
|
+
expect(manager.getAccountCount()).toBe(0);
|
|
118
|
+
expect(manager.getTotalAccountCount()).toBe(0);
|
|
119
|
+
expect(manager.getCurrentIndex()).toBe(-1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("loads stored accounts from disk", async () => {
|
|
123
|
+
mockLoadAccounts.mockResolvedValue(
|
|
124
|
+
makeAccountsData([{ lastUsed: 2000 }, { lastUsed: 4000 }], {
|
|
125
|
+
activeIndex: 1,
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, null);
|
|
129
|
+
expect(manager.getAccountCount()).toBe(2);
|
|
130
|
+
expect(manager.getCurrentIndex()).toBe(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("auto-loads CC credentials on startup and keeps OAuth accounts available", async () => {
|
|
134
|
+
mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "oauth@example.com", source: "oauth" }]));
|
|
135
|
+
mockReadCCredentials.mockReturnValue([
|
|
136
|
+
makeCCCredential({
|
|
137
|
+
refreshToken: "cc-refresh-1",
|
|
138
|
+
accessToken: "cc-access-1",
|
|
139
|
+
source: "cc-keychain",
|
|
140
|
+
label: "Claude Code-credentials:oauth@example.com",
|
|
141
|
+
}),
|
|
142
|
+
makeCCCredential({
|
|
143
|
+
refreshToken: "cc-refresh-2",
|
|
144
|
+
accessToken: "cc-access-2",
|
|
145
|
+
source: "cc-file",
|
|
146
|
+
label: "/Users/test/.claude/.credentials.json",
|
|
147
|
+
}),
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
const config = {
|
|
151
|
+
...DEFAULT_CONFIG,
|
|
152
|
+
cc_credential_reuse: {
|
|
153
|
+
...DEFAULT_CONFIG.cc_credential_reuse,
|
|
154
|
+
prefer_over_oauth: false,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const manager = await AccountManager.load(config, null);
|
|
159
|
+
const snapshot = manager.getAccountsSnapshot();
|
|
160
|
+
|
|
161
|
+
expect(mockReadCCredentials).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(snapshot).toHaveLength(3);
|
|
163
|
+
expect(snapshot.map((account) => account.refreshToken)).toEqual(["token1", "cc-refresh-1", "cc-refresh-2"]);
|
|
164
|
+
expect(manager.getOAuthAccounts()).toHaveLength(1);
|
|
165
|
+
expect(manager.getCCAccounts().map((account) => account.source)).toEqual(["cc-keychain", "cc-file"]);
|
|
166
|
+
// Email collision detection keeps both accounts (CC and OAuth)
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("prefers CC accounts over OAuth when configured", async () => {
|
|
170
|
+
mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "oauth@example.com", source: "oauth" }]));
|
|
171
|
+
mockReadCCredentials.mockReturnValue([
|
|
172
|
+
makeCCCredential({
|
|
173
|
+
refreshToken: "cc-refresh-1",
|
|
174
|
+
accessToken: "cc-access-1",
|
|
175
|
+
source: "cc-keychain",
|
|
176
|
+
}),
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, null);
|
|
180
|
+
const snapshot = manager.getAccountsSnapshot();
|
|
181
|
+
|
|
182
|
+
expect(snapshot.map((account) => account.source)).toEqual(["cc-keychain", "oauth"]);
|
|
183
|
+
expect(snapshot.map((account) => account.index)).toEqual([0, 1]);
|
|
184
|
+
expect(manager.getCurrentAccount()?.source).toBe("cc-keychain");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("stays graceful when CC credentials are unavailable", async () => {
|
|
188
|
+
mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "oauth@example.com", source: "oauth" }]));
|
|
189
|
+
mockReadCCredentials.mockReturnValue([]);
|
|
190
|
+
|
|
191
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, null);
|
|
192
|
+
|
|
193
|
+
expect(manager.getTotalAccountCount()).toBe(1);
|
|
194
|
+
expect(manager.getAccountsSnapshot()[0]?.refreshToken).toBe("token1");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("skips CC detection when CC credential reuse is disabled", async () => {
|
|
198
|
+
mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "oauth@example.com", source: "oauth" }]));
|
|
199
|
+
|
|
200
|
+
const config = {
|
|
201
|
+
...DEFAULT_CONFIG,
|
|
202
|
+
cc_credential_reuse: {
|
|
203
|
+
...DEFAULT_CONFIG.cc_credential_reuse,
|
|
204
|
+
enabled: false,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const manager = await AccountManager.load(config, null);
|
|
207
209
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
expect(manager.getCurrentIndex()).toBe(0);
|
|
253
|
-
});
|
|
210
|
+
expect(mockReadCCredentials).not.toHaveBeenCalled();
|
|
211
|
+
expect(manager.getTotalAccountCount()).toBe(1);
|
|
212
|
+
expect(manager.getCCAccounts()).toEqual([]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("matches auth fallback to existing stored account", async () => {
|
|
216
|
+
mockLoadAccounts.mockResolvedValue(makeAccountsData([{ lastUsed: 2000 }]));
|
|
217
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
218
|
+
refresh: "token1",
|
|
219
|
+
access: "fresh-access",
|
|
220
|
+
expires: Date.now() + 3600_000,
|
|
221
|
+
});
|
|
222
|
+
const snapshot = manager.getAccountsSnapshot();
|
|
223
|
+
expect(snapshot[0].access).toBe("fresh-access");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("does not let stale/partial fallback override fresher stored auth", async () => {
|
|
227
|
+
mockLoadAccounts.mockResolvedValue(
|
|
228
|
+
makeAccountsData([
|
|
229
|
+
{
|
|
230
|
+
lastUsed: 2000,
|
|
231
|
+
access: "disk-access",
|
|
232
|
+
expires: Date.now() + 6 * 3600_000,
|
|
233
|
+
token_updated_at: Date.now() + 6 * 3600_000,
|
|
234
|
+
},
|
|
235
|
+
]),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
239
|
+
refresh: "token1",
|
|
240
|
+
access: "stale-fallback-access",
|
|
241
|
+
expires: Date.now() - 60_000,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const snapshot = manager.getAccountsSnapshot();
|
|
245
|
+
expect(snapshot[0].access).toBe("disk-access");
|
|
246
|
+
expect(snapshot[0].expires).toBeGreaterThan(Date.now());
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("clamps activeIndex to valid range", async () => {
|
|
250
|
+
mockLoadAccounts.mockResolvedValue(makeAccountsData([{ lastUsed: 2000 }], { activeIndex: 99 }));
|
|
251
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, null);
|
|
252
|
+
expect(manager.getCurrentIndex()).toBe(0);
|
|
253
|
+
});
|
|
254
254
|
});
|
|
255
255
|
|
|
256
256
|
// ---------------------------------------------------------------------------
|
|
@@ -258,96 +258,98 @@ describe("AccountManager.load", () => {
|
|
|
258
258
|
// ---------------------------------------------------------------------------
|
|
259
259
|
|
|
260
260
|
describe("AccountManager account management", () => {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
261
|
+
/** @type {AccountManager} */
|
|
262
|
+
let manager: AccountManager;
|
|
263
|
+
|
|
264
|
+
beforeEach(async () => {
|
|
265
|
+
vi.resetAllMocks();
|
|
266
|
+
mockReadCCredentials.mockReturnValue([]);
|
|
267
|
+
vi.useFakeTimers();
|
|
268
|
+
vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
|
|
269
|
+
mockLoadAccounts.mockResolvedValue(null);
|
|
270
|
+
manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
271
|
+
refresh: "token1",
|
|
272
|
+
access: "access1",
|
|
273
|
+
expires: Date.now() + 3600_000,
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("addAccount adds a new account", () => {
|
|
278
|
+
const account = expectAccount(
|
|
279
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000, "user@example.com"),
|
|
280
|
+
);
|
|
281
|
+
expect(manager.getTotalAccountCount()).toBe(2);
|
|
282
|
+
expect(account.email).toBe("user@example.com");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("addAccount deduplicates by refresh token", () => {
|
|
286
|
+
manager.addAccount("token1", "new-access", Date.now() + 7200_000);
|
|
287
|
+
expect(manager.getTotalAccountCount()).toBe(1);
|
|
288
|
+
const snapshot = manager.getAccountsSnapshot();
|
|
289
|
+
expect(snapshot[0].access).toBe("new-access");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("addAccount respects MAX_ACCOUNTS limit", () => {
|
|
293
|
+
for (let i = 2; i <= 10; i++) {
|
|
294
|
+
manager.addAccount(`token${i}`, `access${i}`, Date.now() + 3600_000);
|
|
295
|
+
}
|
|
296
|
+
expect(manager.getTotalAccountCount()).toBe(10);
|
|
297
|
+
const result = manager.addAccount("token11", "access11", Date.now() + 3600_000);
|
|
298
|
+
expect(result).toBeNull();
|
|
299
|
+
expect(manager.getTotalAccountCount()).toBe(10);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("removeAccount removes by index", () => {
|
|
303
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000);
|
|
304
|
+
expect(manager.getTotalAccountCount()).toBe(2);
|
|
305
|
+
const removed = manager.removeAccount(0);
|
|
306
|
+
expect(removed).toBe(true);
|
|
307
|
+
expect(manager.getTotalAccountCount()).toBe(1);
|
|
308
|
+
const snapshot = manager.getAccountsSnapshot();
|
|
309
|
+
expect(snapshot[0].refreshToken).toBe("token2");
|
|
310
|
+
expect(snapshot[0].index).toBe(0); // Re-indexed
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("removeAccount returns false for invalid index", () => {
|
|
314
|
+
expect(manager.removeAccount(-1)).toBe(false);
|
|
315
|
+
expect(manager.removeAccount(99)).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("removeAccount adjusts currentIndex", () => {
|
|
319
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000);
|
|
320
|
+
// Select account 1
|
|
321
|
+
manager.getCurrentAccount();
|
|
322
|
+
manager.removeAccount(0);
|
|
323
|
+
// currentIndex should be adjusted
|
|
324
|
+
expect(manager.getCurrentIndex()).toBeLessThanOrEqual(0);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("toggleAccount toggles enabled state", () => {
|
|
328
|
+
const newState = manager.toggleAccount(0);
|
|
329
|
+
expect(newState).toBe(false);
|
|
330
|
+
expect(manager.getAccountCount()).toBe(0); // Disabled
|
|
331
|
+
const restored = manager.toggleAccount(0);
|
|
332
|
+
expect(restored).toBe(true);
|
|
333
|
+
expect(manager.getAccountCount()).toBe(1);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("toggleAccount returns false for invalid index", () => {
|
|
337
|
+
expect(manager.toggleAccount(99)).toBe(false);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("clearAll removes all accounts", () => {
|
|
341
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000);
|
|
342
|
+
manager.clearAll();
|
|
343
|
+
expect(manager.getTotalAccountCount()).toBe(0);
|
|
344
|
+
expect(manager.getCurrentIndex()).toBe(-1);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("getAccountsSnapshot returns copies", () => {
|
|
348
|
+
const snapshot = manager.getAccountsSnapshot();
|
|
349
|
+
snapshot[0].email = "modified";
|
|
350
|
+
const snapshot2 = manager.getAccountsSnapshot();
|
|
351
|
+
expect(snapshot2[0].email).not.toBe("modified");
|
|
352
|
+
});
|
|
351
353
|
});
|
|
352
354
|
|
|
353
355
|
// ---------------------------------------------------------------------------
|
|
@@ -355,40 +357,40 @@ describe("AccountManager account management", () => {
|
|
|
355
357
|
// ---------------------------------------------------------------------------
|
|
356
358
|
|
|
357
359
|
describe("AccountManager account selection", () => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
360
|
+
/** @type {AccountManager} */
|
|
361
|
+
let manager: AccountManager;
|
|
362
|
+
|
|
363
|
+
beforeEach(async () => {
|
|
364
|
+
vi.resetAllMocks();
|
|
365
|
+
mockReadCCredentials.mockReturnValue([]);
|
|
366
|
+
vi.useFakeTimers();
|
|
367
|
+
vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
|
|
368
|
+
mockLoadAccounts.mockResolvedValue(null);
|
|
369
|
+
manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
370
|
+
refresh: "token1",
|
|
371
|
+
access: "access1",
|
|
372
|
+
expires: Date.now() + 3600_000,
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("getCurrentAccount returns an account", () => {
|
|
377
|
+
const account = expectAccount(manager.getCurrentAccount());
|
|
378
|
+
expect(account.refreshToken).toBe("token1");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("getCurrentAccount returns null when no accounts", async () => {
|
|
382
|
+
mockLoadAccounts.mockResolvedValue(null);
|
|
383
|
+
const empty = await AccountManager.load(DEFAULT_CONFIG, null);
|
|
384
|
+
expect(empty.getCurrentAccount()).toBeNull();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("getCurrentAccount updates lastUsed", () => {
|
|
388
|
+
const before = manager.getAccountsSnapshot()[0].lastUsed;
|
|
389
|
+
vi.advanceTimersByTime(1000);
|
|
390
|
+
manager.getCurrentAccount();
|
|
391
|
+
const after = manager.getAccountsSnapshot()[0].lastUsed;
|
|
392
|
+
expect(after).toBeGreaterThan(before);
|
|
393
|
+
});
|
|
392
394
|
});
|
|
393
395
|
|
|
394
396
|
// ---------------------------------------------------------------------------
|
|
@@ -396,65 +398,65 @@ describe("AccountManager account selection", () => {
|
|
|
396
398
|
// ---------------------------------------------------------------------------
|
|
397
399
|
|
|
398
400
|
describe("AccountManager rate limiting", () => {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
401
|
+
/** @type {AccountManager} */
|
|
402
|
+
let manager: AccountManager;
|
|
403
|
+
|
|
404
|
+
beforeEach(async () => {
|
|
405
|
+
vi.resetAllMocks();
|
|
406
|
+
mockReadCCredentials.mockReturnValue([]);
|
|
407
|
+
vi.useFakeTimers();
|
|
408
|
+
vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
|
|
409
|
+
mockLoadAccounts.mockResolvedValue(null);
|
|
410
|
+
manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
411
|
+
refresh: "token1",
|
|
412
|
+
access: "access1",
|
|
413
|
+
expires: Date.now() + 3600_000,
|
|
414
|
+
});
|
|
415
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("markRateLimited sets backoff and returns duration", () => {
|
|
419
|
+
const account = expectAccount(manager.getCurrentAccount());
|
|
420
|
+
const backoffMs = manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
|
|
421
|
+
expect(backoffMs).toBeGreaterThan(0);
|
|
422
|
+
expect(account.consecutiveFailures).toBe(1);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("markRateLimited increments consecutive failures", () => {
|
|
426
|
+
const account = expectAccount(manager.getCurrentAccount());
|
|
427
|
+
manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
|
|
428
|
+
manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
|
|
429
|
+
expect(account.consecutiveFailures).toBe(2);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("markSuccess resets consecutive failures", () => {
|
|
433
|
+
const account = expectAccount(manager.getCurrentAccount());
|
|
434
|
+
manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
|
|
435
|
+
expect(account.consecutiveFailures).toBe(1);
|
|
436
|
+
manager.markSuccess(account);
|
|
437
|
+
expect(account.consecutiveFailures).toBe(0);
|
|
438
|
+
expect(account.lastFailureTime).toBeNull();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("markFailure reduces health score", () => {
|
|
442
|
+
const account = expectAccount(manager.getCurrentAccount());
|
|
443
|
+
manager.markFailure(account);
|
|
444
|
+
// Can't directly check health score, but we can verify it doesn't crash
|
|
445
|
+
expect(account).toBeDefined();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("failure TTL resets consecutive failures after timeout", () => {
|
|
449
|
+
const account = expectAccount(manager.getCurrentAccount());
|
|
450
|
+
manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
|
|
451
|
+
expect(account.consecutiveFailures).toBe(1);
|
|
452
|
+
|
|
453
|
+
// Advance past failure TTL (3600 seconds)
|
|
454
|
+
vi.advanceTimersByTime(3601_000);
|
|
455
|
+
|
|
456
|
+
// Next rate limit should reset the counter first
|
|
457
|
+
manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
|
|
458
|
+
expect(account.consecutiveFailures).toBe(1); // Reset to 0, then +1
|
|
459
|
+
});
|
|
458
460
|
});
|
|
459
461
|
|
|
460
462
|
// ---------------------------------------------------------------------------
|
|
@@ -462,166 +464,167 @@ describe("AccountManager rate limiting", () => {
|
|
|
462
464
|
// ---------------------------------------------------------------------------
|
|
463
465
|
|
|
464
466
|
describe("AccountManager persistence", () => {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
467
|
+
/** @type {AccountManager} */
|
|
468
|
+
let manager: AccountManager;
|
|
469
|
+
|
|
470
|
+
beforeEach(async () => {
|
|
471
|
+
vi.resetAllMocks();
|
|
472
|
+
mockReadCCredentials.mockReturnValue([]);
|
|
473
|
+
vi.useFakeTimers();
|
|
474
|
+
vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
|
|
475
|
+
mockLoadAccounts.mockResolvedValue(null);
|
|
476
|
+
mockSaveAccounts.mockResolvedValue(undefined);
|
|
477
|
+
manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
478
|
+
refresh: "token1",
|
|
479
|
+
access: "access1",
|
|
480
|
+
expires: Date.now() + 3600_000,
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("saveToDisk calls saveAccounts with correct format", async () => {
|
|
485
|
+
await manager.saveToDisk();
|
|
486
|
+
expect(saveAccounts).toHaveBeenCalledWith(
|
|
487
|
+
expect.objectContaining({
|
|
488
|
+
version: 1,
|
|
489
|
+
accounts: expect.arrayContaining([
|
|
490
|
+
expect.objectContaining({
|
|
491
|
+
refreshToken: "token1",
|
|
492
|
+
enabled: true,
|
|
493
|
+
}),
|
|
494
|
+
]),
|
|
495
|
+
activeIndex: expect.any(Number),
|
|
496
|
+
}),
|
|
497
|
+
expect.anything(),
|
|
498
|
+
);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("requestSaveToDisk debounces saves", async () => {
|
|
502
|
+
manager.requestSaveToDisk();
|
|
503
|
+
manager.requestSaveToDisk();
|
|
504
|
+
manager.requestSaveToDisk();
|
|
505
|
+
|
|
506
|
+
// Should not have saved yet
|
|
507
|
+
expect(saveAccounts).not.toHaveBeenCalled();
|
|
508
|
+
|
|
509
|
+
// Advance past debounce timeout
|
|
510
|
+
vi.advanceTimersByTime(1100);
|
|
511
|
+
|
|
512
|
+
// Should have saved once
|
|
513
|
+
// Wait for the async save to complete
|
|
514
|
+
await vi.runAllTimersAsync();
|
|
515
|
+
expect(saveAccounts).toHaveBeenCalledTimes(1);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("requestSaveToDisk resets timer on subsequent calls", async () => {
|
|
519
|
+
manager.requestSaveToDisk();
|
|
520
|
+
vi.advanceTimersByTime(500); // Half the debounce window
|
|
521
|
+
manager.requestSaveToDisk(); // Should reset the timer
|
|
522
|
+
vi.advanceTimersByTime(500); // 500ms after second call (total 1000ms)
|
|
523
|
+
expect(saveAccounts).not.toHaveBeenCalled(); // Timer was reset
|
|
524
|
+
vi.advanceTimersByTime(600); // Now past the debounce window
|
|
525
|
+
await vi.runAllTimersAsync();
|
|
526
|
+
expect(saveAccounts).toHaveBeenCalledTimes(1);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("toAuthDetails converts to OpenCode format", () => {
|
|
530
|
+
const account = expectAccount(manager.getCurrentAccount());
|
|
531
|
+
const details = manager.toAuthDetails(account);
|
|
532
|
+
expect(details).toEqual({
|
|
533
|
+
type: "oauth",
|
|
534
|
+
refresh: "token1",
|
|
535
|
+
access: "access1",
|
|
536
|
+
expires: expect.any(Number),
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("syncActiveIndexFromDisk picks up CLI changes", async () => {
|
|
541
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
|
|
542
|
+
|
|
543
|
+
// Currently on account 0
|
|
544
|
+
expect(manager.getCurrentIndex()).toBe(0);
|
|
545
|
+
|
|
546
|
+
// CLI changes activeIndex to 1 on disk
|
|
547
|
+
mockLoadAccounts.mockResolvedValue(
|
|
548
|
+
makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com" }], {
|
|
549
|
+
activeIndex: 1,
|
|
550
|
+
}),
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
await manager.syncActiveIndexFromDisk();
|
|
554
|
+
expect(manager.getCurrentIndex()).toBe(1);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("syncActiveIndexFromDisk ignores disabled target account", async () => {
|
|
558
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
|
|
559
|
+
|
|
560
|
+
expect(manager.getCurrentIndex()).toBe(0);
|
|
561
|
+
|
|
562
|
+
mockLoadAccounts.mockResolvedValue(
|
|
563
|
+
makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com", enabled: false }], { activeIndex: 1 }),
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
await manager.syncActiveIndexFromDisk();
|
|
567
|
+
// Should stay on 0 because account 1 is disabled
|
|
568
|
+
expect(manager.getCurrentIndex()).toBe(0);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("syncActiveIndexFromDisk no-ops when disk matches memory", async () => {
|
|
572
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
|
|
573
|
+
|
|
574
|
+
expect(manager.getCurrentIndex()).toBe(0);
|
|
575
|
+
|
|
576
|
+
mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com" }]));
|
|
577
|
+
|
|
578
|
+
await manager.syncActiveIndexFromDisk();
|
|
579
|
+
expect(manager.getCurrentIndex()).toBe(0);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("syncActiveIndexFromDisk reconciles removed accounts from disk", async () => {
|
|
583
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
|
|
584
|
+
|
|
585
|
+
mockLoadAccounts.mockResolvedValue(makeAccountsData([{ email: "a@test.com" }]));
|
|
586
|
+
|
|
587
|
+
await manager.syncActiveIndexFromDisk();
|
|
588
|
+
await manager.saveToDisk();
|
|
589
|
+
|
|
590
|
+
const saved = mockSaveAccounts.mock.calls[mockSaveAccounts.mock.calls.length - 1]?.[0];
|
|
591
|
+
expect(saved.accounts).toHaveLength(1);
|
|
592
|
+
expect(saved.accounts[0].refreshToken).toBe("token1");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("syncActiveIndexFromDisk updates enabled state from disk", async () => {
|
|
596
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
|
|
597
|
+
|
|
598
|
+
mockLoadAccounts.mockResolvedValue(
|
|
599
|
+
makeAccountsData([{ email: "a@test.com", enabled: false }, { email: "b@test.com" }], { activeIndex: 1 }),
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
await manager.syncActiveIndexFromDisk();
|
|
603
|
+
expect(manager.getCurrentIndex()).toBe(1);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("syncActiveIndexFromDisk reconciles by stable id when refresh token rotates", async () => {
|
|
607
|
+
const current = manager.getAccountsSnapshot()[0];
|
|
608
|
+
|
|
609
|
+
mockLoadAccounts.mockResolvedValue(
|
|
610
|
+
makeAccountsData([
|
|
611
|
+
{
|
|
612
|
+
id: current.id,
|
|
613
|
+
refreshToken: "token1-rotated",
|
|
614
|
+
access: "rotated-access",
|
|
615
|
+
expires: Date.now() + 7200_000,
|
|
616
|
+
token_updated_at: Date.now() + 5_000,
|
|
617
|
+
},
|
|
618
|
+
]),
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
await manager.syncActiveIndexFromDisk();
|
|
622
|
+
const snapshot = manager.getAccountsSnapshot();
|
|
623
|
+
|
|
624
|
+
expect(snapshot[0].id).toBe(current.id);
|
|
625
|
+
expect(snapshot[0].refreshToken).toBe("token1-rotated");
|
|
626
|
+
expect(snapshot[0].access).toBe("rotated-access");
|
|
627
|
+
});
|
|
625
628
|
});
|
|
626
629
|
|
|
627
630
|
// ---------------------------------------------------------------------------
|
|
@@ -629,129 +632,130 @@ describe("AccountManager persistence", () => {
|
|
|
629
632
|
// ---------------------------------------------------------------------------
|
|
630
633
|
|
|
631
634
|
describe("AccountManager usage stats", () => {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
635
|
+
beforeEach(() => {
|
|
636
|
+
vi.clearAllMocks();
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
async function createManagerWithAccounts(n = 2) {
|
|
640
|
+
mockLoadAccounts.mockResolvedValue(null);
|
|
641
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
642
|
+
refresh: "tok-1",
|
|
643
|
+
access: "access-1",
|
|
644
|
+
expires: Date.now() + 3600_000,
|
|
645
|
+
});
|
|
646
|
+
for (let i = 2; i <= n; i++) {
|
|
647
|
+
manager.addAccount(`tok-${i}`, `access-${i}`, Date.now() + 3600_000, `user${i}@test.com`);
|
|
648
|
+
}
|
|
649
|
+
return manager;
|
|
645
650
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
651
|
+
|
|
652
|
+
it("recordUsage increments stats for the given account", async () => {
|
|
653
|
+
const manager = await createManagerWithAccounts(2);
|
|
654
|
+
manager.recordUsage(0, {
|
|
655
|
+
inputTokens: 100,
|
|
656
|
+
outputTokens: 50,
|
|
657
|
+
cacheReadTokens: 10,
|
|
658
|
+
cacheWriteTokens: 5,
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
const snap = manager.getAccountsSnapshot();
|
|
662
|
+
expect(snap[0].stats.requests).toBe(1);
|
|
663
|
+
expect(snap[0].stats.inputTokens).toBe(100);
|
|
664
|
+
expect(snap[0].stats.outputTokens).toBe(50);
|
|
665
|
+
expect(snap[0].stats.cacheReadTokens).toBe(10);
|
|
666
|
+
expect(snap[0].stats.cacheWriteTokens).toBe(5);
|
|
667
|
+
// Account 1 should be untouched
|
|
668
|
+
expect(snap[1].stats.requests).toBe(0);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("recordUsage accumulates over multiple calls", async () => {
|
|
672
|
+
const manager = await createManagerWithAccounts(1);
|
|
673
|
+
manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
|
|
674
|
+
manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
|
|
675
|
+
|
|
676
|
+
const snap = manager.getAccountsSnapshot();
|
|
677
|
+
expect(snap[0].stats.requests).toBe(2);
|
|
678
|
+
expect(snap[0].stats.inputTokens).toBe(300);
|
|
679
|
+
expect(snap[0].stats.outputTokens).toBe(150);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("recordUsage handles missing fields gracefully", async () => {
|
|
683
|
+
const manager = await createManagerWithAccounts(1);
|
|
684
|
+
manager.recordUsage(0, {});
|
|
685
|
+
|
|
686
|
+
const snap = manager.getAccountsSnapshot();
|
|
687
|
+
expect(snap[0].stats.requests).toBe(1);
|
|
688
|
+
expect(snap[0].stats.inputTokens).toBe(0);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("recordUsage ignores invalid index", async () => {
|
|
692
|
+
const manager = await createManagerWithAccounts(1);
|
|
693
|
+
manager.recordUsage(99, { inputTokens: 100 });
|
|
694
|
+
// Should not throw
|
|
695
|
+
const snap = manager.getAccountsSnapshot();
|
|
696
|
+
expect(snap[0].stats.requests).toBe(0);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it("resetStats resets a single account", async () => {
|
|
700
|
+
const manager = await createManagerWithAccounts(2);
|
|
701
|
+
manager.recordUsage(0, { inputTokens: 500, outputTokens: 200 });
|
|
702
|
+
manager.recordUsage(1, { inputTokens: 300, outputTokens: 100 });
|
|
703
|
+
|
|
704
|
+
manager.resetStats(0);
|
|
705
|
+
|
|
706
|
+
const snap = manager.getAccountsSnapshot();
|
|
707
|
+
expect(snap[0].stats.requests).toBe(0);
|
|
708
|
+
expect(snap[0].stats.inputTokens).toBe(0);
|
|
709
|
+
// Account 1 should be untouched
|
|
710
|
+
expect(snap[1].stats.requests).toBe(1);
|
|
711
|
+
expect(snap[1].stats.inputTokens).toBe(300);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("resetStats ignores invalid index", async () => {
|
|
715
|
+
const manager = await createManagerWithAccounts(1);
|
|
716
|
+
manager.recordUsage(0, { inputTokens: 500 });
|
|
717
|
+
|
|
718
|
+
manager.resetStats(99);
|
|
719
|
+
|
|
720
|
+
// Account 0 should be untouched
|
|
721
|
+
const snap = manager.getAccountsSnapshot();
|
|
722
|
+
expect(snap[0].stats.requests).toBe(1);
|
|
723
|
+
expect(snap[0].stats.inputTokens).toBe(500);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it("resetStats resets all accounts", async () => {
|
|
727
|
+
const manager = await createManagerWithAccounts(2);
|
|
728
|
+
manager.recordUsage(0, { inputTokens: 500 });
|
|
729
|
+
manager.recordUsage(1, { inputTokens: 300 });
|
|
730
|
+
|
|
731
|
+
manager.resetStats("all");
|
|
732
|
+
|
|
733
|
+
const snap = manager.getAccountsSnapshot();
|
|
734
|
+
expect(snap[0].stats.requests).toBe(0);
|
|
735
|
+
expect(snap[1].stats.requests).toBe(0);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("stats are included in saveToDisk output", async () => {
|
|
739
|
+
const manager = await createManagerWithAccounts(1);
|
|
740
|
+
manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
|
|
741
|
+
|
|
742
|
+
await manager.saveToDisk();
|
|
743
|
+
|
|
744
|
+
expect(saveAccounts).toHaveBeenCalledWith(
|
|
745
|
+
expect.objectContaining({
|
|
746
|
+
accounts: expect.arrayContaining([
|
|
747
|
+
expect.objectContaining({
|
|
748
|
+
stats: expect.objectContaining({
|
|
749
|
+
requests: 1,
|
|
750
|
+
inputTokens: 100,
|
|
751
|
+
outputTokens: 50,
|
|
752
|
+
}),
|
|
753
|
+
}),
|
|
754
|
+
]),
|
|
749
755
|
}),
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
);
|
|
754
|
-
});
|
|
756
|
+
expect.anything(),
|
|
757
|
+
);
|
|
758
|
+
});
|
|
755
759
|
});
|
|
756
760
|
|
|
757
761
|
// ---------------------------------------------------------------------------
|
|
@@ -759,256 +763,256 @@ describe("AccountManager usage stats", () => {
|
|
|
759
763
|
// ---------------------------------------------------------------------------
|
|
760
764
|
|
|
761
765
|
describe("AccountManager merge-on-save", () => {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
766
|
+
beforeEach(() => {
|
|
767
|
+
vi.clearAllMocks();
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
async function createManagerWithAccounts(n = 1) {
|
|
771
|
+
mockLoadAccounts.mockResolvedValue(null);
|
|
772
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
773
|
+
refresh: "tok-1",
|
|
774
|
+
access: "access-1",
|
|
775
|
+
expires: Date.now() + 3600_000,
|
|
776
|
+
});
|
|
777
|
+
for (let i = 2; i <= n; i++) {
|
|
778
|
+
manager.addAccount(`tok-${i}`, `access-${i}`, Date.now() + 3600_000, `user${i}@test.com`);
|
|
779
|
+
}
|
|
780
|
+
// Save once to establish baseline, then clear mocks
|
|
781
|
+
await manager.saveToDisk();
|
|
782
|
+
vi.clearAllMocks();
|
|
783
|
+
return manager;
|
|
775
784
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
);
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
const saved = mockSaveAccounts.mock.calls[0][0];
|
|
1011
|
-
expect(saved.accounts[0].refreshToken).toBe("disk-rotated-no-id");
|
|
1012
|
-
expect(saved.accounts[0].access).toBe("disk-fresh-no-id");
|
|
1013
|
-
});
|
|
785
|
+
|
|
786
|
+
it("merges stats with disk values on save", async () => {
|
|
787
|
+
const manager = await createManagerWithAccounts(1);
|
|
788
|
+
const snap = manager.getAccountsSnapshot();
|
|
789
|
+
const accountId = snap[0].id;
|
|
790
|
+
|
|
791
|
+
// Simulate another instance having written stats to disk
|
|
792
|
+
mockLoadAccounts.mockResolvedValue(
|
|
793
|
+
makeAccountsData([
|
|
794
|
+
{
|
|
795
|
+
id: accountId,
|
|
796
|
+
refreshToken: "tok-1",
|
|
797
|
+
stats: {
|
|
798
|
+
requests: 50,
|
|
799
|
+
inputTokens: 10000,
|
|
800
|
+
outputTokens: 5000,
|
|
801
|
+
cacheReadTokens: 1000,
|
|
802
|
+
cacheWriteTokens: 500,
|
|
803
|
+
lastReset: 1000,
|
|
804
|
+
},
|
|
805
|
+
},
|
|
806
|
+
]),
|
|
807
|
+
);
|
|
808
|
+
mockSaveAccounts.mockResolvedValue(undefined);
|
|
809
|
+
|
|
810
|
+
// This instance records 3 requests
|
|
811
|
+
manager.recordUsage(0, {
|
|
812
|
+
inputTokens: 100,
|
|
813
|
+
outputTokens: 50,
|
|
814
|
+
cacheReadTokens: 10,
|
|
815
|
+
cacheWriteTokens: 5,
|
|
816
|
+
});
|
|
817
|
+
manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
|
|
818
|
+
manager.recordUsage(0, { inputTokens: 300, outputTokens: 150 });
|
|
819
|
+
|
|
820
|
+
await manager.saveToDisk();
|
|
821
|
+
|
|
822
|
+
const saved = mockSaveAccounts.mock.calls[0][0];
|
|
823
|
+
const stats = saved.accounts[0].stats;
|
|
824
|
+
|
|
825
|
+
// Should be disk values + our deltas
|
|
826
|
+
expect(stats.requests).toBe(53); // 50 + 3
|
|
827
|
+
expect(stats.inputTokens).toBe(10600); // 10000 + 100 + 200 + 300
|
|
828
|
+
expect(stats.outputTokens).toBe(5300); // 5000 + 50 + 100 + 150
|
|
829
|
+
expect(stats.cacheReadTokens).toBe(1010); // 1000 + 10
|
|
830
|
+
expect(stats.cacheWriteTokens).toBe(505); // 500 + 5
|
|
831
|
+
expect(stats.lastReset).toBe(1000); // Preserved from disk
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it("clears deltas after save", async () => {
|
|
835
|
+
const manager = await createManagerWithAccounts(1);
|
|
836
|
+
const snap = manager.getAccountsSnapshot();
|
|
837
|
+
const accountId = snap[0].id;
|
|
838
|
+
|
|
839
|
+
mockLoadAccounts.mockResolvedValue(
|
|
840
|
+
makeAccountsData([
|
|
841
|
+
{
|
|
842
|
+
id: accountId,
|
|
843
|
+
refreshToken: "tok-1",
|
|
844
|
+
stats: {
|
|
845
|
+
requests: 10,
|
|
846
|
+
inputTokens: 1000,
|
|
847
|
+
outputTokens: 500,
|
|
848
|
+
cacheReadTokens: 0,
|
|
849
|
+
cacheWriteTokens: 0,
|
|
850
|
+
lastReset: 1000,
|
|
851
|
+
},
|
|
852
|
+
},
|
|
853
|
+
]),
|
|
854
|
+
);
|
|
855
|
+
mockSaveAccounts.mockResolvedValue(undefined);
|
|
856
|
+
|
|
857
|
+
manager.recordUsage(0, { inputTokens: 100 });
|
|
858
|
+
await manager.saveToDisk();
|
|
859
|
+
|
|
860
|
+
// First save: 10 + 1 = 11 requests
|
|
861
|
+
expect(mockSaveAccounts.mock.calls[0][0].accounts[0].stats.requests).toBe(11);
|
|
862
|
+
|
|
863
|
+
// Second save with no new usage should write disk values as-is (no delta)
|
|
864
|
+
mockLoadAccounts.mockResolvedValue(
|
|
865
|
+
makeAccountsData([
|
|
866
|
+
{
|
|
867
|
+
id: accountId,
|
|
868
|
+
refreshToken: "tok-1",
|
|
869
|
+
stats: {
|
|
870
|
+
requests: 11,
|
|
871
|
+
inputTokens: 1100,
|
|
872
|
+
outputTokens: 500,
|
|
873
|
+
cacheReadTokens: 0,
|
|
874
|
+
cacheWriteTokens: 0,
|
|
875
|
+
lastReset: 1000,
|
|
876
|
+
},
|
|
877
|
+
},
|
|
878
|
+
]),
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
await manager.saveToDisk();
|
|
882
|
+
// No delta, so stats should match what's on disk
|
|
883
|
+
expect(mockSaveAccounts.mock.calls[1][0].accounts[0].stats.requests).toBe(11);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it("resetStats writes absolute values ignoring disk", async () => {
|
|
887
|
+
const manager = await createManagerWithAccounts(1);
|
|
888
|
+
const snap = manager.getAccountsSnapshot();
|
|
889
|
+
const accountId = snap[0].id;
|
|
890
|
+
|
|
891
|
+
// Disk has 100 requests from other instances
|
|
892
|
+
mockLoadAccounts.mockResolvedValue(
|
|
893
|
+
makeAccountsData([
|
|
894
|
+
{
|
|
895
|
+
id: accountId,
|
|
896
|
+
refreshToken: "tok-1",
|
|
897
|
+
stats: {
|
|
898
|
+
requests: 100,
|
|
899
|
+
inputTokens: 50000,
|
|
900
|
+
outputTokens: 20000,
|
|
901
|
+
cacheReadTokens: 0,
|
|
902
|
+
cacheWriteTokens: 0,
|
|
903
|
+
lastReset: 1000,
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
]),
|
|
907
|
+
);
|
|
908
|
+
mockSaveAccounts.mockResolvedValue(undefined);
|
|
909
|
+
|
|
910
|
+
manager.resetStats(0);
|
|
911
|
+
await manager.saveToDisk();
|
|
912
|
+
|
|
913
|
+
const stats = mockSaveAccounts.mock.calls[0][0].accounts[0].stats;
|
|
914
|
+
expect(stats.requests).toBe(0);
|
|
915
|
+
expect(stats.inputTokens).toBe(0);
|
|
916
|
+
expect(stats.outputTokens).toBe(0);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it("accumulates usage after resetStats correctly", async () => {
|
|
920
|
+
const manager = await createManagerWithAccounts(1);
|
|
921
|
+
const snap = manager.getAccountsSnapshot();
|
|
922
|
+
const accountId = snap[0].id;
|
|
923
|
+
|
|
924
|
+
mockLoadAccounts.mockResolvedValue(
|
|
925
|
+
makeAccountsData([
|
|
926
|
+
{
|
|
927
|
+
id: accountId,
|
|
928
|
+
refreshToken: "tok-1",
|
|
929
|
+
stats: {
|
|
930
|
+
requests: 100,
|
|
931
|
+
inputTokens: 50000,
|
|
932
|
+
outputTokens: 20000,
|
|
933
|
+
cacheReadTokens: 0,
|
|
934
|
+
cacheWriteTokens: 0,
|
|
935
|
+
lastReset: 1000,
|
|
936
|
+
},
|
|
937
|
+
},
|
|
938
|
+
]),
|
|
939
|
+
);
|
|
940
|
+
mockSaveAccounts.mockResolvedValue(undefined);
|
|
941
|
+
|
|
942
|
+
// Reset then record new usage before saving
|
|
943
|
+
manager.resetStats(0);
|
|
944
|
+
manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
|
|
945
|
+
|
|
946
|
+
await manager.saveToDisk();
|
|
947
|
+
|
|
948
|
+
const stats = mockSaveAccounts.mock.calls[0][0].accounts[0].stats;
|
|
949
|
+
expect(stats.requests).toBe(1); // 0 (reset) + 1
|
|
950
|
+
expect(stats.inputTokens).toBe(200); // 0 (reset) + 200
|
|
951
|
+
expect(stats.outputTokens).toBe(100); // 0 (reset) + 100
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it("falls through to absolute values when disk read fails", async () => {
|
|
955
|
+
const manager = await createManagerWithAccounts(1);
|
|
956
|
+
|
|
957
|
+
// Disk read fails
|
|
958
|
+
mockLoadAccounts.mockRejectedValue(new Error("disk error"));
|
|
959
|
+
mockSaveAccounts.mockResolvedValue(undefined);
|
|
960
|
+
|
|
961
|
+
manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
|
|
962
|
+
await manager.saveToDisk();
|
|
963
|
+
|
|
964
|
+
// Should write in-memory stats as-is
|
|
965
|
+
const stats = mockSaveAccounts.mock.calls[0][0].accounts[0].stats;
|
|
966
|
+
expect(stats.requests).toBe(1);
|
|
967
|
+
expect(stats.inputTokens).toBe(100);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it("does not let stale in-memory auth overwrite fresher disk auth", async () => {
|
|
971
|
+
const manager = await createManagerWithAccounts(1);
|
|
972
|
+
const account = manager.getAccountsSnapshot()[0];
|
|
973
|
+
|
|
974
|
+
mockLoadAccounts.mockResolvedValue(
|
|
975
|
+
makeAccountsData([
|
|
976
|
+
{
|
|
977
|
+
id: account.id,
|
|
978
|
+
refreshToken: "disk-rotated-refresh",
|
|
979
|
+
access: "disk-fresh-access",
|
|
980
|
+
expires: Date.now() + 9_000_000,
|
|
981
|
+
token_updated_at: Date.now() + 10_000,
|
|
982
|
+
},
|
|
983
|
+
]),
|
|
984
|
+
);
|
|
985
|
+
|
|
986
|
+
await manager.saveToDisk();
|
|
987
|
+
|
|
988
|
+
const saved = mockSaveAccounts.mock.calls[0][0];
|
|
989
|
+
expect(saved.accounts[0].refreshToken).toBe("disk-rotated-refresh");
|
|
990
|
+
expect(saved.accounts[0].access).toBe("disk-fresh-access");
|
|
991
|
+
expect(saved.accounts[0].token_updated_at).toBeGreaterThan(account.tokenUpdatedAt);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("matches id-less disk records by addedAt when merging auth freshness", async () => {
|
|
995
|
+
const manager = await createManagerWithAccounts(1);
|
|
996
|
+
const account = manager.getAccountsSnapshot()[0];
|
|
997
|
+
|
|
998
|
+
// Simulate legacy/id-less disk record after token rotation.
|
|
999
|
+
mockLoadAccounts.mockResolvedValue(
|
|
1000
|
+
makeAccountsData([
|
|
1001
|
+
{
|
|
1002
|
+
id: undefined,
|
|
1003
|
+
addedAt: account.addedAt,
|
|
1004
|
+
refreshToken: "disk-rotated-no-id",
|
|
1005
|
+
access: "disk-fresh-no-id",
|
|
1006
|
+
expires: Date.now() + 9_000_000,
|
|
1007
|
+
token_updated_at: Date.now() + 10_000,
|
|
1008
|
+
},
|
|
1009
|
+
]),
|
|
1010
|
+
);
|
|
1011
|
+
|
|
1012
|
+
await manager.saveToDisk();
|
|
1013
|
+
|
|
1014
|
+
const saved = mockSaveAccounts.mock.calls[0][0];
|
|
1015
|
+
expect(saved.accounts[0].refreshToken).toBe("disk-rotated-no-id");
|
|
1016
|
+
expect(saved.accounts[0].access).toBe("disk-fresh-no-id");
|
|
1017
|
+
});
|
|
1014
1018
|
});
|