diff --git a/package.json b/package.json index a3d4e2d8..bcdaca5a 100644 --- a/package.json +++ b/package.json @@ -106,9 +106,6 @@ "lint-staged": { "*": [ "eslint --cache --fix" - ], - "package.json": [ - "sort-package-json" ] }, "trustedDependencies": [ diff --git a/src/_fixtures.ts b/src/_fixtures.ts new file mode 100644 index 00000000..d9bf6f86 --- /dev/null +++ b/src/_fixtures.ts @@ -0,0 +1,245 @@ +/** + * @fileoverview Test fixture factory functions for data-loader tests + * + * This module provides factory functions for creating test fixtures + * used in data-loader.ts tests. Each function returns a function that + * when called, creates the fixture data structure using fs-fixture. + * + * @module _fixtures + */ + +import type { FsFixture } from 'fs-fixture'; +import { createFixture } from 'fs-fixture'; +import { + createISOTimestamp, + createMessageId, + createModelName, + createRequestId, + createVersion, +} from './_types.ts'; + +type UsageData = { + timestamp: string; + message: { + usage: { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + }; + model?: string; + id?: string; + }; + version?: string; + costUSD?: number; + request?: { + id: string; + }; +}; + +/** + * Creates an empty projects fixture + */ +export async function createEmptyProjectsFixture(): Promise { + return createFixture({ + projects: {}, + }); +} + +/** + * Creates a fixture with basic usage data for testing daily aggregation + */ +export async function createDailyUsageFixture(data: { + mockData1?: UsageData[]; + mockData2?: UsageData; + project?: string; + sessions?: Record; +}): Promise { + const project = data.project ?? 'project1'; + const sessions = data.sessions ?? {}; + + if (data.mockData1 != null && data.mockData2 != null) { + sessions['session1.jsonl'] = data.mockData1 + .map(d => JSON.stringify(d)) + .join('\n'); + sessions['session2.jsonl'] = JSON.stringify(data.mockData2); + } + + const projects: Record> = {}; + projects[project] = Object.fromEntries( + Object.entries(sessions).map(([filename, sessionData]) => { + if (typeof sessionData === 'string') { + return [filename, sessionData]; + } + if (Array.isArray(sessionData)) { + return [filename, sessionData.map(d => JSON.stringify(d)).join('\n')]; + } + return [filename, JSON.stringify(sessionData)]; + }), + ); + + return createFixture({ projects }); +} + +/** + * Creates a fixture with session data + */ +export async function createSessionFixture(sessions: Array<{ + project?: string; + sessionId: string; + data: UsageData | UsageData[]; +}>): Promise { + const projects: Record> = {}; + + for (const session of sessions) { + const project = session.project ?? 'project1'; + if (projects[project] == null) { + projects[project] = {}; + } + + const filename = `${session.sessionId}.jsonl`; + if (Array.isArray(session.data)) { + projects[project][filename] = session.data + .map(d => JSON.stringify(d)) + .join('\n'); + } + else { + projects[project][filename] = JSON.stringify(session.data); + } + } + + return createFixture({ projects }); +} + +/** + * Creates a fixture with multiple projects + */ +export async function createMultiProjectFixture(projectData: Record>): Promise { + const projects: Record> = {}; + + for (const [projectPath, sessions] of Object.entries(projectData)) { + projects[projectPath] = Object.fromEntries( + Object.entries(sessions).map(([filename, data]) => { + if (typeof data === 'string') { + return [filename, data]; + } + return [filename, JSON.stringify(data)]; + }), + ); + } + + return createFixture({ projects }); +} + +/** + * Creates a fixture with raw JSONL content (including invalid lines) + */ +export async function createRawJSONLFixture(project: string, sessionFile: string, content: string): Promise { + return createFixture({ + projects: { + [project]: { + [sessionFile]: content.trim(), + }, + }, + }); +} + +/** + * Creates a fixture for testing file timestamp operations + */ +export async function createTimestampTestFixture(files: Record>): Promise { + return createFixture(files); +} + +/** + * Common test data patterns + */ +export const testData = { + basicUsageData: (timestamp: string, inputTokens: number, outputTokens: number, costUSD?: number): UsageData => ({ + timestamp: createISOTimestamp(timestamp), + message: { usage: { input_tokens: inputTokens, output_tokens: outputTokens } }, + ...(costUSD !== undefined && { costUSD }), + }), + + usageDataWithCache: ( + timestamp: string, + inputTokens: number, + outputTokens: number, + cacheCreation: number, + cacheRead: number, + costUSD?: number, + ): UsageData => ({ + timestamp: createISOTimestamp(timestamp), + message: { + usage: { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreation, + cache_read_input_tokens: cacheRead, + }, + }, + ...(costUSD !== undefined && { costUSD }), + }), + + usageDataWithModel: ( + timestamp: string, + inputTokens: number, + outputTokens: number, + model: string, + costUSD?: number, + ): UsageData => ({ + timestamp: createISOTimestamp(timestamp), + message: { + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + model: createModelName(model), + }, + ...(costUSD !== undefined && { costUSD }), + }), + + usageDataWithIds: ( + timestamp: string, + inputTokens: number, + outputTokens: number, + messageId: string, + requestId?: string, + costUSD?: number, + ): UsageData => ({ + timestamp: createISOTimestamp(timestamp), + message: { + id: createMessageId(messageId), + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + }, + ...(requestId != null && { request: { id: createRequestId(requestId) } }), + ...(costUSD !== undefined && { costUSD }), + }), + + usageDataWithVersion: ( + timestamp: string, + inputTokens: number, + outputTokens: number, + version: string, + costUSD?: number, + ): UsageData => ({ + timestamp: createISOTimestamp(timestamp), + message: { usage: { input_tokens: inputTokens, output_tokens: outputTokens } }, + version: createVersion(version), + ...(costUSD !== undefined && { costUSD }), + }), + + sessionBlockData: ( + timestamp: string, + messageId: string, + inputTokens: number, + outputTokens: number, + model: string, + costUSD?: number, + ) => ({ + timestamp, + message: { + id: messageId, + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + model: createModelName(model), + }, + ...(costUSD !== undefined && { costUSD }), + }), +}; diff --git a/src/data-loader.ts b/src/data-loader.ts index 8cec9116..31d69c07 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -28,14 +28,24 @@ import { unreachable } from '@core/errorutil'; import { Result } from '@praha/byethrow'; import { groupBy, uniq } from 'es-toolkit'; // TODO: after node20 is deprecated, switch to native Object.groupBy import { sort } from 'fast-sort'; -import { createFixture } from 'fs-fixture'; import { isDirectorySync } from 'path-type'; import { glob } from 'tinyglobby'; import { z } from 'zod'; -import { CLAUDE_CONFIG_DIR_ENV, CLAUDE_PROJECTS_DIR_NAME, DEFAULT_CLAUDE_CODE_PATH, DEFAULT_CLAUDE_CONFIG_PATH, USAGE_DATA_GLOB_PATTERN, USER_HOME_DIR } from './_consts.ts'; import { - identifySessionBlocks, -} from './_session-blocks.ts'; + CLAUDE_CONFIG_DIR_ENV, + CLAUDE_PROJECTS_DIR_NAME, + DEFAULT_CLAUDE_CODE_PATH, + DEFAULT_CLAUDE_CONFIG_PATH, + USAGE_DATA_GLOB_PATTERN, + USER_HOME_DIR, +} from './_consts.ts'; +import { + createDailyUsageFixture, + createEmptyProjectsFixture, + createMultiProjectFixture, + createTimestampTestFixture, +} from './_fixtures.ts'; +import { identifySessionBlocks } from './_session-blocks.ts'; import { activityDateSchema, createBucket, @@ -61,9 +71,7 @@ import { weeklyDateSchema, } from './_types.ts'; import { logger } from './logger.ts'; -import { - PricingFetcher, -} from './pricing-fetcher.ts'; +import { PricingFetcher } from './pricing-fetcher.ts'; /** * Get Claude data directories to search for usage data @@ -1024,10 +1032,18 @@ export async function loadSessionData( const relativePath = path.relative(baseDir, file); const parts = relativePath.split(path.sep); - // Session ID is the directory name containing the JSONL file - const sessionId = parts[parts.length - 2] ?? 'unknown'; - // Project path is everything before the session ID - const joinedPath = parts.slice(0, -2).join(path.sep); + // Session ID is the name of the jsonl file name containing the JSONL file + let sessionId: string; + if (parts.length > 0 && (parts[parts.length - 1] ?? '').replace(/\.jsonl/g, '') !== '') { + sessionId = (parts[parts.length - 1] ?? '').replace(/\.jsonl/g, ''); + } + else { + sessionId = 'unknown'; + } + // Project path is everything before the session ID. Since it is relative to the + // projects folder (.../dashed-path-project-name/uuid-for-session.jsonl) + // so 0,-2 would be empty since there's only 2 elements. + const joinedPath = parts.slice(0, -1).join(path.sep); const projectPath = joinedPath.length > 0 ? joinedPath : 'Unknown Project'; const content = await readFile(file, 'utf-8'); @@ -1648,9 +1664,7 @@ if (import.meta.vitest != null) { describe('loadDailyUsageData', () => { it('returns empty array when no files found', async () => { - await using fixture = await createFixture({ - projects: {}, - }); + await using fixture = await createEmptyProjectsFixture(); const result = await loadDailyUsageData({ claudePath: fixture.path }); expect(result).toEqual([]); @@ -1677,17 +1691,9 @@ if (import.meta.vitest != null) { costUSD: 0.03, }; - await using fixture = await createFixture({ - projects: { - project1: { - session1: { - 'file1.jsonl': mockData1.map(d => JSON.stringify(d)).join('\n'), - }, - session2: { - 'file2.jsonl': JSON.stringify(mockData2), - }, - }, - }, + await using fixture = await createDailyUsageFixture({ + mockData1, + mockData2, }); const result = await loadDailyUsageData({ claudePath: fixture.path }); @@ -1713,14 +1719,8 @@ if (import.meta.vitest != null) { costUSD: 0.01, }; - await using fixture = await createFixture({ - projects: { - project1: { - session1: { - 'file.jsonl': JSON.stringify(mockData), - }, - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadDailyUsageData({ claudePath: fixture.path }); @@ -1748,14 +1748,8 @@ if (import.meta.vitest != null) { }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - session1: { - 'file.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadDailyUsageData({ @@ -1788,14 +1782,8 @@ if (import.meta.vitest != null) { }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - session1: { - 'file.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadDailyUsageData({ claudePath: fixture.path }); @@ -1824,14 +1812,8 @@ if (import.meta.vitest != null) { }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - session1: { - 'usage.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadDailyUsageData({ @@ -1864,14 +1846,8 @@ if (import.meta.vitest != null) { }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - session1: { - 'usage.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadDailyUsageData({ @@ -1886,6 +1862,10 @@ if (import.meta.vitest != null) { }); it('handles invalid JSON lines gracefully', async () => { + //