import { describe, expect, it, spyOn } from "bun:test"; import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { claudeDirToPath, estimateSessionMinutes, getClaudeProjectsDir, getClaudeSessionsStreaming, parseClaudeConversation, pathToClaudeDir, resolveClaudeDirPath, } from "./claude"; describe("claude ingestion", () => { it("parses Claude JSONL conversations and skips non-message records", async () => { const dir = await mkdtemp(join(tmpdir(), "scribe-claude-test-")); const conversationFile = join(dir, "conversation.jsonl"); try { await writeFile( conversationFile, [ JSON.stringify({ type: "snapshot", timestamp: "2026-02-09T09:59:00.000Z", }), JSON.stringify({ type: "user", timestamp: "2026-02-09T10:00:00.000Z", uuid: "u-1", sessionId: "thread-1", message: { content: "Help me build parser fixture tests.", }, }), JSON.stringify({ type: "assistant", timestamp: "2026-02-09T10:01:00.000Z", uuid: "a-1", sessionId: "thread-1", message: { content: [ { type: "text", text: "Sure. I will add deterministic tests." }, ], }, }), JSON.stringify({ type: "summary", timestamp: "2026-02-09T10:02:00.000Z", }), "not-json", ].join("\n"), "utf8", ); const events = await parseClaudeConversation( conversationFile, "/Users/satchmo/code/scribe", ); expect(events).toHaveLength(2); expect(events[0]?.messageType).toBe("user"); expect(events[0]?.messagePreview).toContain("fixture tests"); expect(events[0]?.conversationFile).toBe(basename(conversationFile)); expect(events[1]?.messageType).toBe("assistant"); expect(events[1]?.fullContent).toContain("deterministic tests"); } finally { await rm(dir, { recursive: true, force: true }); } }); it("counts and reports skipped malformed lines instead of dropping them silently", async () => { const dir = await mkdtemp(join(tmpdir(), "scribe-claude-skip-test-")); const conversationFile = join(dir, "conversation.jsonl"); const warn = spyOn(console, "warn").mockImplementation(() => {}); try { await writeFile( conversationFile, [ "{ broken json", JSON.stringify({ type: "user", timestamp: "2026-02-09T10:00:00.000Z", uuid: "u-ok", sessionId: "thread-skip", message: { content: "survives the bad lines", }, }), "also not json", ].join("\n"), "utf8", ); const events = await parseClaudeConversation( conversationFile, "/Users/satchmo/code/scribe", ); // the one valid message survives despite two malformed lines expect(events).toHaveLength(1); expect(events[0]?.fullContent).toContain("survives the bad lines"); // and the skips are now SURFACED, not silently lost const warned = warn.mock.calls.map((c) => String(c[0])).join("\n"); expect(warned).toContain("[claude] skipped 2 malformed line(s)"); } finally { warn.mockRestore(); await rm(dir, { recursive: true, force: true }); } }); it("concatenates all text blocks and skips tool_result-only user turns", async () => { const dir = await mkdtemp(join(tmpdir(), "scribe-claude-toolresult-")); const conversationFile = join(dir, "conversation.jsonl"); try { await writeFile( conversationFile, [ // User turn carrying ONLY a tool_result block (no text) — must NOT // produce a blank timeline event. JSON.stringify({ type: "user", timestamp: "2026-02-09T10:00:00.000Z", uuid: "u-toolresult", sessionId: "thread-tr", message: { content: [ { type: "tool_result", tool_use_id: "toolu_1", content: "command output", }, ], }, }), // Assistant turn with multiple text blocks interleaved with tool_use — // all text blocks must be concatenated, not just the first. JSON.stringify({ type: "assistant", timestamp: "2026-02-09T10:01:00.000Z", uuid: "a-multi", sessionId: "thread-tr", message: { content: [ { type: "text", text: "First part." }, { type: "tool_use", id: "toolu_1", name: "Bash", input: {} }, { type: "text", text: "Second part." }, ], }, }), // Real user text turn — must survive. JSON.stringify({ type: "user", timestamp: "2026-02-09T10:02:00.000Z", uuid: "u-real", sessionId: "thread-tr", message: { content: "please continue" }, }), ].join("\n"), "utf8", ); const events = await parseClaudeConversation( conversationFile, "/Users/satchmo/code/scribe", ); // tool_result-only turn dropped → 2 events (assistant + real user). expect(events).toHaveLength(2); expect(events.every((e) => e.fullContent.trim().length > 0)).toBe(true); const assistant = events.find((e) => e.messageType === "assistant"); expect(assistant?.fullContent).toBe("First part.\nSecond part."); const user = events.find((e) => e.id === "u-real"); expect(user?.fullContent).toBe("please continue"); } finally { await rm(dir, { recursive: true, force: true }); } }); it("streams Claude sessions and filters events by since timestamp", async () => { const claudeProjectsDir = await mkdtemp( join(tmpdir(), "scribe-claude-projects-"), ); const previousClaudeProjectsDir = process.env.SCRIBE_CLAUDE_PROJECTS_DIR; process.env.SCRIBE_CLAUDE_PROJECTS_DIR = claudeProjectsDir; const projectPath = `/tmp/scribeclaudeproject${Date.now()}`; const projectDir = join( getClaudeProjectsDir(), pathToClaudeDir(projectPath), ); const conversationFile = join(projectDir, "thread-2.jsonl"); try { await mkdir(projectDir, { recursive: true }); await writeFile( conversationFile, [ JSON.stringify({ type: "user", timestamp: "2026-02-09T12:00:00.000Z", uuid: "old", sessionId: "thread-2", message: { content: "before window", }, }), JSON.stringify({ type: "assistant", timestamp: "2026-02-09T12:05:00.000Z", uuid: "new", sessionId: "thread-2", message: { content: [{ type: "text", text: "inside window" }], }, }), ].join("\n"), "utf8", ); const now = new Date(); await utimes(conversationFile, now, now); const since = new Date("2026-02-09T12:01:00.000Z"); const sessions = []; for await (const session of getClaudeSessionsStreaming( [projectPath], since, )) { sessions.push(session); } expect(sessions).toHaveLength(1); expect(sessions[0]?.events).toHaveLength(1); expect(sessions[0]?.events[0]?.fullContent).toContain("inside window"); } finally { if (previousClaudeProjectsDir === undefined) { delete process.env.SCRIBE_CLAUDE_PROJECTS_DIR; } else { process.env.SCRIBE_CLAUDE_PROJECTS_DIR = previousClaudeProjectsDir; } await rm(claudeProjectsDir, { recursive: true, force: true }); } }); it("uses record cwd for project matching when Claude directory names are lossy", async () => { const claudeProjectsDir = await mkdtemp( join(tmpdir(), "scribe-claude-projects-"), ); const previousClaudeProjectsDir = process.env.SCRIBE_CLAUDE_PROJECTS_DIR; process.env.SCRIBE_CLAUDE_PROJECTS_DIR = claudeProjectsDir; const projectPath = `/tmp/scribe-claude-project-${Date.now()}`; const lossyProjectDir = join( getClaudeProjectsDir(), pathToClaudeDir(projectPath), ); const conversationFile = join(lossyProjectDir, "thread-cwd.jsonl"); try { await mkdir(lossyProjectDir, { recursive: true }); await writeFile( conversationFile, [ JSON.stringify({ type: "user", timestamp: "2026-02-09T12:00:00.000Z", uuid: "cwd-message", sessionId: "thread-cwd", cwd: projectPath, message: { content: "message with accurate cwd", }, }), ].join("\n"), "utf8", ); const now = new Date(); await utimes(conversationFile, now, now); const sessions = []; for await (const session of getClaudeSessionsStreaming([projectPath])) { sessions.push(session); } expect(claudeDirToPath(pathToClaudeDir(projectPath))).not.toBe( projectPath, ); expect(sessions).toHaveLength(1); expect(sessions[0]?.projectPath).toBe(projectPath); expect(sessions[0]?.events[0]?.projectPath).toBe(projectPath); } finally { if (previousClaudeProjectsDir === undefined) { delete process.env.SCRIBE_CLAUDE_PROJECTS_DIR; } else { process.env.SCRIBE_CLAUDE_PROJECTS_DIR = previousClaudeProjectsDir; } await rm(claudeProjectsDir, { recursive: true, force: true }); } }); it("resolves hyphenated project paths against the filesystem when cwd is absent", async () => { // A real project directory whose basename contains a hyphen — the case the // naive `-` -> `/` decoder gets wrong (OPL-2241). const realBase = await mkdtemp(join(tmpdir(), "scribe-real-")); const projectPath = join(realBase, "yours-wallet"); await mkdir(projectPath, { recursive: true }); const claudeProjectsDir = await mkdtemp( join(tmpdir(), "scribe-claude-projects-"), ); const previousClaudeProjectsDir = process.env.SCRIBE_CLAUDE_PROJECTS_DIR; process.env.SCRIBE_CLAUDE_PROJECTS_DIR = claudeProjectsDir; const lossyProjectDir = join( getClaudeProjectsDir(), pathToClaudeDir(projectPath), ); const conversationFile = join(lossyProjectDir, "thread-nocwd.jsonl"); try { await mkdir(lossyProjectDir, { recursive: true }); // Note: no `cwd` field, so the directory name is the only signal. await writeFile( conversationFile, JSON.stringify({ type: "user", timestamp: "2026-02-09T12:00:00.000Z", uuid: "nocwd-message", sessionId: "thread-nocwd", message: { content: "message without cwd" }, }), "utf8", ); const now = new Date(); await utimes(conversationFile, now, now); // The naive decoder mangles the hyphen; the resolver must recover it. expect(claudeDirToPath(pathToClaudeDir(projectPath))).not.toBe( projectPath, ); expect(await resolveClaudeDirPath(pathToClaudeDir(projectPath))).toBe( projectPath, ); const sessions = []; for await (const session of getClaudeSessionsStreaming([realBase])) { sessions.push(session); } expect(sessions).toHaveLength(1); expect(sessions[0]?.projectPath).toBe(projectPath); expect(sessions[0]?.events[0]?.projectPath).toBe(projectPath); } finally { if (previousClaudeProjectsDir === undefined) { delete process.env.SCRIBE_CLAUDE_PROJECTS_DIR; } else { process.env.SCRIBE_CLAUDE_PROJECTS_DIR = previousClaudeProjectsDir; } await rm(claudeProjectsDir, { recursive: true, force: true }); await rm(realBase, { recursive: true, force: true }); } }); it("falls back to the naive decode when the project no longer exists on disk", async () => { // Encoded path that cannot be reconstructed from the filesystem (the real // dir was deleted). Resolver must not throw and must return the naive decode. const missing = "/tmp/scribe-deleted-project-does-not-exist-xyz"; expect(await resolveClaudeDirPath(pathToClaudeDir(missing))).toBe( claudeDirToPath(pathToClaudeDir(missing)), ); }); it("estimates session minutes across grouped sessions", () => { const minutes = estimateSessionMinutes([ { id: "e-1", timestamp: new Date("2026-02-09T09:00:00.000Z"), projectPath: "/Users/satchmo/code/scribe", sessionId: "thread-a", messageType: "user", messagePreview: "hello", fullContent: "hello", conversationFile: "a.jsonl", }, { id: "e-2", timestamp: new Date("2026-02-09T09:20:00.000Z"), projectPath: "/Users/satchmo/code/scribe", sessionId: "thread-a", messageType: "assistant", messagePreview: "hi", fullContent: "hi", conversationFile: "a.jsonl", }, { id: "e-3", timestamp: new Date("2026-02-09T10:00:00.000Z"), projectPath: "/Users/satchmo/code/scribe", sessionId: "thread-b", messageType: "user", messagePreview: "single", fullContent: "single", conversationFile: "b.jsonl", }, ]); // thread-a contributes 20 minutes, thread-b contributes minimum 5. expect(minutes).toBe(25); }); });