Files
cc-web/scripts/usage-statistics-unit.js

229 lines
9.2 KiB
JavaScript

'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
SCHEMA_VERSION,
UsageStatisticsError,
createUsageStatisticsIndex,
} = require('../lib/usage-statistics');
function writeSession(sessionsDir, session) {
const filePath = path.join(sessionsDir, `${session.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
return filePath;
}
function sessionFixture() {
return {
id: 'usage-session-a',
title: '统计夹具 A',
created: '2026-07-27T00:00:00.000Z',
updated: '2026-07-29T03:00:00.000Z',
cwd: '/tmp/usage-project',
agent: 'codexapp',
messages: [
{
role: 'user',
content: '不能出现在统计响应里的正文 secret-body',
timestamp: '2026-07-28T01:00:00.000Z',
composerMentions: [
{ kind: 'skill', name: 'openai-docs', label: '$openai-docs' },
{ kind: 'file', name: 'AGENTS.md', label: '@AGENTS.md' },
],
},
{
role: 'assistant',
content: '已处理',
timestamp: '2026-07-28T02:00:00.000Z',
toolCalls: [
{
name: 'McpToolCall',
kind: 'mcp_tool_call',
input: { server: 'ccweb', tool: 'ccweb_list_conversations', arguments: { secret: true } },
done: true,
result: 'secret-result',
meta: { kind: 'mcp_tool_call', subtitle: 'ccweb.ccweb_list_conversations', status: 'completed' },
},
{
name: 'McpToolCall',
kind: 'mcp_tool_call',
input: { server: 'ccweb', tool: 'ccweb_send_message', arguments: {} },
done: true,
result: 'failed secret-result',
meta: { kind: 'mcp_tool_call', subtitle: 'ccweb.ccweb_send_message', status: 'failed' },
},
{
name: 'Read',
kind: 'function_call',
input: { file: 'SKILL.md' },
done: true,
},
],
},
{
role: 'user',
content: '跨会话消息',
timestamp: '2026-07-29T03:00:00.000Z',
crossConversation: { sourceSessionId: 'source-session' },
},
{
role: 'assistant',
content: '范围外调用',
timestamp: '2026-08-03T00:00:00.000Z',
toolCalls: [{
name: 'McpToolCall',
kind: 'mcp_tool_call',
input: { server: 'outside', tool: 'outside_tool' },
done: true,
meta: { status: 'completed' },
}],
},
],
};
}
async function main() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-usage-statistics-'));
const sessionsDir = path.join(tempRoot, 'sessions');
const cacheFile = path.join(tempRoot, 'cache', 'index-v1.json');
fs.mkdirSync(sessionsDir, { recursive: true });
try {
const fixture = sessionFixture();
const sourceFile = writeSession(sessionsDir, fixture);
writeSession(sessionsDir, {
id: 'usage-session-boundary',
title: '范围右边界',
created: '2026-08-03T00:00:00.000Z',
updated: '2026-08-03T00:00:00.000Z',
messages: [{ role: 'user', content: '右边界消息', timestamp: '2026-08-03T00:00:00.000Z' }],
});
fs.writeFileSync(path.join(sessionsDir, 'broken-session.json'), '{broken');
const beforeContent = fs.readFileSync(sourceFile, 'utf8');
const beforeMtime = fs.statSync(sourceFile).mtimeMs;
const index = createUsageStatisticsIndex({ sessionsDir, cacheFile });
const initStatus = await index.initialize();
assert(initStatus.ready, '统计索引首次构建失败');
assert.strictEqual(initStatus.indexedSessions, 2, '损坏会话不应进入统计索引');
assert(initStatus.failedFiles >= 1, '损坏会话应记录失败数量');
const result = index.query({
from: '2026-07-27T00:00:00.000Z',
to: '2026-08-03T00:00:00.000Z',
timeZone: 'UTC',
});
assert.strictEqual(result.schemaVersion, SCHEMA_VERSION);
assert.strictEqual(result.range.semantics, '[from,to)');
assert.strictEqual(result.overview.newSessions, 1, '右边界会话不应计入');
assert.strictEqual(result.overview.messages, 2);
assert.strictEqual(result.overview.directMessages, 1);
assert.strictEqual(result.overview.crossConversationMessages, 1);
assert.strictEqual(result.overview.mcpCalls, 2);
assert.strictEqual(result.overview.mcpFailures, 1, 'done=true 的失败调用仍应计入失败');
assert.strictEqual(result.overview.skillMentions, 1);
assert.strictEqual(result.overview.projects, 1);
assert.strictEqual(result.overview.projectSessions, 1);
assert.strictEqual(result.overview.unassignedProjectSessions, 0);
assert.strictEqual(result.mcpStatus.completed, 1);
assert.strictEqual(result.mcpStatus.failed, 1);
assert.strictEqual(result.mcpTools[0].server, 'ccweb');
assert.strictEqual(result.mcpTools[0].calls, 1, '同调用数时应按工具名稳定排序');
assert.strictEqual(result.mcpTools[0].detailCallsReturned, 1);
assert.strictEqual(result.skills[0].name, 'openai-docs');
assert.strictEqual(result.projects[0].name, 'usage-project');
assert.strictEqual(result.projects[0].sessions, 1);
assert.strictEqual(result.projects[0].messages, 2);
assert.strictEqual(result.projects[0].mcpCalls, 2);
assert.strictEqual(result.recentSessions[0].projectName, 'usage-project');
assert.strictEqual(result.mcpRecentCalls[0].timestamp, '2026-07-28T02:00:00.000Z');
assert(result.trend.find((row) => row.date === '2026-07-28')?.mcpCalls === 2);
assert(result.trend.find((row) => row.date === '2026-07-28')?.projects === 1);
const serialized = JSON.stringify(result);
assert(!serialized.includes('secret-body'));
assert(!serialized.includes('secret-result'));
assert(!serialized.includes('arguments'));
assert.strictEqual(fs.readFileSync(sourceFile, 'utf8'), beforeContent, '统计查询不得修改会话文件');
assert.strictEqual(fs.statSync(sourceFile).mtimeMs, beforeMtime, '统计查询不得触碰会话文件 mtime');
assert(fs.existsSync(cacheFile), '统计派生缓存应被持久化');
const limited = index.query({
from: '2026-07-27T00:00:00.000Z',
to: '2026-08-03T00:00:00.000Z',
timeZone: 'UTC',
mcpToolLimit: 1,
skillLimit: 1,
projectLimit: 1,
mcpDetailLimit: 1,
});
assert.strictEqual(limited.coverage.distinctMcpTools, 2);
assert.strictEqual(limited.coverage.returnedMcpTools, 1);
assert.strictEqual(limited.mcpTools.length, 1);
assert.strictEqual(limited.mcpRecentCalls.length, 1);
assert.strictEqual(limited.mcpTools[0].detailCallsReturned, 1);
assert.strictEqual(limited.coverage.distinctSkills, 1);
assert.strictEqual(limited.coverage.returnedSkills, 1);
assert.strictEqual(limited.coverage.distinctProjects, 1);
assert.strictEqual(limited.coverage.returnedProjects, 1);
assert.strictEqual(limited.projects.length, 1);
fixture.messages.push({ role: 'user', content: '增量消息', timestamp: '2026-07-30T04:00:00.000Z' });
writeSession(sessionsDir, fixture);
assert(index.scheduleUpsert(fixture.id), '索引初始化后应接受增量更新');
await index.flush();
assert.strictEqual(index.query({
from: '2026-07-27T00:00:00.000Z',
to: '2026-08-03T00:00:00.000Z',
timeZone: 'UTC',
}).overview.messages, 3, '增量更新未生效');
fs.writeFileSync(sourceFile, '');
assert(index.scheduleUpsert(fixture.id), '空文件更新应进入安全增量路径');
await index.flush();
assert.strictEqual(index.query({
from: '2026-07-27T00:00:00.000Z',
to: '2026-08-03T00:00:00.000Z',
timeZone: 'UTC',
}).overview.messages, 0, '异常空文件不应继续保留旧统计文档');
writeSession(sessionsDir, fixture);
assert(index.scheduleUpsert(fixture.id), '恢复后的会话文件应可重新进入索引');
await index.flush();
fs.unlinkSync(sourceFile);
assert(index.remove(fixture.id), '删除会话应移除统计文档');
await index.flush();
assert.strictEqual(index.query({
from: '2026-07-27T00:00:00.000Z',
to: '2026-08-03T00:00:00.000Z',
timeZone: 'UTC',
}).overview.messages, 0, '删除后的会话仍出现在统计中');
const reused = createUsageStatisticsIndex({ sessionsDir, cacheFile });
const reusedStatus = await reused.initialize();
assert(reusedStatus.ready, '派生缓存无法重新加载');
assert.strictEqual(reusedStatus.indexedSessions, 1);
assert.throws(() => reused.query({
from: '2026-08-03T00:00:00.000Z',
to: '2026-07-27T00:00:00.000Z',
timeZone: 'UTC',
}), (error) => error instanceof UsageStatisticsError && error.code === 'invalid_range');
assert.throws(() => reused.query({
from: '2026-07-27T00:00:00.000Z',
to: '2026-08-03T00:00:00.000Z',
timeZone: 'Invalid/Zone',
}), (error) => error instanceof UsageStatisticsError && error.code === 'invalid_time_zone');
console.log('Usage statistics unit checks passed.');
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});