mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
340 lines
9.1 KiB
TypeScript
340 lines
9.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
type HandlersModule = typeof import('../../../src/web-server/usage/handlers');
|
|
type AggregatorModule = typeof import('../../../src/web-server/usage/aggregator');
|
|
|
|
interface AssistantFixture {
|
|
project: string;
|
|
sessionId: string;
|
|
timestamp: string;
|
|
model: string;
|
|
inputTokens?: number;
|
|
outputTokens?: number;
|
|
cacheCreationTokens?: number;
|
|
cacheReadTokens?: number;
|
|
}
|
|
|
|
interface MockResponse {
|
|
payload: unknown;
|
|
statusCode: number;
|
|
status: (code: number) => MockResponse;
|
|
json: (body: unknown) => MockResponse;
|
|
}
|
|
|
|
let tempHome = '';
|
|
let claudeDir = '';
|
|
let codexDir = '';
|
|
let handlers: HandlersModule;
|
|
let aggregator: AggregatorModule;
|
|
let originalCcsHome: string | undefined;
|
|
let originalClaudeConfigDir: string | undefined;
|
|
let originalCodexHome: string | undefined;
|
|
|
|
function writeUnifiedConfigFixture(): void {
|
|
const yaml = `version: 2
|
|
accounts: {}
|
|
profiles: {}
|
|
preferences:
|
|
theme: system
|
|
telemetry: false
|
|
auto_update: true
|
|
cliproxy:
|
|
oauth_accounts: {}
|
|
providers:
|
|
- gemini
|
|
- codex
|
|
- agy
|
|
variants: {}
|
|
cliproxy_server:
|
|
local:
|
|
port: 65534
|
|
`;
|
|
|
|
fs.mkdirSync(path.join(tempHome, '.ccs'), { recursive: true });
|
|
fs.writeFileSync(path.join(tempHome, '.ccs', 'config.yaml'), yaml, 'utf-8');
|
|
}
|
|
|
|
function writeAssistantEntries(entries: AssistantFixture[]): void {
|
|
for (const entry of entries) {
|
|
const projectDir = path.join(claudeDir, 'projects', entry.project);
|
|
fs.mkdirSync(projectDir, { recursive: true });
|
|
|
|
const line = JSON.stringify({
|
|
type: 'assistant',
|
|
sessionId: entry.sessionId,
|
|
timestamp: entry.timestamp,
|
|
version: '1.0.0',
|
|
cwd: `/tmp/${entry.project}`,
|
|
message: {
|
|
model: entry.model,
|
|
usage: {
|
|
input_tokens: entry.inputTokens ?? 0,
|
|
output_tokens: entry.outputTokens ?? 0,
|
|
cache_creation_input_tokens: entry.cacheCreationTokens ?? 0,
|
|
cache_read_input_tokens: entry.cacheReadTokens ?? 0,
|
|
},
|
|
},
|
|
});
|
|
|
|
fs.writeFileSync(
|
|
path.join(projectDir, `${entry.sessionId}.jsonl`),
|
|
`${line}\n`,
|
|
'utf-8'
|
|
);
|
|
}
|
|
}
|
|
|
|
function createMockResponse(): MockResponse {
|
|
return {
|
|
payload: undefined,
|
|
statusCode: 200,
|
|
status(code: number) {
|
|
this.statusCode = code;
|
|
return this;
|
|
},
|
|
json(body: unknown) {
|
|
this.payload = body;
|
|
return this;
|
|
},
|
|
};
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-handlers-'));
|
|
claudeDir = path.join(tempHome, '.claude');
|
|
codexDir = path.join(tempHome, '.codex');
|
|
|
|
originalCcsHome = process.env.CCS_HOME;
|
|
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
originalCodexHome = process.env.CODEX_HOME;
|
|
process.env.CCS_HOME = tempHome;
|
|
process.env.CLAUDE_CONFIG_DIR = claudeDir;
|
|
process.env.CODEX_HOME = codexDir;
|
|
|
|
writeUnifiedConfigFixture();
|
|
|
|
handlers = await import('../../../src/web-server/usage/handlers');
|
|
aggregator = await import('../../../src/web-server/usage/aggregator');
|
|
aggregator.shutdownUsageAggregator();
|
|
aggregator.clearUsageCache();
|
|
});
|
|
|
|
afterEach(() => {
|
|
aggregator.shutdownUsageAggregator();
|
|
aggregator.clearUsageCache();
|
|
|
|
if (originalCcsHome !== undefined) {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
} else {
|
|
delete process.env.CCS_HOME;
|
|
}
|
|
|
|
if (originalClaudeConfigDir !== undefined) {
|
|
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
|
} else {
|
|
delete process.env.CLAUDE_CONFIG_DIR;
|
|
}
|
|
|
|
if (originalCodexHome !== undefined) {
|
|
process.env.CODEX_HOME = originalCodexHome;
|
|
} else {
|
|
delete process.env.CODEX_HOME;
|
|
}
|
|
|
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('usage handlers semantics', () => {
|
|
it('includes cache tokens in summary totals and uses calendar-day averages', async () => {
|
|
writeAssistantEntries([
|
|
{
|
|
project: 'project-one',
|
|
sessionId: 'session-a',
|
|
timestamp: '2026-03-02T10:00:00.000Z',
|
|
model: 'claude-sonnet-4-5',
|
|
inputTokens: 1_000_000,
|
|
outputTokens: 100_000,
|
|
cacheCreationTokens: 100_000,
|
|
cacheReadTokens: 200_000,
|
|
},
|
|
]);
|
|
|
|
const res = createMockResponse();
|
|
await handlers.handleSummary(
|
|
{ query: { since: '20260301', until: '20260303' } } as never,
|
|
res as never
|
|
);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.payload).toMatchObject({
|
|
success: true,
|
|
data: {
|
|
totalTokens: 1_400_000,
|
|
totalCacheTokens: 300_000,
|
|
totalDays: 3,
|
|
activeDays: 1,
|
|
averageTokensPerDay: 466_667,
|
|
averageTokensPerActiveDay: 1_400_000,
|
|
averageCostPerDay: 1.65,
|
|
averageCostPerActiveDay: 4.93,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('counts hourly requests from raw entries instead of distinct models', async () => {
|
|
writeAssistantEntries([
|
|
{
|
|
project: 'project-one',
|
|
sessionId: 'session-a',
|
|
timestamp: '2026-03-02T10:05:00.000Z',
|
|
model: 'claude-sonnet-4-5',
|
|
inputTokens: 100,
|
|
outputTokens: 10,
|
|
},
|
|
{
|
|
project: 'project-two',
|
|
sessionId: 'session-b',
|
|
timestamp: '2026-03-02T10:15:00.000Z',
|
|
model: 'claude-sonnet-4-5',
|
|
inputTokens: 120,
|
|
outputTokens: 15,
|
|
},
|
|
{
|
|
project: 'project-three',
|
|
sessionId: 'session-c',
|
|
timestamp: '2026-03-02T10:30:00.000Z',
|
|
model: 'gemini-2.5-pro',
|
|
inputTokens: 80,
|
|
outputTokens: 20,
|
|
},
|
|
]);
|
|
|
|
const res = createMockResponse();
|
|
await handlers.handleHourly(
|
|
{ query: { since: '20260302', until: '20260302' } } as never,
|
|
res as never
|
|
);
|
|
|
|
const payload = res.payload as { success: boolean; data: Array<{ hour: string; requests: number }> };
|
|
const targetHour = payload.data.find((row) => row.hour === '2026-03-02 10:00');
|
|
|
|
expect(targetHour?.requests).toBe(3);
|
|
});
|
|
|
|
it('uses overlapping months for monthly filtering', async () => {
|
|
writeAssistantEntries([
|
|
{
|
|
project: 'march-project',
|
|
sessionId: 'session-march',
|
|
timestamp: '2026-03-20T10:00:00.000Z',
|
|
model: 'claude-sonnet-4-5',
|
|
inputTokens: 100,
|
|
outputTokens: 10,
|
|
},
|
|
{
|
|
project: 'april-project',
|
|
sessionId: 'session-april',
|
|
timestamp: '2026-04-05T10:00:00.000Z',
|
|
model: 'claude-sonnet-4-5',
|
|
inputTokens: 200,
|
|
outputTokens: 20,
|
|
},
|
|
]);
|
|
|
|
const res = createMockResponse();
|
|
await handlers.handleMonthly(
|
|
{ query: { since: '20260315', until: '20260410' } } as never,
|
|
res as never
|
|
);
|
|
|
|
expect(res.payload).toMatchObject({
|
|
success: true,
|
|
data: [
|
|
expect.objectContaining({ month: '2026-03' }),
|
|
expect.objectContaining({ month: '2026-04' }),
|
|
],
|
|
});
|
|
});
|
|
|
|
it('reports actual cache size after warming the usage cache', async () => {
|
|
writeAssistantEntries([
|
|
{
|
|
project: 'project-one',
|
|
sessionId: 'session-a',
|
|
timestamp: '2026-03-02T10:00:00.000Z',
|
|
model: 'claude-sonnet-4-5',
|
|
inputTokens: 100,
|
|
outputTokens: 10,
|
|
},
|
|
]);
|
|
|
|
await aggregator.getCachedDailyData();
|
|
|
|
const res = createMockResponse();
|
|
handlers.handleStatus({} as never, res as never);
|
|
|
|
const payload = res.payload as {
|
|
success: boolean;
|
|
data: { lastFetch: number | null; cacheSize: unknown };
|
|
};
|
|
expect(payload.success).toBe(true);
|
|
expect(payload.data.lastFetch).not.toBeNull();
|
|
expect(payload.data.cacheSize).toBe(aggregator.getUsageCacheSize());
|
|
expect(aggregator.getUsageCacheSize()).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('includes cache-only model activity in model token percentages', async () => {
|
|
writeAssistantEntries([
|
|
{
|
|
project: 'project-one',
|
|
sessionId: 'session-a',
|
|
timestamp: '2026-03-02T10:00:00.000Z',
|
|
model: 'claude-sonnet-4-5',
|
|
inputTokens: 100,
|
|
outputTokens: 0,
|
|
},
|
|
{
|
|
project: 'project-two',
|
|
sessionId: 'session-b',
|
|
timestamp: '2026-03-02T11:00:00.000Z',
|
|
model: 'gemini-2.5-pro',
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadTokens: 100,
|
|
},
|
|
]);
|
|
|
|
const res = createMockResponse();
|
|
await handlers.handleModels(
|
|
{ query: { since: '20260302', until: '20260302' } } as never,
|
|
res as never
|
|
);
|
|
|
|
expect(res.payload).toMatchObject({
|
|
success: true,
|
|
data: expect.arrayContaining([
|
|
expect.objectContaining({ model: 'claude-sonnet-4-5', tokens: 100, percentage: 50 }),
|
|
expect.objectContaining({ model: 'gemini-2.5-pro', tokens: 100, percentage: 50 }),
|
|
]),
|
|
});
|
|
});
|
|
|
|
it('rejects reversed date ranges before computing summary totals', async () => {
|
|
const res = createMockResponse();
|
|
|
|
await handlers.handleSummary(
|
|
{ query: { since: '20260410', until: '20260401' } } as never,
|
|
res as never
|
|
);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.payload).toMatchObject({
|
|
success: false,
|
|
error: 'The "since" date must be earlier than or equal to "until"',
|
|
});
|
|
});
|
|
});
|