mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
Fixes the two collector-side root causes of #1601: 1. Claude per-profile credential reads were file-only, but on macOS Claude Code stores the OAuth token for an isolated CLAUDE_CONFIG_DIR in a per-directory Keychain item ("Claude Code-credentials-<sha256(dir)[0..8]>"). The .credentials.json file never exists, so every isolated profile was parked with needsReauth:true forever. The reader now falls back to that Keychain item (file-first, same security-CLI read the shipped global fallback already performs; TTL-gated so it is not on every /summary). 2. Non-default profiles were cache-only forever, so they could never leave the parked state even with valid credentials. getNativeAccountRows now gives each surface ONE rotating live slot: the stalest eligible non-default profile is refreshed per pass, skipping profiles inside breaker/reauth cooldowns. Every account converges to real quota within a few polls while the per-pass upstream budget stays constant (<= 2 calls per surface regardless of profile count). Codex named profiles with valid auth but sparse payloads now yield an active quota-less row instead of a false needsReauth row. Non-default rows keep paused:true (dimmed) even when freshly refreshed so only the default renders active and rows do not flicker between polls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1830 lines
64 KiB
TypeScript
1830 lines
64 KiB
TypeScript
/**
|
|
* Tests for the native subscription quota collector.
|
|
*
|
|
* Neither the Anthropic nor the ChatGPT endpoint is ever hit — all fetches are
|
|
* injected via NativeQuotaDeps. A controllable clock drives TTL / backoff /
|
|
* breaker assertions for both the Claude and Codex paths.
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import {
|
|
getNativeAccountRows,
|
|
getCachedNativeAccountRows,
|
|
resetNativeQuotaState,
|
|
type NativeQuotaDeps,
|
|
} from '../../../src/web-server/usage/native-quota-collector';
|
|
import type { ClaudeQuotaResult, CodexQuotaResult } from '../../../src/cliproxy/quota/quota-types';
|
|
import type { ClaudeNativeCredentials } from '../../../src/web-server/usage/claude-native-credentials';
|
|
import type { CodexLocalQuota } from '../../../src/web-server/usage/codex-local-quota-collector';
|
|
|
|
// A jump comfortably past any single-call backoff cooldown (<= 60s), used so a
|
|
// breaker-test fetch is not blocked by the prior 429's per-call cooldown.
|
|
const MAX_COOLDOWN_JUMP = 61_000;
|
|
|
|
function maxCreds(): ClaudeNativeCredentials {
|
|
return { claudeAiOauth: { accessToken: 'native-tok', subscriptionType: 'max' } };
|
|
}
|
|
|
|
function successQuota(): ClaudeQuotaResult {
|
|
return {
|
|
success: true,
|
|
windows: [],
|
|
coreUsage: {
|
|
fiveHour: {
|
|
rateLimitType: 'five_hour',
|
|
label: 'Session limit',
|
|
remainingPercent: 42,
|
|
resetAt: '2026-06-09T20:00:00.000Z',
|
|
status: 'allowed',
|
|
},
|
|
weekly: {
|
|
rateLimitType: 'seven_day',
|
|
label: 'Weekly limit',
|
|
remainingPercent: 70,
|
|
resetAt: '2026-06-15T00:00:00.000Z',
|
|
status: 'allowed',
|
|
},
|
|
},
|
|
lastUpdated: Date.now(),
|
|
accountId: 'claude-code',
|
|
};
|
|
}
|
|
|
|
/** A Max-plan quota carrying the Opus/Sonnet weekly splits in windows[]. */
|
|
function maxQuotaWithSplits(): ClaudeQuotaResult {
|
|
const base = successQuota();
|
|
return {
|
|
...base,
|
|
windows: [
|
|
{
|
|
rateLimitType: 'seven_day_opus',
|
|
label: 'Opus weekly',
|
|
status: 'allowed',
|
|
utilization: 0.25,
|
|
usedPercent: 25,
|
|
remainingPercent: 75,
|
|
resetAt: '2026-06-15T00:00:00.000Z',
|
|
},
|
|
{
|
|
rateLimitType: 'seven_day_sonnet',
|
|
label: 'Sonnet weekly',
|
|
status: 'allowed',
|
|
utilization: 0.6,
|
|
usedPercent: 60,
|
|
remainingPercent: 40,
|
|
resetAt: '2026-06-15T00:00:00.000Z',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function reauthQuota(): ClaudeQuotaResult {
|
|
return {
|
|
success: false,
|
|
windows: [],
|
|
coreUsage: { fiveHour: null, weekly: null },
|
|
lastUpdated: Date.now(),
|
|
accountId: 'claude-code',
|
|
needsReauth: true,
|
|
error: 'Authentication required',
|
|
};
|
|
}
|
|
|
|
function rateLimitedQuota(retryAfter?: string): ClaudeQuotaResult {
|
|
return {
|
|
success: false,
|
|
windows: [],
|
|
coreUsage: { fiveHour: null, weekly: null },
|
|
lastUpdated: Date.now(),
|
|
accountId: 'claude-code',
|
|
httpStatus: 429,
|
|
retryable: true,
|
|
...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}),
|
|
error: 'rate limited',
|
|
};
|
|
}
|
|
|
|
/** Build a deps object with a controllable clock + counted fetch. */
|
|
function makeDeps(
|
|
fetchImpl: (token: string) => Promise<ClaudeQuotaResult>,
|
|
clock: { now: number },
|
|
credsImpl: () => ClaudeNativeCredentials | null = maxCreds
|
|
): NativeQuotaDeps & { fetchCount: () => number } {
|
|
let count = 0;
|
|
return {
|
|
readCredentials: credsImpl,
|
|
fetchClaudeQuota: async (token: string) => {
|
|
count += 1;
|
|
return fetchImpl(token);
|
|
},
|
|
getCodexQuota: async () => null,
|
|
now: () => clock.now,
|
|
sleep: async () => {},
|
|
fetchCount: () => count,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetNativeQuotaState();
|
|
});
|
|
|
|
describe('Claude native row mapping', () => {
|
|
it('maps a successful fetch into a claude-code ok row with min remaining', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(async () => successQuota(), clock);
|
|
const rows = await getNativeAccountRows(deps);
|
|
|
|
const row = rows.find((r) => r.provider === 'claude-code');
|
|
expect(row).toBeDefined();
|
|
expect(row?.quotaStatus).toBe('ok');
|
|
// min(42, 70)
|
|
expect(row?.quota_percentage).toBe(42);
|
|
expect(row?.next_reset).toBe('2026-06-09T20:00:00.000Z');
|
|
expect(row?.tier).toBe('max');
|
|
expect(row?.displayName).toBe('Claude Code');
|
|
expect(row?.account_id).toBe('claude-code');
|
|
expect(row?.needsReauth).toBe(false);
|
|
});
|
|
|
|
it('emits quotaWindows with five_hour + seven_day from coreUsage (no opus/sonnet on non-Max)', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(async () => successQuota(), clock);
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'claude-code');
|
|
|
|
expect(row?.quotaWindows).toHaveLength(2);
|
|
const five = row?.quotaWindows?.find((w) => w.key === 'five_hour');
|
|
expect(five?.label).toBe('5h');
|
|
expect(five?.remainingPercent).toBe(42);
|
|
expect(five?.usedPercent).toBe(58); // 100 - 42
|
|
expect(five?.windowMinutes).toBe(300);
|
|
expect(five?.resetAt).toBe('2026-06-09T20:00:00.000Z');
|
|
|
|
const week = row?.quotaWindows?.find((w) => w.key === 'seven_day');
|
|
expect(week?.label).toBe('week');
|
|
expect(week?.remainingPercent).toBe(70);
|
|
expect(week?.usedPercent).toBe(30);
|
|
expect(week?.windowMinutes).toBe(10080);
|
|
|
|
// Max-only splits absent here.
|
|
expect(row?.quotaWindows?.find((w) => w.key === 'seven_day_opus')).toBeUndefined();
|
|
expect(row?.quotaWindows?.find((w) => w.key === 'seven_day_sonnet')).toBeUndefined();
|
|
});
|
|
|
|
it('adds seven_day_opus + seven_day_sonnet windows when present (Max plan)', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(async () => maxQuotaWithSplits(), clock);
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'claude-code');
|
|
|
|
// five_hour, seven_day, seven_day_opus, seven_day_sonnet
|
|
expect(row?.quotaWindows).toHaveLength(4);
|
|
const opus = row?.quotaWindows?.find((w) => w.key === 'seven_day_opus');
|
|
expect(opus?.label).toBe('Opus · week');
|
|
expect(opus?.usedPercent).toBe(25);
|
|
expect(opus?.remainingPercent).toBe(75);
|
|
expect(opus?.windowMinutes).toBe(10080);
|
|
|
|
const sonnet = row?.quotaWindows?.find((w) => w.key === 'seven_day_sonnet');
|
|
expect(sonnet?.label).toBe('Sonnet · week');
|
|
expect(sonnet?.usedPercent).toBe(60);
|
|
expect(sonnet?.remainingPercent).toBe(40);
|
|
});
|
|
|
|
it('emits a reauth error row on 401/needsReauth', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(async () => reauthQuota(), clock);
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'claude-code');
|
|
expect(row?.quotaStatus).toBe('error');
|
|
expect(row?.health).toBe('error');
|
|
expect(row?.needsReauth).toBe(true);
|
|
});
|
|
|
|
it('omits the claude row when there is no token', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(
|
|
async () => successQuota(),
|
|
clock,
|
|
() => null
|
|
);
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(rows.find((r) => r.provider === 'claude-code')).toBeUndefined();
|
|
});
|
|
|
|
it('omits the claude row for an unsupported (free) subscription without spending a call', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(
|
|
async () => successQuota(),
|
|
clock,
|
|
() => ({
|
|
claudeAiOauth: { accessToken: 'x', subscriptionType: 'free' },
|
|
})
|
|
);
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(rows.find((r) => r.provider === 'claude-code')).toBeUndefined();
|
|
expect(deps.fetchCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('cache / TTL', () => {
|
|
it('serves cache within TTL and does NOT re-fetch', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(async () => successQuota(), clock);
|
|
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(1);
|
|
|
|
// Advance < 10 min: still cached.
|
|
clock.now += 5 * 60 * 1000;
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(1);
|
|
expect(rows.find((r) => r.provider === 'claude-code')?.cached).toBe(true);
|
|
});
|
|
|
|
it('re-fetches after the TTL expires', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(async () => successQuota(), clock);
|
|
|
|
await getNativeAccountRows(deps);
|
|
clock.now += 11 * 60 * 1000; // past 10-min TTL
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('in-flight coalescing', () => {
|
|
it('shares ONE fetch across concurrent callers past TTL', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
let resolveFetch: (q: ClaudeQuotaResult) => void = () => {};
|
|
const gate = new Promise<ClaudeQuotaResult>((resolve) => {
|
|
resolveFetch = resolve;
|
|
});
|
|
const deps = makeDeps(async () => gate, clock);
|
|
|
|
const p1 = getNativeAccountRows(deps);
|
|
const p2 = getNativeAccountRows(deps);
|
|
resolveFetch(successQuota());
|
|
await Promise.all([p1, p2]);
|
|
|
|
expect(deps.fetchCount()).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('Retry-After + backoff + circuit breaker', () => {
|
|
it('honors Retry-After: no fetch until the cooldown elapses', async () => {
|
|
const t0 = 1_000_000;
|
|
const clock = { now: t0 };
|
|
const deps = makeDeps(async () => rateLimitedQuota('30'), clock);
|
|
|
|
await getNativeAccountRows(deps); // fetch #1 -> 429, cooldown = t0 + 30s
|
|
expect(deps.fetchCount()).toBe(1);
|
|
|
|
// Within the 30s Retry-After cooldown -> zero network even though there is
|
|
// no cached row yet.
|
|
clock.now = t0 + 10_000;
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(1);
|
|
|
|
// Past the 30s cooldown -> a fetch is allowed again.
|
|
clock.now = t0 + 31_000;
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(2);
|
|
});
|
|
|
|
it('trips the breaker after 3 consecutive 429s, then a success closes it', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
let mode: 'fail' | 'ok' = 'fail';
|
|
const deps = makeDeps(
|
|
async () => (mode === 'fail' ? rateLimitedQuota() : successQuota()),
|
|
clock
|
|
);
|
|
|
|
// Three 429s; each separated past the per-call cooldown so they actually fetch.
|
|
for (let i = 0; i < 3; i++) {
|
|
await getNativeAccountRows(deps);
|
|
// jump past TTL and any backoff cooldown
|
|
clock.now += 11 * 60 * 1000 + MAX_COOLDOWN_JUMP;
|
|
}
|
|
expect(deps.fetchCount()).toBe(3);
|
|
|
|
// Breaker is open now -> zero network even past TTL.
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(3);
|
|
|
|
// Advance past the 15-min breaker cooldown; allow a success which closes it.
|
|
clock.now += 16 * 60 * 1000;
|
|
mode = 'ok';
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(4);
|
|
|
|
// After success, breaker closed: another fetch past TTL proceeds.
|
|
clock.now += 11 * 60 * 1000;
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('stale-on-fail', () => {
|
|
it('returns the last good row when a subsequent fetch rejects', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
let mode: 'ok' | 'throw' = 'ok';
|
|
const deps = makeDeps(async () => {
|
|
if (mode === 'throw') throw new Error('network down');
|
|
return successQuota();
|
|
}, clock);
|
|
|
|
await getNativeAccountRows(deps); // good row cached
|
|
mode = 'throw';
|
|
clock.now += 11 * 60 * 1000; // force re-fetch
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'claude-code');
|
|
expect(row).toBeDefined();
|
|
expect(row?.quotaStatus).toBe('ok');
|
|
expect(row?.cached).toBe(true);
|
|
});
|
|
|
|
it('omits the row when the first-ever fetch fails (no prior cache)', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(async () => {
|
|
throw new Error('network down');
|
|
}, clock);
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(rows.find((r) => r.provider === 'claude-code')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Codex network helpers
|
|
// ============================================================================
|
|
|
|
function codexSuccessQuota(): CodexQuotaResult {
|
|
return {
|
|
success: true,
|
|
windows: [],
|
|
coreUsage: {
|
|
fiveHour: {
|
|
label: 'Primary',
|
|
remainingPercent: 60,
|
|
resetAfterSeconds: 3600,
|
|
resetAt: '2026-06-09T19:00:00.000Z',
|
|
},
|
|
weekly: {
|
|
label: 'Secondary',
|
|
remainingPercent: 80,
|
|
resetAfterSeconds: 86400 * 4,
|
|
resetAt: '2026-06-13T00:00:00.000Z',
|
|
},
|
|
},
|
|
planType: 'pro',
|
|
lastUpdated: Date.now(),
|
|
accountId: 'codex-user@example.com',
|
|
};
|
|
}
|
|
|
|
function codexRateLimitedQuota(retryAfter?: string): CodexQuotaResult {
|
|
return {
|
|
success: false,
|
|
windows: [],
|
|
planType: null,
|
|
lastUpdated: Date.now(),
|
|
accountId: 'codex-user@example.com',
|
|
httpStatus: 429,
|
|
retryable: true,
|
|
...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}),
|
|
error: 'rate limited',
|
|
};
|
|
}
|
|
|
|
function codexReatuhQuota(): CodexQuotaResult {
|
|
return {
|
|
success: false,
|
|
windows: [],
|
|
planType: null,
|
|
lastUpdated: Date.now(),
|
|
accountId: 'codex-user@example.com',
|
|
needsReauth: true,
|
|
error: 'Token expired',
|
|
};
|
|
}
|
|
|
|
function codexLocalQuota(): CodexLocalQuota {
|
|
return {
|
|
quotaPercentage: 30,
|
|
nextReset: '2026-06-09T19:00:00.000Z',
|
|
tier: 'pro',
|
|
stale: true,
|
|
staleAsOf: '2026-06-09T13:30:00.000Z',
|
|
windows: [
|
|
{
|
|
key: 'five_hour',
|
|
label: '5h',
|
|
usedPercent: 70,
|
|
remainingPercent: 30,
|
|
resetAt: '2026-06-09T19:00:00.000Z',
|
|
windowMinutes: 300,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a NativeQuotaDeps that completely stubs the Codex path.
|
|
* Claude path is disabled (no credentials) so only the Codex row is produced.
|
|
*/
|
|
function makeCodexDeps(
|
|
overrides: Partial<NativeQuotaDeps> & { clock: { now: number } }
|
|
): NativeQuotaDeps & { networkCount: () => number; localCount: () => number } {
|
|
let networkCalls = 0;
|
|
let localCalls = 0;
|
|
const { clock, ...rest } = overrides;
|
|
return {
|
|
// Disable Claude path
|
|
readCredentials: () => null,
|
|
// Default network quota: success
|
|
fetchCodexNetworkQuota: async (_id: string) => {
|
|
networkCalls += 1;
|
|
return codexSuccessQuota();
|
|
},
|
|
// Default local fallback: stale data
|
|
getCodexQuota: async () => {
|
|
localCalls += 1;
|
|
return codexLocalQuota();
|
|
},
|
|
// Default account id resolved
|
|
getDefaultCodexAccountId: () => 'codex-user@example.com',
|
|
now: () => clock.now,
|
|
sleep: async () => {},
|
|
// Allow overriding any dep
|
|
...rest,
|
|
networkCount: () => networkCalls,
|
|
localCount: () => localCalls,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Codex network path tests
|
|
// ============================================================================
|
|
|
|
describe('Codex network path', () => {
|
|
it('production profile enumeration skips paused Codex accounts before live network refresh', async () => {
|
|
const originalCcsHome = process.env.CCS_HOME;
|
|
const originalHome = process.env.HOME;
|
|
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-paused-codex-'));
|
|
process.env.CCS_HOME = tempHome;
|
|
process.env.HOME = tempHome;
|
|
|
|
try {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const cliproxyDir = path.join(ccsDir, 'cliproxy');
|
|
fs.mkdirSync(cliproxyDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'codex-profiles.yaml'),
|
|
[
|
|
'version: "1.0"',
|
|
'default: paused',
|
|
'profiles:',
|
|
' paused:',
|
|
' type: codex',
|
|
' created: "2026-01-01T00:00:00.000Z"',
|
|
' last_used: null',
|
|
' email: paused-codex@example.com',
|
|
' account_id: paused-codex@example.com',
|
|
' active:',
|
|
' type: codex',
|
|
' created: "2026-01-02T00:00:00.000Z"',
|
|
' last_used: null',
|
|
' email: active-codex@example.com',
|
|
' account_id: active-codex@example.com',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(cliproxyDir, 'accounts.json'),
|
|
JSON.stringify(
|
|
{
|
|
version: 1,
|
|
providers: {
|
|
codex: {
|
|
default: 'paused-codex@example.com',
|
|
accounts: {
|
|
'paused-codex@example.com': {
|
|
email: 'paused-codex@example.com',
|
|
tokenFile: 'paused-codex@example.com.json',
|
|
paused: true,
|
|
},
|
|
'active-codex@example.com': {
|
|
email: 'active-codex@example.com',
|
|
tokenFile: 'active-codex@example.com.json',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
fs.mkdirSync(path.join(cliproxyDir, 'auth'), { recursive: true });
|
|
fs.mkdirSync(path.join(cliproxyDir, 'auth-paused'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(cliproxyDir, 'auth', 'active-codex@example.com.json'),
|
|
JSON.stringify({ type: 'codex' })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(cliproxyDir, 'auth-paused', 'paused-codex@example.com.json'),
|
|
JSON.stringify({ type: 'codex' })
|
|
);
|
|
|
|
const fetchedAccountIds: string[] = [];
|
|
const deps = makeCodexDeps({
|
|
clock: { now: 1_000_000 },
|
|
listClaudeProfiles: () => [],
|
|
readCredentials: undefined,
|
|
getDefaultCodexAccountId: undefined,
|
|
readCodexNativeAuth: (profile: string) => ({
|
|
accessToken: `token-${profile}`,
|
|
accountId: profile === 'active' ? 'active-codex@example.com' : 'paused-codex@example.com',
|
|
}),
|
|
fetchCodexNetworkQuota: async (accountId: string) => {
|
|
fetchedAccountIds.push(accountId);
|
|
return { ...codexSuccessQuota(), accountId };
|
|
},
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
|
|
expect(fetchedAccountIds).toEqual(['active-codex@example.com']);
|
|
expect(rows.find((row) => row.profile === 'paused')).toBeUndefined();
|
|
const active = rows.find((row) => row.profile === 'active');
|
|
expect(active?.paused).toBe(false);
|
|
expect(active?.is_default).toBe(true);
|
|
} finally {
|
|
if (originalCcsHome !== undefined) {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
} else {
|
|
delete process.env.CCS_HOME;
|
|
}
|
|
if (originalHome !== undefined) {
|
|
process.env.HOME = originalHome;
|
|
} else {
|
|
delete process.env.HOME;
|
|
}
|
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('production profile enumeration does not live-refresh when all Codex accounts are paused', async () => {
|
|
const originalCcsHome = process.env.CCS_HOME;
|
|
const originalHome = process.env.HOME;
|
|
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-all-paused-codex-'));
|
|
process.env.CCS_HOME = tempHome;
|
|
process.env.HOME = tempHome;
|
|
|
|
try {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const cliproxyDir = path.join(ccsDir, 'cliproxy');
|
|
fs.mkdirSync(cliproxyDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'codex-profiles.yaml'),
|
|
[
|
|
'version: "1.0"',
|
|
'default: paused',
|
|
'profiles:',
|
|
' paused:',
|
|
' type: codex',
|
|
' created: "2026-01-01T00:00:00.000Z"',
|
|
' last_used: null',
|
|
' email: paused-codex@example.com',
|
|
' account_id: paused-codex@example.com',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(cliproxyDir, 'accounts.json'),
|
|
JSON.stringify(
|
|
{
|
|
version: 1,
|
|
providers: {
|
|
codex: {
|
|
default: 'paused-codex@example.com',
|
|
accounts: {
|
|
'paused-codex@example.com': {
|
|
email: 'paused-codex@example.com',
|
|
tokenFile: 'paused-codex@example.com.json',
|
|
paused: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
fs.mkdirSync(path.join(cliproxyDir, 'auth-paused'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(cliproxyDir, 'auth-paused', 'paused-codex@example.com.json'),
|
|
JSON.stringify({ type: 'codex' })
|
|
);
|
|
|
|
let networkCalls = 0;
|
|
const deps = makeCodexDeps({
|
|
clock: { now: 1_000_000 },
|
|
listClaudeProfiles: () => [],
|
|
readCredentials: undefined,
|
|
getDefaultCodexAccountId: undefined,
|
|
readCodexNativeAuth: (profile: string) => ({
|
|
accessToken: `token-${profile}`,
|
|
accountId: 'paused-codex@example.com',
|
|
}),
|
|
fetchCodexNetworkQuota: async () => {
|
|
networkCalls += 1;
|
|
return codexSuccessQuota();
|
|
},
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
|
|
expect(networkCalls).toBe(0);
|
|
expect(deps.localCount()).toBe(0);
|
|
expect(rows.find((row) => row.profile === 'paused')).toBeUndefined();
|
|
} finally {
|
|
if (originalCcsHome !== undefined) {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
} else {
|
|
delete process.env.CCS_HOME;
|
|
}
|
|
if (originalHome !== undefined) {
|
|
process.env.HOME = originalHome;
|
|
} else {
|
|
delete process.env.HOME;
|
|
}
|
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
it('network success builds a fresh row from coreUsage — no staleAsOf, health ok, correct windows', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeCodexDeps({ clock });
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'codex');
|
|
|
|
expect(row).toBeDefined();
|
|
expect(row?.quotaStatus).toBe('ok');
|
|
expect(row?.health).toBe('ok');
|
|
expect(row?.tier).toBe('pro');
|
|
expect(row?.needsReauth).toBe(false);
|
|
// No staleAsOf on a live result
|
|
expect(row?.staleAsOf).toBeUndefined();
|
|
// quota_percentage = min(60, 80) = 60
|
|
expect(row?.quota_percentage).toBe(60);
|
|
// next_reset = soonest = fiveHour resetAt
|
|
expect(row?.next_reset).toBe('2026-06-09T19:00:00.000Z');
|
|
// quotaWindows: five_hour + seven_day
|
|
expect(row?.quotaWindows).toHaveLength(2);
|
|
const fiveHr = row?.quotaWindows?.find((w) => w.key === 'five_hour');
|
|
expect(fiveHr?.label).toBe('5h');
|
|
expect(fiveHr?.remainingPercent).toBe(60);
|
|
expect(fiveHr?.usedPercent).toBe(40);
|
|
expect(fiveHr?.windowMinutes).toBe(300);
|
|
expect(fiveHr?.resetAt).toBe('2026-06-09T19:00:00.000Z');
|
|
const week = row?.quotaWindows?.find((w) => w.key === 'seven_day');
|
|
expect(week?.label).toBe('week');
|
|
expect(week?.remainingPercent).toBe(80);
|
|
expect(week?.usedPercent).toBe(20);
|
|
expect(week?.windowMinutes).toBe(10080);
|
|
// Network was called; local was NOT (network succeeded)
|
|
expect(deps.networkCount()).toBe(1);
|
|
expect(deps.localCount()).toBe(0);
|
|
});
|
|
|
|
it('network failure falls back to local stale row — staleAsOf set, health warning', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeCodexDeps({
|
|
clock,
|
|
fetchCodexNetworkQuota: async () => ({
|
|
success: false,
|
|
windows: [],
|
|
planType: null,
|
|
lastUpdated: Date.now(),
|
|
error: 'network error',
|
|
retryable: true,
|
|
}),
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'codex');
|
|
|
|
expect(row).toBeDefined();
|
|
expect(row?.health).toBe('warning');
|
|
expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z');
|
|
expect(deps.localCount()).toBe(1);
|
|
});
|
|
|
|
it('force bypasses TTL and re-fetches from network', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeCodexDeps({ clock });
|
|
|
|
// Prime the cache
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.networkCount()).toBe(1);
|
|
|
|
// Normal call within TTL: served from cache
|
|
clock.now += 5 * 60 * 1000;
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.networkCount()).toBe(1);
|
|
|
|
// Force call: bypasses TTL, re-fetches
|
|
await getNativeAccountRows(deps, { force: true });
|
|
expect(deps.networkCount()).toBe(2);
|
|
});
|
|
|
|
it('token_expired (needsReauth) returns a reauth row — not cached as good', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeCodexDeps({
|
|
clock,
|
|
fetchCodexNetworkQuota: async () => codexReatuhQuota(),
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'codex');
|
|
|
|
expect(row?.quotaStatus).toBe('error');
|
|
expect(row?.health).toBe('error');
|
|
expect(row?.needsReauth).toBe(true);
|
|
// Local fallback was NOT called (reauth is a distinct path, not a transient error)
|
|
expect(deps.localCount()).toBe(0);
|
|
});
|
|
|
|
it('breaker-open path: skips network and serves local fallback', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
let networkCalls = 0;
|
|
let localCalls = 0;
|
|
|
|
const deps: NativeQuotaDeps & { networkCount: () => number; localCount: () => number } = {
|
|
readCredentials: () => null,
|
|
getDefaultCodexAccountId: () => 'codex-user@example.com',
|
|
fetchCodexNetworkQuota: async () => {
|
|
networkCalls += 1;
|
|
return codexRateLimitedQuota();
|
|
},
|
|
getCodexQuota: async () => {
|
|
localCalls += 1;
|
|
return codexLocalQuota();
|
|
},
|
|
now: () => clock.now,
|
|
sleep: async () => {},
|
|
networkCount: () => networkCalls,
|
|
localCount: () => localCalls,
|
|
};
|
|
|
|
// Trip the breaker with 3 consecutive 429s
|
|
for (let i = 0; i < 3; i++) {
|
|
await getNativeAccountRows(deps);
|
|
// advance past per-call cooldown between each
|
|
clock.now += 62_000;
|
|
}
|
|
const callsAfterTrip = networkCalls;
|
|
|
|
// Breaker is now open: next call must skip network
|
|
await getNativeAccountRows(deps);
|
|
expect(networkCalls).toBe(callsAfterTrip); // no new network call
|
|
// Local fallback is called instead
|
|
expect(localCalls).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('no-account path: skips network and serves local fallback', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
let networkCalls = 0;
|
|
let localCalls = 0;
|
|
|
|
const deps: NativeQuotaDeps = {
|
|
readCredentials: () => null,
|
|
getDefaultCodexAccountId: () => null, // no account configured
|
|
fetchCodexNetworkQuota: async () => {
|
|
networkCalls += 1;
|
|
return codexSuccessQuota();
|
|
},
|
|
getCodexQuota: async () => {
|
|
localCalls += 1;
|
|
return codexLocalQuota();
|
|
},
|
|
now: () => clock.now,
|
|
sleep: async () => {},
|
|
};
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'codex');
|
|
|
|
// No network call since no account
|
|
expect(networkCalls).toBe(0);
|
|
// Local fallback was used
|
|
expect(localCalls).toBe(1);
|
|
// Row is present from local data
|
|
expect(row).toBeDefined();
|
|
expect(row?.health).toBe('warning'); // local stale
|
|
});
|
|
|
|
it('network success with EMPTY coreUsage falls back to local (no contentless ok row)', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
// Track our own network counter — an override passed to makeCodexDeps
|
|
// replaces the helper's default counting fetcher, so deps.networkCount()
|
|
// would not see this override's calls.
|
|
let networkCalls = 0;
|
|
const deps = makeCodexDeps({
|
|
clock,
|
|
// Healthy response but no resolved core windows (e.g. only code-review
|
|
// windows or a changed payload) — carries no glanceable 5h/weekly signal.
|
|
fetchCodexNetworkQuota: async () => {
|
|
networkCalls += 1;
|
|
return {
|
|
success: true,
|
|
windows: [],
|
|
coreUsage: { fiveHour: null, weekly: null },
|
|
planType: 'pro',
|
|
lastUpdated: clock.now,
|
|
accountId: 'codex-user@example.com',
|
|
};
|
|
},
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'codex');
|
|
|
|
// Network was attempted, but the empty result must NOT be cached as an "ok"
|
|
// row — the local fallback supplies real (stale) data instead.
|
|
expect(networkCalls).toBe(1);
|
|
expect(deps.localCount()).toBe(1);
|
|
expect(row).toBeDefined();
|
|
expect(row?.health).toBe('warning'); // came from local stale path
|
|
expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z');
|
|
expect(row?.quotaWindows).toHaveLength(1); // local five_hour window
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Codex path (original local-only tests, preserved)
|
|
// These tests explicitly set getDefaultCodexAccountId: () => null so the
|
|
// network path is skipped and only the local fallback is exercised.
|
|
// ============================================================================
|
|
|
|
describe('Codex path', () => {
|
|
it('maps a local Codex quota into a codex ok row', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps: NativeQuotaDeps = {
|
|
readCredentials: () => null, // no claude row
|
|
getDefaultCodexAccountId: () => null, // disable network path
|
|
getCodexQuota: async () => ({
|
|
quotaPercentage: 52,
|
|
nextReset: '2026-06-09T19:00:00.000Z',
|
|
tier: 'pro',
|
|
stale: false,
|
|
staleAsOf: null,
|
|
windows: [
|
|
{
|
|
key: 'five_hour',
|
|
label: '5h',
|
|
usedPercent: 19,
|
|
remainingPercent: 81,
|
|
resetAt: '2026-06-09T19:00:00.000Z',
|
|
windowMinutes: 300,
|
|
},
|
|
{
|
|
key: 'seven_day',
|
|
label: 'week',
|
|
usedPercent: 48,
|
|
remainingPercent: 52,
|
|
resetAt: '2026-06-14T00:00:00.000Z',
|
|
windowMinutes: 10080,
|
|
},
|
|
],
|
|
}),
|
|
now: () => clock.now,
|
|
};
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'codex');
|
|
expect(row?.quotaStatus).toBe('ok');
|
|
expect(row?.quota_percentage).toBe(52);
|
|
expect(row?.tier).toBe('pro');
|
|
expect(row?.health).toBe('ok');
|
|
expect(row?.quotaWindows).toHaveLength(2);
|
|
expect(row?.quotaWindows?.[0].windowMinutes).toBe(300);
|
|
expect(row?.staleAsOf).toBeUndefined();
|
|
});
|
|
|
|
it('flags health warning when the Codex source is stale', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps: NativeQuotaDeps = {
|
|
readCredentials: () => null,
|
|
getDefaultCodexAccountId: () => null, // disable network path
|
|
getCodexQuota: async () => ({
|
|
quotaPercentage: 10,
|
|
nextReset: null,
|
|
tier: null,
|
|
stale: true,
|
|
staleAsOf: '2026-06-09T13:30:00.000Z',
|
|
windows: [],
|
|
}),
|
|
now: () => clock.now,
|
|
};
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'codex');
|
|
expect(row?.health).toBe('warning');
|
|
// staleAsOf flows through so the bar can render the freshness footnote.
|
|
expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z');
|
|
});
|
|
|
|
it('omits the codex row when there is no rate_limits (exec-mode)', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps: NativeQuotaDeps = {
|
|
readCredentials: () => null,
|
|
getDefaultCodexAccountId: () => null, // disable network path
|
|
getCodexQuota: async () => null,
|
|
now: () => clock.now,
|
|
};
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(rows.find((r) => r.provider === 'codex')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getCachedNativeAccountRows (instant, no-fetch fallback)', () => {
|
|
it('returns [] before any successful collect', () => {
|
|
expect(getCachedNativeAccountRows()).toEqual([]);
|
|
});
|
|
|
|
it('returns the last cached codex row (cached=true) after a successful collect, [] after reset', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeCodexDeps({ clock });
|
|
|
|
// Prime the cache via a successful network collect.
|
|
await getNativeAccountRows(deps);
|
|
|
|
const cached = getCachedNativeAccountRows();
|
|
const codex = cached.find((r) => r.provider === 'codex');
|
|
expect(codex).toBeDefined();
|
|
expect(codex?.cached).toBe(true);
|
|
// No additional network call — the accessor reads cache only.
|
|
expect(deps.networkCount()).toBe(1);
|
|
|
|
resetNativeQuotaState();
|
|
expect(getCachedNativeAccountRows()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Multi-profile path tests (GH-1595)
|
|
//
|
|
// These tests inject listClaudeProfiles / listCodexProfiles / defaultClaudeProfile
|
|
// / defaultCodexProfile so the production profile-enumeration path is exercised
|
|
// without touching real ~/.ccs or any Keychain. The readClaudeCredentialsForProfile
|
|
// and readCodexNativeAuth seams prevent fs access.
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Build a NativeQuotaDeps for the multi-profile path.
|
|
*
|
|
* - claudeProfiles: profile names for the Claude surface (ccs)
|
|
* - codexProfiles: profile names for the Codex surface (ccsx)
|
|
* - claudeDefault / codexDefault: the only live-polled profile per surface (paused:false)
|
|
* - credsForProfile: map from profile name to credentials (null = parked)
|
|
* - claudeFetch: network fetcher for Claude (all profiles share one implementation)
|
|
* - codexNativeAuth: map from profile name to {accessToken, accountId}
|
|
* - codexNetworkFetch: network fetcher for Codex (all profiles share one impl)
|
|
*/
|
|
function makeMultiProfileDeps(opts: {
|
|
clock: { now: number };
|
|
claudeProfiles: string[];
|
|
codexProfiles: string[];
|
|
claudeDefault?: string | null;
|
|
codexDefault?: string | null;
|
|
credsForProfile?: (profile: string) => ClaudeNativeCredentials | null;
|
|
claudeFetch?: (token: string, accountId?: string) => Promise<ClaudeQuotaResult>;
|
|
codexNativeAuth?: (profile: string) => { accessToken: string; accountId: string } | null;
|
|
codexNetworkFetch?: (accountId: string) => Promise<CodexQuotaResult>;
|
|
codexLocalFallback?: () => Promise<CodexLocalQuota | null>;
|
|
}): NativeQuotaDeps & {
|
|
claudeFetchCount: () => number;
|
|
codexNetworkCount: () => number;
|
|
} {
|
|
let claudeFetches = 0;
|
|
let codexNetworkFetches = 0;
|
|
|
|
const {
|
|
clock,
|
|
claudeProfiles,
|
|
codexProfiles,
|
|
claudeDefault = null,
|
|
codexDefault = null,
|
|
credsForProfile = () => null,
|
|
claudeFetch = async () => successQuota(),
|
|
codexNativeAuth = () => null,
|
|
codexNetworkFetch = async () => codexSuccessQuota(),
|
|
codexLocalFallback = async () => null,
|
|
} = opts;
|
|
|
|
return {
|
|
// Enumeration seams
|
|
listClaudeProfiles: () => claudeProfiles,
|
|
listCodexProfiles: () => codexProfiles,
|
|
defaultClaudeProfile: () => claudeDefault,
|
|
defaultCodexProfile: () => codexDefault,
|
|
// Credential seams (file-only, no keychain)
|
|
readClaudeCredentialsForProfile: credsForProfile,
|
|
readCodexNativeAuth: codexNativeAuth,
|
|
// Fetch seams
|
|
fetchClaudeQuota: async (token: string, accountId?: string) => {
|
|
claudeFetches += 1;
|
|
return claudeFetch(token, accountId);
|
|
},
|
|
fetchCodexNetworkQuota: async (accountId: string) => {
|
|
codexNetworkFetches += 1;
|
|
return codexNetworkFetch(accountId);
|
|
},
|
|
getCodexQuota: codexLocalFallback,
|
|
// Disable legacy single-profile paths
|
|
readCredentials: () => null,
|
|
getDefaultCodexAccountId: () => null,
|
|
// Clock + sleep seams
|
|
now: () => clock.now,
|
|
sleep: async () => {},
|
|
// Counters
|
|
claudeFetchCount: () => claudeFetches,
|
|
codexNetworkCount: () => codexNetworkFetches,
|
|
};
|
|
}
|
|
|
|
describe('multi-profile: account_id and wire fields', () => {
|
|
it('Claude profile rows carry surface="ccs", account_id="ccs:<p>", is_subscription=true', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['work', 'ck'],
|
|
codexProfiles: [],
|
|
claudeDefault: 'work',
|
|
// 'work' has creds; 'ck' does not (parked)
|
|
credsForProfile: (p) => (p === 'work' ? maxCreds() : null),
|
|
claudeFetch: async () => successQuota(),
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(rows.length).toBe(2);
|
|
|
|
const work = rows.find((r) => r.profile === 'work');
|
|
expect(work).toBeDefined();
|
|
expect(work?.account_id).toBe('ccs:work');
|
|
expect(work?.surface).toBe('ccs');
|
|
expect(work?.is_subscription).toBe(true);
|
|
expect(work?.provider).toBe('claude-code');
|
|
|
|
const ck = rows.find((r) => r.profile === 'ck');
|
|
expect(ck).toBeDefined();
|
|
expect(ck?.account_id).toBe('ccs:ck');
|
|
expect(ck?.surface).toBe('ccs');
|
|
expect(ck?.is_subscription).toBe(true);
|
|
});
|
|
|
|
it('Codex profile rows carry surface="ccsx", account_id="ccsx:<p>", is_subscription=true', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: [],
|
|
codexProfiles: ['personal', 'ck'],
|
|
codexDefault: 'personal',
|
|
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
|
codexNetworkFetch: async () => codexSuccessQuota(),
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(rows.length).toBe(2);
|
|
|
|
const personal = rows.find((r) => r.profile === 'personal');
|
|
expect(personal?.account_id).toBe('ccsx:personal');
|
|
expect(personal?.surface).toBe('ccsx');
|
|
expect(personal?.is_subscription).toBe(true);
|
|
expect(personal?.provider).toBe('codex');
|
|
|
|
const ck = rows.find((r) => r.profile === 'ck');
|
|
expect(ck?.account_id).toBe('ccsx:ck');
|
|
expect(ck?.surface).toBe('ccsx');
|
|
expect(ck?.is_subscription).toBe(true);
|
|
});
|
|
|
|
it('only default profiles are live-polled; is_default marks the default independently', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
// Claude: work = default + creds (live); ck = non-default + NO creds (parked).
|
|
claudeProfiles: ['work', 'ck'],
|
|
// Codex: personal = default + creds (live); ck = NON-default + creds (cache-only).
|
|
codexProfiles: ['personal', 'ck'],
|
|
claudeDefault: 'work',
|
|
codexDefault: 'personal',
|
|
credsForProfile: (p) => (p === 'work' ? maxCreds() : null),
|
|
claudeFetch: async () => successQuota(),
|
|
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
|
codexNetworkFetch: async () => codexSuccessQuota(),
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
|
|
// Claude work: default + creds -> live, not dimmed.
|
|
const claudeWork = rows.find((r) => r.surface === 'ccs' && r.profile === 'work');
|
|
expect(claudeWork?.paused).toBe(false);
|
|
expect(claudeWork?.is_default).toBe(true);
|
|
|
|
// Claude ck: non-default + NO creds -> parked/dimmed.
|
|
const claudeCk = rows.find((r) => r.surface === 'ccs' && r.profile === 'ck');
|
|
expect(claudeCk?.paused).toBe(true);
|
|
expect(claudeCk?.is_default).toBe(false);
|
|
|
|
// Codex personal: default + creds -> live.
|
|
const codexPersonal = rows.find((r) => r.surface === 'ccsx' && r.profile === 'personal');
|
|
expect(codexPersonal?.paused).toBe(false);
|
|
expect(codexPersonal?.is_default).toBe(true);
|
|
|
|
// Codex ck: NON-default with creds is cache-only/parked. Only the default
|
|
// profile is live-polled so a dashboard request cannot fan out to every
|
|
// configured subscription account.
|
|
const codexCk = rows.find((r) => r.surface === 'ccsx' && r.profile === 'ck');
|
|
expect(codexCk?.paused).toBe(true);
|
|
expect(codexCk?.is_default).toBe(false);
|
|
});
|
|
|
|
it('N Claude + M Codex profiles produce N+M rows', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const claudeProfiles = ['work', 'ck', 'personal'];
|
|
const codexProfiles = ['personal', 'ck'];
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles,
|
|
codexProfiles,
|
|
claudeDefault: 'work',
|
|
codexDefault: 'personal',
|
|
credsForProfile: () => maxCreds(),
|
|
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(rows.length).toBe(claudeProfiles.length + codexProfiles.length);
|
|
// Budget per pass: the default + one rotating non-default live slot per
|
|
// surface — constant regardless of profile count.
|
|
expect(deps.claudeFetchCount()).toBe(2);
|
|
expect(deps.codexNetworkCount()).toBe(2);
|
|
});
|
|
|
|
it('rows are sorted by (surface, profile)', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['work', 'ck'],
|
|
codexProfiles: ['ck', 'personal'],
|
|
claudeDefault: 'work',
|
|
codexDefault: 'personal',
|
|
credsForProfile: () => maxCreds(),
|
|
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
const keys = rows.map((r) => `${r.surface}:${r.profile}`);
|
|
// ccs:ck < ccs:work < ccsx:ck < ccsx:personal
|
|
expect(keys).toEqual(['ccs:ck', 'ccs:work', 'ccsx:ck', 'ccsx:personal']);
|
|
});
|
|
});
|
|
|
|
describe('multi-profile: Claude file-only reader', () => {
|
|
it('profile with .credentials.json present -> live fetch row (paused:false when default)', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['work'],
|
|
codexProfiles: [],
|
|
claudeDefault: 'work',
|
|
credsForProfile: (p) => (p === 'work' ? maxCreds() : null),
|
|
claudeFetch: async () => successQuota(),
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(rows.length).toBe(1);
|
|
const row = rows[0];
|
|
expect(row?.profile).toBe('work');
|
|
expect(row?.quotaStatus).toBe('ok');
|
|
expect(row?.needsReauth).toBe(false);
|
|
expect(row?.paused).toBe(false);
|
|
expect(deps.claudeFetchCount()).toBe(1);
|
|
});
|
|
|
|
it('profile without .credentials.json -> parked row (needsReauth:true, no live fetch)', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['ck'],
|
|
codexProfiles: [],
|
|
claudeDefault: 'ck',
|
|
credsForProfile: () => null, // no file on disk
|
|
claudeFetch: async () => successQuota(),
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(rows.length).toBe(1);
|
|
const row = rows[0];
|
|
expect(row?.profile).toBe('ck');
|
|
expect(row?.needsReauth).toBe(true);
|
|
expect(row?.quota_percentage).toBeNull();
|
|
// No live network call when creds are absent
|
|
expect(deps.claudeFetchCount()).toBe(0);
|
|
});
|
|
|
|
it('absent creds row has quotaStatus unsupported (honest "needs auth" state)', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['ck'],
|
|
codexProfiles: [],
|
|
claudeDefault: 'ck',
|
|
credsForProfile: () => null,
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows[0];
|
|
expect(row?.quotaStatus).toBe('unsupported');
|
|
expect(row?.is_subscription).toBe(true);
|
|
});
|
|
|
|
it('existing default profile directory without creds does not read global credentials', async () => {
|
|
const originalCcsHome = process.env.CCS_HOME;
|
|
const tempCcsHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-default-profile-'));
|
|
const clock = { now: 1_000_000 };
|
|
let defaultReadCount = 0;
|
|
let fetchCount = 0;
|
|
|
|
try {
|
|
process.env.CCS_HOME = tempCcsHome;
|
|
fs.mkdirSync(path.join(tempCcsHome, '.ccs', 'instances', 'default'), { recursive: true });
|
|
|
|
const rows = await getNativeAccountRows({
|
|
readCredentials: () => {
|
|
defaultReadCount += 1;
|
|
return { claudeAiOauth: { accessToken: 'global-token', subscriptionType: 'max' } };
|
|
},
|
|
listClaudeProfiles: () => ['default'],
|
|
listCodexProfiles: () => [],
|
|
defaultClaudeProfile: () => 'default',
|
|
defaultCodexProfile: () => null,
|
|
fetchClaudeQuota: async () => {
|
|
fetchCount += 1;
|
|
return successQuota();
|
|
},
|
|
getCodexQuota: async () => null,
|
|
now: () => clock.now,
|
|
sleep: async () => {},
|
|
});
|
|
|
|
const row = rows.find((r) => r.surface === 'ccs' && r.profile === 'default');
|
|
expect(defaultReadCount).toBe(0);
|
|
expect(fetchCount).toBe(0);
|
|
expect(row?.needsReauth).toBe(true);
|
|
expect(row?.quotaStatus).toBe('unsupported');
|
|
} finally {
|
|
if (originalCcsHome === undefined) {
|
|
delete process.env.CCS_HOME;
|
|
} else {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
}
|
|
fs.rmSync(tempCcsHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('existing default profile credentials win over global credentials', async () => {
|
|
const originalCcsHome = process.env.CCS_HOME;
|
|
const tempCcsHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-default-profile-'));
|
|
const clock = { now: 1_000_000 };
|
|
let fetchedToken: string | null = null;
|
|
|
|
try {
|
|
process.env.CCS_HOME = tempCcsHome;
|
|
const defaultInstanceDir = path.join(tempCcsHome, '.ccs', 'instances', 'default');
|
|
fs.mkdirSync(defaultInstanceDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(defaultInstanceDir, '.credentials.json'),
|
|
JSON.stringify({ claudeAiOauth: { accessToken: 'profile-token', subscriptionType: 'max' } })
|
|
);
|
|
|
|
const rows = await getNativeAccountRows({
|
|
readCredentials: () => ({
|
|
claudeAiOauth: { accessToken: 'global-token', subscriptionType: 'max' },
|
|
}),
|
|
listClaudeProfiles: () => ['default'],
|
|
listCodexProfiles: () => [],
|
|
defaultClaudeProfile: () => 'default',
|
|
defaultCodexProfile: () => null,
|
|
fetchClaudeQuota: async (token) => {
|
|
fetchedToken = token;
|
|
return successQuota();
|
|
},
|
|
getCodexQuota: async () => null,
|
|
now: () => clock.now,
|
|
sleep: async () => {},
|
|
});
|
|
|
|
const row = rows.find((r) => r.surface === 'ccs' && r.profile === 'default');
|
|
expect(fetchedToken).toBe('profile-token');
|
|
expect(row?.needsReauth).toBe(false);
|
|
expect(row?.quotaStatus).toBe('ok');
|
|
} finally {
|
|
if (originalCcsHome === undefined) {
|
|
delete process.env.CCS_HOME;
|
|
} else {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
}
|
|
fs.rmSync(tempCcsHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('multi-profile: per-profile circuit breaker isolation', () => {
|
|
it("one profile's 429 does not open another profile's breaker", async () => {
|
|
const MAX_COOLDOWN_JUMP_MP = 61_000;
|
|
const clock = { now: 1_000_000 };
|
|
let workFails = true;
|
|
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['work', 'ck'],
|
|
codexProfiles: [],
|
|
claudeDefault: 'work',
|
|
credsForProfile: () => maxCreds(),
|
|
claudeFetch: async (_token, accountId) => {
|
|
// 'work' (ccs:work) always 429s; 'ck' remains cache-only
|
|
if (accountId?.includes('work') && workFails) {
|
|
return {
|
|
success: false,
|
|
windows: [],
|
|
coreUsage: { fiveHour: null, weekly: null },
|
|
lastUpdated: Date.now(),
|
|
accountId: accountId ?? 'ccs:work',
|
|
httpStatus: 429,
|
|
retryable: true,
|
|
error: 'rate limited',
|
|
} as ClaudeQuotaResult;
|
|
}
|
|
return successQuota();
|
|
},
|
|
});
|
|
|
|
// Trip the work breaker with 3 consecutive 429s.
|
|
for (let i = 0; i < 3; i++) {
|
|
resetNativeQuotaState();
|
|
clock.now += i === 0 ? 0 : MAX_COOLDOWN_JUMP_MP;
|
|
// Re-inject the multi-profile deps after reset so the state maps are fresh.
|
|
await getNativeAccountRows({
|
|
...deps,
|
|
listClaudeProfiles: () => ['work'],
|
|
listCodexProfiles: () => [],
|
|
defaultClaudeProfile: () => 'work',
|
|
});
|
|
}
|
|
|
|
// After the three 429s on 'work', check that 'ck' remains cache-only.
|
|
// We reset state to have a clean run with no prior breaker history.
|
|
resetNativeQuotaState();
|
|
clock.now += MAX_COOLDOWN_JUMP_MP;
|
|
workFails = false;
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
const ckRow = rows.find((r) => r.profile === 'ck');
|
|
const workRow = rows.find((r) => r.profile === 'work');
|
|
|
|
// 'ck' gets its own live row via the rotating slot, independent of work's
|
|
// breaker history.
|
|
expect(ckRow?.quotaStatus).toBe('ok');
|
|
// 'work' is also fine after reset (no breaker state).
|
|
expect(workRow?.quotaStatus).toBe('ok');
|
|
});
|
|
|
|
it("per-profile breaker: one profile's 429s only block that profile", async () => {
|
|
const clock = { now: 1_000_000 };
|
|
let workCall429Count = 0;
|
|
|
|
// 'work' returns 429 each call; 'ck' returns success.
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['work', 'ck'],
|
|
codexProfiles: [],
|
|
claudeDefault: 'work',
|
|
credsForProfile: () => maxCreds(),
|
|
claudeFetch: async (_token, accountId) => {
|
|
if (accountId?.includes('work')) {
|
|
workCall429Count += 1;
|
|
return {
|
|
success: false,
|
|
windows: [],
|
|
coreUsage: { fiveHour: null, weekly: null },
|
|
lastUpdated: clock.now,
|
|
accountId: accountId ?? '',
|
|
httpStatus: 429,
|
|
retryable: true,
|
|
error: 'rate limited',
|
|
} as ClaudeQuotaResult;
|
|
}
|
|
return successQuota();
|
|
},
|
|
});
|
|
|
|
// First call: 'work' gets a 429; 'ck' is refreshed by the rotating slot and
|
|
// succeeds — work's failures do not leak into ck's state.
|
|
const rows1 = await getNativeAccountRows(deps);
|
|
const ck1 = rows1.find((r) => r.profile === 'ck');
|
|
expect(ck1?.quotaStatus).toBe('ok');
|
|
expect(workCall429Count).toBeGreaterThanOrEqual(1);
|
|
|
|
// Skip past cooldown for 'work' only; 'ck' is within TTL.
|
|
clock.now += 62_000;
|
|
|
|
// Second call past 'work' cooldown: work tries again (429 again); ck cached.
|
|
const rows2 = await getNativeAccountRows(deps);
|
|
const ck2 = rows2.find((r) => r.profile === 'ck');
|
|
// 'ck' keeps its healthy cached row and is not affected by work's breaker.
|
|
expect(ck2?.quotaStatus).toBe('ok');
|
|
});
|
|
});
|
|
|
|
describe('multi-profile: displayName uses profile name', () => {
|
|
it('displayName is the profile name, not "Claude Code" or "Codex"', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['my-work'],
|
|
codexProfiles: ['my-codex'],
|
|
claudeDefault: 'my-work',
|
|
codexDefault: 'my-codex',
|
|
credsForProfile: () => maxCreds(),
|
|
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
const c = rows.find((r) => r.surface === 'ccs');
|
|
const x = rows.find((r) => r.surface === 'ccsx');
|
|
expect(c?.displayName).toBe('my-work');
|
|
expect(x?.displayName).toBe('my-codex');
|
|
});
|
|
});
|
|
|
|
describe('multi-profile: getCachedNativeAccountRows reflects per-profile maps', () => {
|
|
it('returns cached rows from all profiles after a collect', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['work', 'ck'],
|
|
codexProfiles: ['personal'],
|
|
claudeDefault: 'work',
|
|
codexDefault: 'personal',
|
|
credsForProfile: () => maxCreds(),
|
|
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
|
});
|
|
|
|
await getNativeAccountRows(deps);
|
|
|
|
const cached = getCachedNativeAccountRows();
|
|
expect(cached.every((r) => r.cached === true)).toBe(true);
|
|
// Should have rows for work, ck, and personal (parked 'ck' has no cached row
|
|
// yet because it had creds in this test so it did fetch)
|
|
const profiles = cached.map((r) => r.profile);
|
|
expect(profiles).toContain('work');
|
|
expect(profiles).toContain('personal');
|
|
|
|
resetNativeQuotaState();
|
|
expect(getCachedNativeAccountRows()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('review focus areas: reauth caching + codex local fallback', () => {
|
|
it('Claude reauth (401) profile is dimmed, cached, and not re-polled within cooldown', async () => {
|
|
resetNativeQuotaState();
|
|
const clock = { now: 5_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['work'],
|
|
codexProfiles: [],
|
|
claudeDefault: 'work',
|
|
credsForProfile: () => maxCreds(),
|
|
claudeFetch: async () => reauthQuota(),
|
|
});
|
|
|
|
const first = await getNativeAccountRows(deps);
|
|
const r1 = first.find((r) => r.profile === 'work');
|
|
expect(r1?.needsReauth).toBe(true);
|
|
expect(r1?.paused).toBe(true); // dimmed
|
|
expect(deps.claudeFetchCount()).toBe(1);
|
|
|
|
// A forced refresh within the cooldown serves the cached reauth row and does
|
|
// NOT re-hit the endpoint (no repeated 401 on the same account).
|
|
const second = await getNativeAccountRows(deps, { force: true });
|
|
const r2 = second.find((r) => r.profile === 'work');
|
|
expect(r2?.needsReauth).toBe(true);
|
|
expect(r2?.cached).toBe(true);
|
|
expect(deps.claudeFetchCount()).toBe(1);
|
|
});
|
|
|
|
it('Codex reauth (401) profile is dimmed, cached, and not re-polled within cooldown', async () => {
|
|
resetNativeQuotaState();
|
|
const clock = { now: 5_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: [],
|
|
codexProfiles: ['ck'],
|
|
codexDefault: 'default',
|
|
codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }),
|
|
codexNetworkFetch: async () => ({ success: false, needsReauth: true }) as CodexQuotaResult,
|
|
});
|
|
|
|
// The rotating slot polls 'ck' once; the 401 parks it into the reauth
|
|
// cooldown.
|
|
const first = await getNativeAccountRows(deps);
|
|
const r1 = first.find((r) => r.profile === 'ck');
|
|
expect(r1?.needsReauth).toBe(true);
|
|
expect(r1?.paused).toBe(true);
|
|
expect(deps.codexNetworkCount()).toBe(1);
|
|
|
|
// Within the cooldown it is NOT re-polled (no repeated 401s), even forced.
|
|
const second = await getNativeAccountRows(deps, { force: true });
|
|
expect(second.find((r) => r.profile === 'ck')?.cached).toBe(true);
|
|
expect(deps.codexNetworkCount()).toBe(1);
|
|
});
|
|
|
|
it('Codex named profile without on-disk auth is parked, never filled from global local data', async () => {
|
|
resetNativeQuotaState();
|
|
const clock = { now: 5_000_000 };
|
|
let localCalls = 0;
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: [],
|
|
codexProfiles: ['ck'],
|
|
codexDefault: 'default',
|
|
codexNativeAuth: () => null, // no auth.json for the named profile
|
|
codexLocalFallback: async () => {
|
|
localCalls += 1;
|
|
return codexLocalQuota();
|
|
},
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
const ck = rows.find((r) => r.profile === 'ck');
|
|
expect(ck).toBeDefined();
|
|
expect(ck?.paused).toBe(true); // parked, dimmed
|
|
expect(ck?.needsReauth).toBe(true);
|
|
expect(ck?.quota_percentage).toBeNull();
|
|
// The global ~/.codex session data is never attributed to a named profile.
|
|
expect(localCalls).toBe(0);
|
|
});
|
|
|
|
it('Codex default profile without auth uses the global local session fallback', async () => {
|
|
resetNativeQuotaState();
|
|
const clock = { now: 5_000_000 };
|
|
let localCalls = 0;
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: [],
|
|
codexProfiles: ['default'],
|
|
codexDefault: 'default',
|
|
codexNativeAuth: () => null,
|
|
codexLocalFallback: async () => {
|
|
localCalls += 1;
|
|
return codexLocalQuota();
|
|
},
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
const def = rows.find((r) => r.profile === 'default');
|
|
expect(def).toBeDefined();
|
|
// The bare default legitimately reflects the global ~/.codex local data.
|
|
expect(localCalls).toBe(1);
|
|
expect(def?.quotaStatus).not.toBe('unsupported');
|
|
});
|
|
|
|
it('cache-fallback rows keep is_default for the default profile', async () => {
|
|
resetNativeQuotaState();
|
|
const clock = { now: 6_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['work', 'ck'],
|
|
codexProfiles: ['default', 'ck'],
|
|
claudeDefault: 'work',
|
|
codexDefault: 'default',
|
|
credsForProfile: () => maxCreds(),
|
|
claudeFetch: async () => successQuota(),
|
|
codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }),
|
|
codexNetworkFetch: async () => codexSuccessQuota(),
|
|
});
|
|
|
|
await getNativeAccountRows(deps); // populate the per-profile caches
|
|
|
|
// The cache-fallback path must preserve is_default so the UI still orders and
|
|
// tags the default account when /summary serves from cache.
|
|
const cached = getCachedNativeAccountRows();
|
|
expect(cached.find((r) => r.surface === 'ccs' && r.profile === 'work')?.is_default).toBe(true);
|
|
expect(cached.find((r) => r.surface === 'ccsx' && r.profile === 'default')?.is_default).toBe(
|
|
true
|
|
);
|
|
expect(cached.find((r) => r.surface === 'ccs' && r.profile === 'ck')?.is_default).toBe(false);
|
|
expect(cached.find((r) => r.surface === 'ccsx' && r.profile === 'ck')?.is_default).toBe(false);
|
|
});
|
|
|
|
it('parked (no-creds) rows use a short TTL so a fresh login is detected quickly', async () => {
|
|
resetNativeQuotaState();
|
|
const clock = { now: 7_000_000 };
|
|
let hasCreds = false;
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['work'],
|
|
codexProfiles: [],
|
|
claudeDefault: 'work',
|
|
credsForProfile: () => (hasCreds ? maxCreds() : null),
|
|
claudeFetch: async () => successQuota(),
|
|
});
|
|
|
|
// First poll: no creds -> parked, no network.
|
|
const first = await getNativeAccountRows(deps);
|
|
expect(first.find((r) => r.profile === 'work')?.paused).toBe(true);
|
|
expect(deps.claudeFetchCount()).toBe(0);
|
|
|
|
// User logs in; advance past the short parked TTL (30s) but far within the
|
|
// full quota TTL (10min). The parked row must NOT be served stale.
|
|
hasCreds = true;
|
|
clock.now += 31_000;
|
|
const second = await getNativeAccountRows(deps);
|
|
const work = second.find((r) => r.profile === 'work');
|
|
expect(work?.paused).toBe(false); // now live, not dimmed
|
|
expect(work?.quotaStatus).toBe('ok');
|
|
expect(deps.claudeFetchCount()).toBe(1); // re-checked -> fetched
|
|
});
|
|
|
|
it('Codex named profile with valid auth but sparse payload yields an active quota-less row', async () => {
|
|
resetNativeQuotaState();
|
|
const clock = { now: 7_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: [],
|
|
codexProfiles: ['ck'],
|
|
codexDefault: 'default',
|
|
codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }),
|
|
// Successful response but NO core windows (sparse / changed payload).
|
|
codexNetworkFetch: async () => ({ success: true }) as CodexQuotaResult,
|
|
});
|
|
|
|
// The rotating slot polls 'ck'; the token authenticated, so it is a valid
|
|
// active subscription with a sparse payload — an active quota-less row,
|
|
// still dimmed because it is not the default profile.
|
|
const rows = await getNativeAccountRows(deps);
|
|
const ck = rows.find((r) => r.profile === 'ck');
|
|
expect(ck).toBeDefined();
|
|
expect(ck?.paused).toBe(true); // non-default rows always render dimmed
|
|
expect(ck?.needsReauth).toBe(false);
|
|
expect(ck?.quota_percentage).toBeNull(); // no windows in the payload
|
|
});
|
|
|
|
it('non-default cache-only rows do not overwrite the canonical live cache', async () => {
|
|
resetNativeQuotaState();
|
|
const clock = { now: 8_000_000 };
|
|
let codexDefault = 'ck';
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: [],
|
|
codexProfiles: ['default', 'ck'],
|
|
codexDefault,
|
|
codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }),
|
|
codexNetworkFetch: async () => codexSuccessQuota(),
|
|
});
|
|
deps.defaultCodexProfile = () => codexDefault;
|
|
|
|
// First pass: 'ck' (default) is live-polled and the rotating slot also
|
|
// refreshes 'default' — two upstream calls.
|
|
const first = await getNativeAccountRows(deps);
|
|
expect(first.find((r) => r.profile === 'ck')?.paused).toBe(false);
|
|
expect(deps.codexNetworkCount()).toBe(2);
|
|
|
|
codexDefault = 'default';
|
|
const second = await getNativeAccountRows(deps);
|
|
const ckAsNonDefault = second.find((r) => r.profile === 'ck');
|
|
expect(ckAsNonDefault?.cached).toBe(true);
|
|
expect(ckAsNonDefault?.paused).toBe(true);
|
|
expect(deps.codexNetworkCount()).toBe(2);
|
|
|
|
codexDefault = 'ck';
|
|
const third = await getNativeAccountRows(deps);
|
|
const ckDefaultAgain = third.find((r) => r.profile === 'ck');
|
|
expect(ckDefaultAgain?.cached).toBe(true);
|
|
expect(ckDefaultAgain?.paused).toBe(false);
|
|
expect(deps.codexNetworkCount()).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Multi-profile: rotating live slot for non-default profiles
|
|
//
|
|
// Non-default profiles used to be cache-only forever, so accounts other than
|
|
// the default never showed real quota — they sat parked ("needs re-auth") for
|
|
// the lifetime of the server. One rotating live slot per surface refreshes the
|
|
// stalest eligible non-default profile per pass, so every account converges to
|
|
// real data within a few polls while the per-pass upstream budget stays
|
|
// constant (<= 2 calls per surface) regardless of profile count.
|
|
// ============================================================================
|
|
|
|
describe('multi-profile: rotating live slot for non-default profiles', () => {
|
|
it('live-polls the default plus one stale non-default Claude profile per pass', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['work', 'personal', 'fc'],
|
|
codexProfiles: [],
|
|
claudeDefault: 'work',
|
|
credsForProfile: () => maxCreds(),
|
|
claudeFetch: async () => successQuota(),
|
|
});
|
|
|
|
// Pass 1: default (work) + one stale non-default get live data.
|
|
let rows = await getNativeAccountRows(deps);
|
|
expect(deps.claudeFetchCount()).toBe(2);
|
|
expect(rows.filter((r) => r.quotaStatus === 'ok').length).toBe(2);
|
|
|
|
// Pass 2 after the parked TTL: the remaining profile gets its live row.
|
|
clock.now += 31_000;
|
|
rows = await getNativeAccountRows(deps);
|
|
expect(deps.claudeFetchCount()).toBe(3);
|
|
expect(rows.filter((r) => r.quotaStatus === 'ok').length).toBe(3);
|
|
|
|
// Pass 3 while everything is within TTL: fully cached, zero upstream calls.
|
|
clock.now += 1_000;
|
|
rows = await getNativeAccountRows(deps);
|
|
expect(deps.claudeFetchCount()).toBe(3);
|
|
expect(rows.filter((r) => r.quotaStatus === 'ok').length).toBe(3);
|
|
});
|
|
|
|
it('rotated non-default rows keep paused:true (only the default renders active)', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['work', 'personal'],
|
|
codexProfiles: [],
|
|
claudeDefault: 'work',
|
|
credsForProfile: () => maxCreds(),
|
|
claudeFetch: async () => successQuota(),
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
const personal = rows.find((r) => r.profile === 'personal');
|
|
expect(personal?.quotaStatus).toBe('ok');
|
|
expect(personal?.needsReauth).toBe(false);
|
|
expect(personal?.paused).toBe(true);
|
|
expect(personal?.is_default).toBe(false);
|
|
});
|
|
|
|
it('codex non-default profiles also get one rotating live slot per pass', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: [],
|
|
codexProfiles: ['personal', 'ck'],
|
|
codexDefault: 'personal',
|
|
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
|
|
codexNetworkFetch: async () => codexSuccessQuota(),
|
|
});
|
|
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(deps.codexNetworkCount()).toBe(2);
|
|
const ck = rows.find((r) => r.profile === 'ck');
|
|
expect(ck?.paused).toBe(true);
|
|
expect(ck?.needsReauth).toBe(false);
|
|
});
|
|
|
|
it('profiles in reauth cooldown are skipped by the rotating slot', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
let failProfile: string | null = 'personal';
|
|
const deps = makeMultiProfileDeps({
|
|
clock,
|
|
claudeProfiles: ['work', 'personal'],
|
|
codexProfiles: [],
|
|
claudeDefault: 'work',
|
|
credsForProfile: () => maxCreds(),
|
|
claudeFetch: async (_token: string, accountId?: string) =>
|
|
accountId === `ccs:${failProfile}`
|
|
? ({ success: false, needsReauth: true, retryable: false } as ClaudeQuotaResult)
|
|
: successQuota(),
|
|
});
|
|
|
|
// Pass 1: personal is rotated in, 401s, and enters the reauth cooldown.
|
|
let rows = await getNativeAccountRows(deps);
|
|
expect(rows.find((r) => r.profile === 'personal')?.needsReauth).toBe(true);
|
|
expect(deps.claudeFetchCount()).toBe(2);
|
|
|
|
// Pass 2 inside the cooldown: the slot must NOT re-poll (and re-401) it.
|
|
clock.now += 31_000;
|
|
rows = await getNativeAccountRows(deps);
|
|
expect(deps.claudeFetchCount()).toBe(2);
|
|
expect(rows.find((r) => r.profile === 'personal')?.needsReauth).toBe(true);
|
|
});
|
|
});
|