'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); });