mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(web): show Codex plan usage in header
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @fileoverview Main Codex subscription usage for the shared header chip.
|
||||
*
|
||||
* Codex can return multiple model buckets. The header deliberately follows the
|
||||
* backward-compatible `codex` bucket only; model-specific buckets such as Spark
|
||||
* are separate limits and are not part of the requested row.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as telemetryModule from '../src/usage-telemetry.js';
|
||||
import * as codexResolverModule from '../src/utils/codex-cli-resolver.js';
|
||||
|
||||
const REAL_RESPONSE = {
|
||||
rateLimits: {
|
||||
limitId: 'codex',
|
||||
primary: { usedPercent: 40, windowDurationMins: 10080, resetsAt: 1788306836 },
|
||||
secondary: null,
|
||||
},
|
||||
rateLimitsByLimitId: {
|
||||
codex_bengalfox: {
|
||||
limitId: 'codex_bengalfox',
|
||||
limitName: 'GPT-5.3-Codex-Spark',
|
||||
primary: { usedPercent: 12, windowDurationMins: 300, resetsAt: 1787750984 },
|
||||
secondary: { usedPercent: 23, windowDurationMins: 10080, resetsAt: 1788337784 },
|
||||
},
|
||||
codex: {
|
||||
limitId: 'codex',
|
||||
primary: { usedPercent: 40, windowDurationMins: 10080, resetsAt: 1788306836 },
|
||||
secondary: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
type ParseCodexRateLimits = (value: unknown) => {
|
||||
fiveHour?: { usedPercentage: number; resetAt: number };
|
||||
sevenDay?: { usedPercentage: number; resetAt: number };
|
||||
} | null;
|
||||
|
||||
function parser(): ParseCodexRateLimits {
|
||||
const candidate = (telemetryModule as Record<string, unknown>).parseCodexRateLimitsResponse;
|
||||
expect(candidate, 'usage telemetry must expose the Codex rate-limit parser').toBeTypeOf('function');
|
||||
return candidate as ParseCodexRateLimits;
|
||||
}
|
||||
|
||||
describe('parseCodexRateLimitsResponse', () => {
|
||||
it('uses only the main codex bucket and maps its duration-tagged weekly window', () => {
|
||||
expect(parser()(REAL_RESPONSE)).toEqual({
|
||||
sevenDay: { usedPercentage: 40, resetAt: 1788306836 * 1000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps 5-hour and 7-day windows by duration even when their positions are reversed', () => {
|
||||
const result = parser()({
|
||||
rateLimitsByLimitId: {
|
||||
codex: {
|
||||
primary: { usedPercent: 44, windowDurationMins: 10080, resetsAt: 200 },
|
||||
secondary: { usedPercent: 17, windowDurationMins: 300, resetsAt: 100 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
fiveHour: { usedPercentage: 17, resetAt: 100_000 },
|
||||
sevenDay: { usedPercentage: 44, resetAt: 200_000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the backward-compatible rateLimits snapshot', () => {
|
||||
expect(
|
||||
parser()({
|
||||
rateLimits: {
|
||||
limitId: 'codex',
|
||||
primary: { usedPercent: 8, windowDurationMins: 300, resetsAt: 300 },
|
||||
secondary: null,
|
||||
},
|
||||
})
|
||||
).toEqual({ fiveHour: { usedPercentage: 8, resetAt: 300_000 } });
|
||||
});
|
||||
|
||||
it('ignores unrelated duration buckets and malformed percentages', () => {
|
||||
expect(
|
||||
parser()({
|
||||
rateLimitsByLimitId: {
|
||||
codex: {
|
||||
primary: { usedPercent: '40', windowDurationMins: 10080, resetsAt: 200 },
|
||||
secondary: { usedPercent: 20, windowDurationMins: 60, resetsAt: 100 },
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
type CodexRequest = (
|
||||
binaryPath: string,
|
||||
clientVersion: string,
|
||||
request?: (binaryPath: string, clientVersion: string) => Promise<unknown>
|
||||
) => Promise<ReturnType<ParseCodexRateLimits>>;
|
||||
|
||||
describe('readCodexPlanUsage', () => {
|
||||
it('queries through the supplied app-server boundary and normalizes the result', async () => {
|
||||
const candidate = (codexResolverModule as Record<string, unknown>).readCodexPlanUsage;
|
||||
expect(candidate, 'the Codex resolver must expose a read-only usage query').toBeTypeOf('function');
|
||||
const request = vi.fn(async () => REAL_RESPONSE);
|
||||
|
||||
await expect((candidate as CodexRequest)('/opt/codex', '1.23.0', request)).resolves.toEqual({
|
||||
sevenDay: { usedPercentage: 40, resetAt: 1788306836 * 1000 },
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith('/opt/codex', '1.23.0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/** @fileoverview Header plan-usage chip provider rows (Claude above Codex). */
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadCodemanAppClass() {
|
||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const chip = { innerHTML: '', title: '' };
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
|
||||
document: {
|
||||
addEventListener: vi.fn(),
|
||||
getElementById: (id: string) => (id === 'planUsageChip' ? chip : null),
|
||||
},
|
||||
localStorage: {
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return {
|
||||
CodemanApp: (context as { __CodemanApp: new () => unknown }).__CodemanApp,
|
||||
chip,
|
||||
};
|
||||
}
|
||||
|
||||
type UsageApp = { updatePlanUsageChip: (data: unknown) => void };
|
||||
|
||||
describe('header plan usage chip', () => {
|
||||
it('renders Claude first and the main Codex limits underneath', () => {
|
||||
const { CodemanApp, chip } = loadCodemanAppClass();
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
|
||||
|
||||
app.updatePlanUsageChip({
|
||||
fiveHour: { usedPercentage: 97, resetAt: 1000 },
|
||||
sevenDay: { usedPercentage: 44, resetAt: 2000 },
|
||||
codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } },
|
||||
});
|
||||
|
||||
expect(chip.innerHTML).toContain('class="pu-row"');
|
||||
expect(chip.innerHTML).toContain('class="pu-provider">Claude</span>');
|
||||
expect(chip.innerHTML).toContain('class="pu-provider">Codex</span>');
|
||||
expect(chip.innerHTML.indexOf('Claude')).toBeLessThan(chip.innerHTML.indexOf('Codex'));
|
||||
expect(chip.innerHTML).toContain('97%');
|
||||
expect(chip.innerHTML).toContain('44%');
|
||||
expect(chip.innerHTML).toContain('40%');
|
||||
expect(chip.title).toContain('Claude plan usage');
|
||||
expect(chip.title).toContain('Codex plan usage');
|
||||
});
|
||||
|
||||
it('omits unavailable Codex windows instead of inventing zero usage', () => {
|
||||
const { CodemanApp, chip } = loadCodemanAppClass();
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
|
||||
|
||||
app.updatePlanUsageChip({
|
||||
fiveHour: { usedPercentage: 10, resetAt: 1000 },
|
||||
sevenDay: { usedPercentage: 20, resetAt: 2000 },
|
||||
codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } },
|
||||
});
|
||||
|
||||
const codexRow = chip.innerHTML.slice(chip.innerHTML.indexOf('Codex'));
|
||||
expect(codexRow).not.toContain('5h');
|
||||
expect(codexRow).toContain('7d');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/** @fileoverview Merging Claude status-line usage with polled Codex usage. */
|
||||
|
||||
import { expect, it } from 'vitest';
|
||||
import * as latestModule from '../src/web/plan-usage-latest.js';
|
||||
|
||||
it('preserves the Codex row when a later Claude status-line sample arrives', () => {
|
||||
const module = latestModule as Record<string, unknown>;
|
||||
expect(module.setLatestCodexPlanUsage).toBeTypeOf('function');
|
||||
const setCodex = module.setLatestCodexPlanUsage as (value: unknown) => unknown;
|
||||
const setClaude = module.setLatestPlanUsage as (value: Record<string, unknown>) => unknown;
|
||||
|
||||
setCodex({ sevenDay: { usedPercentage: 40, resetAt: 3000 } });
|
||||
expect(setClaude({ sessionId: 's1', fiveHour: { usedPercentage: 97, resetAt: 1000 } })).toEqual({
|
||||
sessionId: 's1',
|
||||
fiveHour: { usedPercentage: 97, resetAt: 1000 },
|
||||
codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } },
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user