diff --git a/src/__tests__/token-usage.test.ts b/src/__tests__/token-usage.test.ts index 1942574..da07e26 100644 --- a/src/__tests__/token-usage.test.ts +++ b/src/__tests__/token-usage.test.ts @@ -1,7 +1,15 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { parseLogContent, normalizeModelName, aggregate } from "../token-usage"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { parseLogContent, normalizeModelName, aggregate, parseClaudeCodeJsonl, getTokenUsage } from "../token-usage"; import { clearCache } from "../cache"; +vi.mock("os", async (importOriginal) => { + const actual = await importOriginal() as typeof import("os"); + return { ...actual, homedir: vi.fn(actual.homedir) }; +}); + const RESPONSE_BLOCK = `2026-01-22T18:34:17.358Z [DEBUG] response (Request-ID 00000-abc): 2026-01-22T18:34:17.358Z [DEBUG] data: 2026-01-22T18:34:17.358Z [DEBUG] { @@ -122,4 +130,203 @@ describe("aggregate", () => { expect(agg.totals.top_model).toBeNull(); expect(agg.daily).toEqual([]); }); + + it("builds weekly and monthly buckets", () => { + const calls = parseLogContent(RESPONSE_BLOCK + RESPONSE_NO_MODEL); + const agg = aggregate(calls, 2, "/tmp"); + expect(agg.weekly.length).toBeGreaterThanOrEqual(1); + expect(agg.monthly.length).toBeGreaterThanOrEqual(1); + expect(agg.weekly[0].period).toMatch(/^\d{4}-W\d{2}$/); + expect(agg.monthly[0].period).toMatch(/^\d{4}-\d{2}$/); + }); + + it("computes per-model token breakdown inside daily buckets", () => { + const calls = parseLogContent(RESPONSE_BLOCK); + const agg = aggregate(calls, 1, "/tmp"); + const day = agg.daily[0]; + expect(day.models["claude-opus-4.7"]).toBeDefined(); + expect(day.models["claude-opus-4.7"].prompt_tokens).toBe(1000); + }); +}); + +// ── parseClaudeCodeJsonl ────────────────────────────────────────────────────── + +const CC_ASSISTANT_LINE = JSON.stringify({ + type: "assistant", + uuid: "req-001", + timestamp: "2026-01-22T18:00:00.000Z", + message: { + id: "msg_001", + model: "claude-sonnet-4-6", + usage: { + input_tokens: 500, + output_tokens: 100, + cache_creation_input_tokens: 50, + cache_read_input_tokens: 200, + }, + }, +}); + +const CC_SIDECHAIN_LINE = JSON.stringify({ + type: "assistant", + isSidechain: true, + uuid: "req-002", + timestamp: "2026-01-22T18:01:00.000Z", + message: { + model: "claude-sonnet-4-6", + usage: { input_tokens: 100, output_tokens: 20 }, + }, +}); + +const CC_SYNTHETIC_LINE = JSON.stringify({ + type: "assistant", + uuid: "req-003", + timestamp: "2026-01-22T18:02:00.000Z", + message: { + model: "", + usage: { input_tokens: 10, output_tokens: 5 }, + }, +}); + +describe("parseClaudeCodeJsonl", () => { + it("parses a valid assistant event", () => { + const calls = parseClaudeCodeJsonl(CC_ASSISTANT_LINE); + expect(calls).toHaveLength(1); + const c = calls[0]; + expect(c.source).toBe("claude-code"); + expect(c.model).toBe("claude-sonnet-4-6"); + expect(c.completion_tokens).toBe(100); + // prompt = input + cacheCreate + cacheRead = 500 + 50 + 200 = 750 + expect(c.prompt_tokens).toBe(750); + expect(c.cached_tokens).toBe(200); + expect(c.total_tokens).toBe(850); + expect(c.request_id).toBe("req-001"); + expect(c.message_id).toBe("msg_001"); + }); + + it("skips sidechain events", () => { + expect(parseClaudeCodeJsonl(CC_SIDECHAIN_LINE)).toHaveLength(0); + }); + + it("skips events with model", () => { + expect(parseClaudeCodeJsonl(CC_SYNTHETIC_LINE)).toHaveLength(0); + }); + + it("skips non-assistant events", () => { + const line = JSON.stringify({ type: "user", message: { text: "hi" } }); + expect(parseClaudeCodeJsonl(line)).toHaveLength(0); + }); + + it("skips events with no usage", () => { + const line = JSON.stringify({ type: "assistant", message: { model: "claude-sonnet-4-6" } }); + expect(parseClaudeCodeJsonl(line)).toHaveLength(0); + }); + + it("skips events where total tokens is zero", () => { + const line = JSON.stringify({ + type: "assistant", + message: { model: "claude-sonnet-4-6", usage: { input_tokens: 0, output_tokens: 0 } }, + }); + expect(parseClaudeCodeJsonl(line)).toHaveLength(0); + }); + + it("skips malformed JSON lines", () => { + const content = "not json\n" + CC_ASSISTANT_LINE; + expect(parseClaudeCodeJsonl(content)).toHaveLength(1); + }); + + it("parses multiple valid lines", () => { + const second = JSON.stringify({ + type: "assistant", + uuid: "req-004", + timestamp: "2026-01-22T19:00:00.000Z", + message: { + model: "claude-opus-4-8", + usage: { input_tokens: 200, output_tokens: 50 }, + }, + }); + const calls = parseClaudeCodeJsonl([CC_ASSISTANT_LINE, second].join("\n")); + expect(calls).toHaveLength(2); + expect(calls[1].model).toBe("claude-opus-4-8"); + }); + + it("handles missing timestamp gracefully", () => { + const line = JSON.stringify({ + type: "assistant", + uuid: "req-005", + message: { + model: "claude-sonnet-4-6", + usage: { input_tokens: 100, output_tokens: 20 }, + }, + }); + const calls = parseClaudeCodeJsonl(line); + expect(calls).toHaveLength(1); + expect(calls[0].timestamp).toBe(""); + }); +}); + +// ── getTokenUsage ───────────────────────────────────────────────────────────── + +describe("getTokenUsage", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-lens-tok-")); + vi.mocked(os.homedir).mockReturnValue(tmpDir); + clearCache(); + }); + + afterEach(() => { + vi.mocked(os.homedir).mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + clearCache(); + }); + + it("returns zero totals when no log files exist", () => { + const result = getTokenUsage(); + expect(result.totals.calls).toBe(0); + expect(result.source).toBe("all"); + }); + + it("reads copilot-cli log files and returns parsed calls", () => { + const logsDir = path.join(tmpDir, ".copilot", "logs"); + fs.mkdirSync(logsDir, { recursive: true }); + fs.writeFileSync(path.join(logsDir, "session.log"), RESPONSE_BLOCK); + + const result = getTokenUsage("copilot-cli"); + expect(result.totals.calls).toBe(1); + expect(result.source).toBe("copilot-cli"); + expect(result.totals.prompt_tokens).toBe(1000); + }); + + it("reads claude-code JSONL files and returns parsed calls", () => { + const projectsDir = path.join(tmpDir, ".claude", "projects", "my-project"); + fs.mkdirSync(projectsDir, { recursive: true }); + fs.writeFileSync(path.join(projectsDir, "session.jsonl"), CC_ASSISTANT_LINE); + + const result = getTokenUsage("claude-code"); + expect(result.totals.calls).toBe(1); + expect(result.source).toBe("claude-code"); + expect(result.totals.completion_tokens).toBe(100); + }); + + it("merges both sources when source is 'all'", () => { + const logsDir = path.join(tmpDir, ".copilot", "logs"); + fs.mkdirSync(logsDir, { recursive: true }); + fs.writeFileSync(path.join(logsDir, "session.log"), RESPONSE_BLOCK); + + const projectsDir = path.join(tmpDir, ".claude", "projects", "my-project"); + fs.mkdirSync(projectsDir, { recursive: true }); + fs.writeFileSync(path.join(projectsDir, "session.jsonl"), CC_ASSISTANT_LINE); + + const result = getTokenUsage("all"); + expect(result.totals.calls).toBe(2); + expect(result.sources).toHaveLength(2); + }); + + it("returns cached result on second call", () => { + const result1 = getTokenUsage(); + const result2 = getTokenUsage(); + expect(result1).toBe(result2); + }); }); diff --git a/src/__tests__/vscode-sessions-api.test.ts b/src/__tests__/vscode-sessions-api.test.ts new file mode 100644 index 0000000..3f97dc8 --- /dev/null +++ b/src/__tests__/vscode-sessions-api.test.ts @@ -0,0 +1,301 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { clearCache } from "../cache"; + +vi.mock("os", async (importOriginal) => { + const actual = await importOriginal() as typeof import("os"); + return { ...actual, homedir: vi.fn(actual.homedir) }; +}); + +vi.mock("better-sqlite3", () => ({ default: vi.fn() })); + +import Database from "better-sqlite3"; +import { + listVSCodeSessions, + getVSCodeSession, + getVSCodeAnalytics, + isVSCodeSession, +} from "../vscode-sessions"; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function platformVSCodeDir(home: string): string { + if (process.platform === "darwin") return path.join(home, "Library", "Application Support", "Code"); + if (process.platform === "win32") return path.join(home, "AppData", "Roaming", "Code"); + return path.join(home, ".config", "Code"); +} + +function makeVSCodeStructure(home: string): string { + const codeDir = platformVSCodeDir(home); + const gsDir = path.join(codeDir, "User", "globalStorage"); + fs.mkdirSync(gsDir, { recursive: true }); + fs.writeFileSync(path.join(gsDir, "state.vscdb"), ""); + return codeDir; +} + +function makeSessionFile(codeDir: string, sessionId: string, content: object): void { + const dir = path.join(codeDir, "User", "globalStorage", "emptyWindowChatSessions"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${sessionId}.json`), JSON.stringify(content)); +} + +function mockDatabase(entries: Record): void { + vi.mocked(Database).mockImplementation(function () { + return { + prepare: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ value: JSON.stringify({ entries }) }), + }), + close: vi.fn(), + }; + } as any); +} + +// ── shared setup ────────────────────────────────────────────────────────────── + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-lens-vsc-")); + vi.mocked(os.homedir).mockReturnValue(tmpDir); + // Default: empty DB + mockDatabase({}); + clearCache(); +}); + +afterEach(() => { + vi.mocked(os.homedir).mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + clearCache(); +}); + +// ── listVSCodeSessions ──────────────────────────────────────────────────────── + +describe("listVSCodeSessions", () => { + it("returns empty array when no VS Code data directory exists", () => { + expect(listVSCodeSessions()).toEqual([]); + }); + + it("returns empty array when index has no entries", () => { + makeVSCodeStructure(tmpDir); + expect(listVSCodeSessions()).toEqual([]); + }); + + it("returns a session from the index", () => { + makeVSCodeStructure(tmpDir); + mockDatabase({ + "sess-1": { + sessionId: "sess-1", + title: "My Session", + lastMessageDate: 1700000000000, + timing: { startTime: 1699999000000, endTime: 1700000000000 }, + isEmpty: false, + }, + }); + + const sessions = listVSCodeSessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0].id).toBe("sess-1"); + expect(sessions[0].source).toBe("vscode"); + expect(sessions[0].title).toBe("My Session"); + expect(sessions[0].status).toBe("completed"); + }); + + it("skips sessions marked as empty", () => { + makeVSCodeStructure(tmpDir); + mockDatabase({ + "empty-sess": { sessionId: "empty-sess", lastMessageDate: 1700000000000, isEmpty: true }, + }); + expect(listVSCodeSessions()).toHaveLength(0); + }); + + it("skips sessions with no lastMessageDate", () => { + makeVSCodeStructure(tmpDir); + mockDatabase({ + "no-date": { sessionId: "no-date", title: "No Date", lastMessageDate: 0, isEmpty: false }, + }); + expect(listVSCodeSessions()).toHaveLength(0); + }); + + it("returns running status for a recently active session", () => { + makeVSCodeStructure(tmpDir); + const now = Date.now(); + mockDatabase({ + "running-sess": { + sessionId: "running-sess", + title: "Running", + lastMessageDate: now - 5000, + timing: { startTime: now - 60000 }, + isEmpty: false, + }, + }); + + const sessions = listVSCodeSessions(); + expect(sessions[0].status).toBe("running"); + }); +}); + +// ── isVSCodeSession ─────────────────────────────────────────────────────────── + +describe("isVSCodeSession", () => { + it("returns false when no VS Code dir exists", () => { + expect(isVSCodeSession("any-id")).toBe(false); + }); + + it("returns false for an ID not in the index", () => { + makeVSCodeStructure(tmpDir); + expect(isVSCodeSession("unknown-id")).toBe(false); + }); + + it("returns true for an ID that is in the index", () => { + makeVSCodeStructure(tmpDir); + mockDatabase({ + "known-id": { sessionId: "known-id", lastMessageDate: 1700000000000, isEmpty: false }, + }); + expect(isVSCodeSession("known-id")).toBe(true); + }); +}); + +// ── getVSCodeSession ────────────────────────────────────────────────────────── + +describe("getVSCodeSession", () => { + it("returns null when no VS Code dir exists", () => { + expect(getVSCodeSession("any-id")).toBeNull(); + }); + + it("returns null when session file does not exist", () => { + makeVSCodeStructure(tmpDir); + mockDatabase({ + "missing-file": { sessionId: "missing-file", lastMessageDate: 1700000000000, isEmpty: false }, + }); + expect(getVSCodeSession("missing-file")).toBeNull(); + }); + + it("returns session detail for a valid session file", () => { + const codeDir = makeVSCodeStructure(tmpDir); + const sessionId = "detail-sess"; + mockDatabase({ + [sessionId]: { + sessionId, + title: "Detail Session", + lastMessageDate: 1700000000000, + timing: { startTime: 1699999000000, endTime: 1700000000000 }, + isEmpty: false, + }, + }); + + makeSessionFile(codeDir, sessionId, { + sessionId, + requests: [ + { + requestId: "r1", + timestamp: 1700000000000, + message: { text: "Hello" }, + response: [{ value: "World" }], + }, + ], + }); + + const detail = getVSCodeSession(sessionId); + expect(detail).not.toBeNull(); + expect(detail!.id).toBe(sessionId); + expect(detail!.source).toBe("vscode"); + expect(detail!.events.some((e) => e.type === "user.message")).toBe(true); + expect(detail!.events.some((e) => e.type === "assistant.message")).toBe(true); + expect(detail!.hasSnapshots).toBe(false); + }); + + it("counts events by type across multiple requests", () => { + const codeDir = makeVSCodeStructure(tmpDir); + const sessionId = "count-sess"; + mockDatabase({ + [sessionId]: { sessionId, lastMessageDate: 1700000000000, isEmpty: false }, + }); + + makeSessionFile(codeDir, sessionId, { + sessionId, + requests: [ + { requestId: "r1", timestamp: 1700000000000, message: { text: "Q1" }, response: [{ value: "A1" }] }, + { requestId: "r2", timestamp: 1700000010000, message: { text: "Q2" }, response: [] }, + ], + }); + + const detail = getVSCodeSession(sessionId); + expect(detail!.eventCounts["user.message"]).toBe(2); + expect(detail!.eventCounts["assistant.turn_start"]).toBe(2); + }); +}); + +// ── getVSCodeAnalytics ──────────────────────────────────────────────────────── + +describe("getVSCodeAnalytics", () => { + it("returns empty array when no VS Code dir exists", () => { + expect(getVSCodeAnalytics()).toEqual([]); + }); + + it("skips session entries with no session file", () => { + makeVSCodeStructure(tmpDir); + mockDatabase({ + "no-file": { sessionId: "no-file", lastMessageDate: 1700000000000, isEmpty: false }, + }); + expect(getVSCodeAnalytics()).toHaveLength(0); + }); + + it("returns analytics for sessions with session files", () => { + const codeDir = makeVSCodeStructure(tmpDir); + const sessionId = "analytics-sess"; + mockDatabase({ + [sessionId]: { + sessionId, + title: "Analytics", + lastMessageDate: 1700000000000, + timing: { startTime: 1699999000000 }, + isEmpty: false, + }, + }); + + makeSessionFile(codeDir, sessionId, { + sessionId, + requests: [ + { + requestId: "r1", + timestamp: 1700000000000, + modelId: "copilot/claude-sonnet-4-6", + message: { text: "Tell me about X" }, + response: [ + { kind: "toolInvocationSerialized", toolCallId: "tc1", originMessage: "bash (Terminal)" }, + { value: "Here is the answer" }, + ], + }, + ], + }); + + const analytics = getVSCodeAnalytics(); + expect(analytics).toHaveLength(1); + expect(analytics[0].sessionId).toBe(sessionId); + expect(analytics[0].turnCount).toBe(1); + expect(analytics[0].modelUsage["claude-sonnet-4-6"]).toBe(1); + expect(analytics[0].toolUsage["bash (Terminal)"]).toBe(1); + expect(analytics[0].msgLengths[0]).toBe("Tell me about X".length); + }); + + it("calculates duration from request timestamps", () => { + const codeDir = makeVSCodeStructure(tmpDir); + const sessionId = "dur-sess"; + mockDatabase({ + [sessionId]: { sessionId, lastMessageDate: 1700000000000, isEmpty: false }, + }); + + makeSessionFile(codeDir, sessionId, { + sessionId, + requests: [ + { requestId: "r1", timestamp: 1700000000000, message: { text: "Hi" }, response: [] }, + { requestId: "r2", timestamp: 1700000010000, message: { text: "More" }, response: [] }, + ], + }); + + const analytics = getVSCodeAnalytics(); + expect(analytics[0].duration).toBeGreaterThan(0); + }); +});