codeburn/tests/parser-claude-cwd.test.ts
iamtoruk 62cb4f8630 test: isolate the real home on Windows and make the CLI suite pass there
os.homedir() reads USERPROFILE on Windows and never HOME, so the
isolation setup and every test that moved home were still resolving the
developer's real home directory. Fixtures came back empty, real sessions
leaked into counts, and the code under test wrote plans, receipts and a
sync ledger into the real ~/.config/codeburn during a run.

USERPROFILE now joins the redirected variables and a shared setHome()
helper moves HOME and USERPROFILE together; every spawn env carries both.
The remaining Windows-only failures were POSIX-shaped fixtures: chmod
tricks that only toggle a read-only attribute on NTFS, pid 1 as an
always-alive lock holder, splitting paths on '/', bare tar, mode-bit
expectations, and per-file timeouts too short for the cost of a tsx spawn.
Each gets a platform-aware equivalent so nothing is skipped on Windows and
the POSIX branches stay byte-for-byte what they were.

Full run on Windows: 267 files passed, 2 skipped, 3592 tests passed.
2026-09-02 13:43:57 -07:00

620 lines
22 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm, utimes } from 'fs/promises'
import { join, relative } from 'path'
import { tmpdir, homedir } from 'os'
import { parseAllSessions } from '../src/parser.js'
import { collectUnsentCalls } from '../src/sync/push.js'
import { buildOtlpPayload } from '../src/sync/otlp.js'
import type { DateRange } from '../src/types.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'claude-cwd-test-'))
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
// Point desktop sessions at an empty subdir by default so real sessions
// on the developer's machine do not bleed into the unit tests.
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'desktop-sessions')
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
function dayRange(day: string): DateRange {
return {
start: new Date(`${day}T00:00:00.000Z`),
end: new Date(`${day}T23:59:59.999Z`),
}
}
async function writeClaudeSession(
projectSlug: string,
sessionId: string,
cwd: string,
timestamp: string,
usage: Record<string, unknown> = { input_tokens: 100, output_tokens: 50 },
model = 'claude-sonnet-4-5',
): Promise<void> {
const projectDir = join(tmpDir, 'projects', projectSlug)
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, `${sessionId}.jsonl`)
await writeFile(filePath, JSON.stringify({
type: 'assistant',
sessionId,
timestamp,
cwd,
message: {
id: `msg-${sessionId}`,
type: 'message',
role: 'assistant',
model,
content: [],
usage,
},
}) + '\n')
const mtime = new Date(timestamp)
await utimes(filePath, mtime, mtime)
}
describe('Claude cwd project paths', () => {
it('uses the JSONL cwd as the canonical project path instead of the lossy directory slug', async () => {
await writeClaudeSession(
'c--AI-LAB-OPENCLAW',
'windows-session',
'C:\\AI_LAB\\OPENCLAW',
'2099-05-01T12:00:00.000Z',
)
const projects = await parseAllSessions(dayRange('2099-05-01'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.projectPath).toBe('C:\\AI_LAB\\OPENCLAW')
expect(projects[0]!.projectPath).not.toBe('c//AI/LAB/OPENCLAW')
expect(projects[0]!.totalApiCalls).toBe(1)
})
it('groups Windows cwd case and slash variants into one project', async () => {
await writeClaudeSession(
'windows-openclaw-a',
'upper-backslash',
'C:\\AI_LAB\\OPENCLAW',
'2099-05-02T10:00:00.000Z',
)
await writeClaudeSession(
'windows-openclaw-b',
'lower-forward-slash',
'c:/AI_LAB/OPENCLAW/',
'2099-05-02T11:00:00.000Z',
)
const projects = await parseAllSessions(dayRange('2099-05-02'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.sessions).toHaveLength(2)
expect(projects[0]!.totalApiCalls).toBe(2)
expect(projects[0]!.sessions.map(s => s.sessionId).sort()).toEqual([
'lower-forward-slash',
'upper-backslash',
])
})
it('prefers the canonical cwd path even when mixed with slug-only sessions in the same directory', async () => {
const slug = 'c--AI-LAB-OPENCLAW'
const projectDir = join(tmpDir, 'projects', slug)
await mkdir(projectDir, { recursive: true })
const noCwdPath = join(projectDir, 'a-no-cwd.jsonl')
await writeFile(noCwdPath, JSON.stringify({
type: 'assistant',
sessionId: 'no-cwd',
timestamp: '2099-05-03T10:00:00.000Z',
message: {
id: 'msg-no-cwd', type: 'message', role: 'assistant',
model: 'claude-sonnet-4-5', content: [],
usage: { input_tokens: 100, output_tokens: 50 },
},
}) + '\n')
await utimes(noCwdPath, new Date('2099-05-03T10:00:00.000Z'), new Date('2099-05-03T10:00:00.000Z'))
await writeClaudeSession(slug, 'b-with-cwd', 'C:\\AI_LAB\\OPENCLAW', '2099-05-03T11:00:00.000Z')
const projects = await parseAllSessions(dayRange('2099-05-03'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.sessions).toHaveLength(2)
expect(projects[0]!.projectPath).toBe('C:\\AI_LAB\\OPENCLAW')
expect(projects[0]!.projectPath).not.toBe('c//AI/LAB/OPENCLAW')
})
it('falls back to the slug-derived path when cwd is null, missing, or empty', async () => {
const slug = 'fallback-slug'
const projectDir = join(tmpDir, 'projects', slug)
await mkdir(projectDir, { recursive: true })
async function writeWith(name: string, sessionId: string, cwdField: unknown, ts: string) {
const filePath = join(projectDir, `${name}.jsonl`)
const obj: Record<string, unknown> = {
type: 'assistant', sessionId, timestamp: ts,
message: {
id: `msg-${sessionId}`, type: 'message', role: 'assistant',
model: 'claude-sonnet-4-5', content: [],
usage: { input_tokens: 100, output_tokens: 50 },
},
}
if (cwdField !== undefined) obj.cwd = cwdField
await writeFile(filePath, JSON.stringify(obj) + '\n')
await utimes(filePath, new Date(ts), new Date(ts))
}
await writeWith('null-cwd', 's-null', null, '2099-05-04T10:00:00.000Z')
await writeWith('empty-cwd', 's-empty', '', '2099-05-04T10:30:00.000Z')
await writeWith('whitespace-cwd', 's-ws', ' ', '2099-05-04T11:00:00.000Z')
await writeWith('missing-cwd', 's-miss', undefined, '2099-05-04T11:30:00.000Z')
const projects = await parseAllSessions(dayRange('2099-05-04'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.sessions).toHaveLength(4)
expect(projects[0]!.projectPath).toBe('fallback-slug')
})
it('keeps a hyphenated slug intact when cwd is absent', async () => {
const slug = 'Projects-Content-OS'
const projectDir = join(tmpDir, 'projects', slug)
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, 'no-cwd.jsonl')
await writeFile(filePath, JSON.stringify({
type: 'assistant',
sessionId: 'no-cwd',
timestamp: '2099-05-09T10:00:00.000Z',
message: {
id: 'msg-no-cwd',
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [],
usage: { input_tokens: 100, output_tokens: 50 },
},
}) + '\n')
await utimes(filePath, new Date('2099-05-09T10:00:00.000Z'), new Date('2099-05-09T10:00:00.000Z'))
const projects = await parseAllSessions(dayRange('2099-05-09'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe(slug)
expect(projects[0]!.projectPath).toBe(slug)
expect(projects[0]!.projectPath).not.toBe('Projects/Content/OS')
})
it('does not promote a Claude Cowork container cwd into trusted session provenance', async () => {
const desktopBase = process.env['CODEBURN_DESKTOP_SESSIONS_DIR']!
const projectDir = join(
desktopBase,
'app-1',
'workspace-1',
'local_session-1',
'.claude',
'projects',
'-sessions-synthetic-cowork-secret',
)
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, 'cowork-session.jsonl')
const timestamp = '2099-05-11T10:00:00.000Z'
await writeFile(filePath, `${JSON.stringify({
type: 'assistant',
sessionId: 'cowork-session',
timestamp,
cwd: '/sessions/synthetic-cowork-secret',
message: {
id: 'msg-cowork-session',
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [],
usage: { input_tokens: 100, output_tokens: 50 },
},
})}\n`)
await utimes(filePath, new Date(timestamp), new Date(timestamp))
const projects = await parseAllSessions(dayRange('2099-05-11'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.sessions[0]!.workingDirectory).toBeUndefined()
const wire = JSON.stringify(buildOtlpPayload(collectUnsentCalls(projects).allCalls))
expect(wire).not.toContain('synthetic-cowork-secret')
expect(wire).not.toContain('ai.project')
})
it('does not group sibling projects under a parent directory that merely contains .git', async () => {
const projectsRoot = join(tmpDir, 'Projects')
const swiftbar = join(projectsRoot, 'Swiftbar')
const contentOs = join(projectsRoot, 'Content-OS')
await mkdir(join(projectsRoot, '.git'), { recursive: true })
await mkdir(swiftbar, { recursive: true })
await mkdir(contentOs, { recursive: true })
await writeClaudeSession(
'tmp-Projects-Swiftbar',
'swiftbar-session',
swiftbar,
'2099-05-10T10:00:00.000Z',
)
await writeClaudeSession(
'tmp-Projects-Content-OS',
'content-os-session',
contentOs,
'2099-05-10T11:00:00.000Z',
)
const projects = await parseAllSessions(dayRange('2099-05-10'), 'claude')
const projectPaths = projects.map(project => project.projectPath).sort()
expect(projects).toHaveLength(2)
expect(projectPaths).toEqual([contentOs, swiftbar].sort())
expect(projectPaths).not.toContain(projectsRoot)
})
it('groups git worktrees under the main repository project', async () => {
const mainRepo = join(tmpDir, 'repos', 'codeburn')
const worktreeA = join(tmpDir, 'worktrees', 'codeburn-feature-a')
const worktreeB = join(tmpDir, 'worktrees', 'codeburn-feature-b')
await mkdir(join(mainRepo, '.git', 'worktrees', 'feature-a'), { recursive: true })
await mkdir(join(mainRepo, '.git', 'worktrees', 'feature-b'), { recursive: true })
await mkdir(worktreeA, { recursive: true })
await mkdir(worktreeB, { recursive: true })
await writeFile(join(worktreeA, '.git'), `gitdir: ${join(mainRepo, '.git', 'worktrees', 'feature-a')}\n`)
await writeFile(join(worktreeB, '.git'), `gitdir: ${relative(worktreeB, join(mainRepo, '.git', 'worktrees', 'feature-b'))}\n`)
await writeClaudeSession(
'tmp-worktrees-codeburn-feature-a',
'worktree-a-session',
worktreeA,
'2099-05-07T10:00:00.000Z',
)
await writeClaudeSession(
'tmp-worktrees-codeburn-feature-b',
'worktree-b-session',
worktreeB,
'2099-05-07T11:00:00.000Z',
)
const projects = await parseAllSessions(dayRange('2099-05-07'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe('codeburn')
expect(projects[0]!.projectPath).toBe(mainRepo)
expect(projects[0]!.sessions).toHaveLength(2)
expect(projects[0]!.totalApiCalls).toBe(2)
expect(projects[0]!.sessions.map(s => s.sessionId).sort()).toEqual([
'worktree-a-session',
'worktree-b-session',
])
})
it('does not group separate-git-dir projects that are not git worktrees', async () => {
const externalGitDir = join(tmpDir, 'external-git-dirs', 'project.git')
const projectDir = join(tmpDir, 'standalone', 'separate-git-dir')
await mkdir(externalGitDir, { recursive: true })
await mkdir(projectDir, { recursive: true })
await writeFile(join(projectDir, '.git'), `gitdir: ${externalGitDir}\n`)
await writeClaudeSession(
'tmp-standalone-separate-git-dir',
'separate-git-dir-session',
projectDir,
'2099-05-08T10:00:00.000Z',
)
const projects = await parseAllSessions(dayRange('2099-05-08'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.projectPath).toBe(projectDir)
expect(projects[0]!.project).toBe('tmp-standalone-separate-git-dir')
})
})
describe('Claude cache creation pricing', () => {
it('prices 1-hour cache writes from usage.cache_creation at the 2x input rate', async () => {
await writeClaudeSession(
'cache-pricing',
'one-hour-cache',
'/tmp/cache-pricing',
'2099-05-05T10:00:00.000Z',
{
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 60_120,
cache_creation: {
ephemeral_5m_input_tokens: 0,
ephemeral_1h_input_tokens: 60_120,
},
},
'claude-opus-4-7',
)
const projects = await parseAllSessions(dayRange('2099-05-05'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.sessions[0]!.totalCacheWriteTokens).toBe(60_120)
expect(projects[0]!.totalCostUSD).toBeCloseTo(0.6012, 6)
})
it('falls back to the legacy 5-minute cache write rate when split fields are absent', async () => {
await writeClaudeSession(
'legacy-cache-pricing',
'legacy-cache',
'/tmp/legacy-cache-pricing',
'2099-05-06T10:00:00.000Z',
{
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 60_120,
},
'claude-opus-4-7',
)
const projects = await parseAllSessions(dayRange('2099-05-06'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.sessions[0]!.totalCacheWriteTokens).toBe(60_120)
expect(projects[0]!.totalCostUSD).toBeCloseTo(0.37575, 6)
})
})
// ── Helpers for Cowork local-agent-mode session fixtures ────────────────
async function writeCoworkSession(opts: {
desktopSessionsDir: string
appId: string
workspaceId: string
sessionId: string
spaceName?: string
spaceId?: string
userSelectedFolders?: string[]
title?: string
claudeSessionId: string
timestamp: string
usage?: Record<string, unknown>
model?: string
}): Promise<void> {
const {
desktopSessionsDir, appId, workspaceId, sessionId,
spaceName, spaceId, userSelectedFolders, title,
claudeSessionId, timestamp,
usage = { input_tokens: 100, output_tokens: 50 },
model = 'claude-sonnet-4-5',
} = opts
const workspaceDir = join(desktopSessionsDir, appId, workspaceId)
// spaces.json — written only when a space is defined
await mkdir(workspaceDir, { recursive: true })
if (spaceId && spaceName) {
await writeFile(join(workspaceDir, 'spaces.json'), JSON.stringify({
spaces: [{ id: spaceId, name: spaceName, folders: [], projects: [] }],
}))
}
// local_<sessionId>.json — session metadata
const outputsDir = join(workspaceDir, sessionId, 'outputs')
const sessionMeta: Record<string, unknown> = {
sessionId,
cwd: outputsDir,
title: title ?? (spaceName ? `Test session for ${spaceName}` : 'Untitled session'),
}
if (spaceId) sessionMeta['spaceId'] = spaceId
if (userSelectedFolders) sessionMeta['userSelectedFolders'] = userSelectedFolders
await writeFile(join(workspaceDir, `${sessionId}.json`), JSON.stringify(sessionMeta))
// .claude/projects/<slug>/session.jsonl — the actual token-bearing session
// The drive colon is encoded too: Claude Code writes 'C--Users-...' because a
// colon cannot appear in a Windows directory name.
const projectSlug = outputsDir.replace(/[/\\:]/g, '-').replace(/^-/, '')
const projectDir = join(workspaceDir, sessionId, '.claude', 'projects', projectSlug)
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, `${claudeSessionId}.jsonl`)
await writeFile(filePath, JSON.stringify({
type: 'assistant',
sessionId: claudeSessionId,
timestamp,
cwd: outputsDir,
message: {
id: `msg-${claudeSessionId}`,
type: 'message',
role: 'assistant',
model,
content: [],
usage,
},
}) + '\n')
const mtime = new Date(timestamp)
await utimes(filePath, mtime, mtime)
}
describe('Claude Cowork local-agent-mode session grouping', () => {
it('groups multiple Cowork sessions from the same space under the space name', async () => {
const desktopSessionsDir = join(tmpDir, 'desktop-sessions')
const spaceId = 'space-001'
const spaceName = 'Project1'
await writeCoworkSession({
desktopSessionsDir,
appId: 'app-abc',
workspaceId: 'ws-001',
sessionId: 'local_aaaa',
spaceName,
spaceId,
claudeSessionId: 'session-a',
timestamp: '2099-06-01T10:00:00.000Z',
})
await writeCoworkSession({
desktopSessionsDir,
appId: 'app-abc',
workspaceId: 'ws-001',
sessionId: 'local_bbbb',
spaceName,
spaceId,
claudeSessionId: 'session-b',
timestamp: '2099-06-01T11:00:00.000Z',
})
const projects = await parseAllSessions(dayRange('2099-06-01'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe(spaceName)
expect(projects[0]!.sessions).toHaveLength(2)
expect(projects[0]!.sessions.map(s => s.sessionId).sort()).toEqual([
'session-a',
'session-b',
])
})
it('keeps sessions from different spaces in separate projects', async () => {
const desktopSessionsDir = join(tmpDir, 'desktop-sessions')
// Each space gets its own workspace so their spaces.json files don't overwrite each other.
await writeCoworkSession({
desktopSessionsDir,
appId: 'app-abc',
workspaceId: 'ws-001',
sessionId: 'local_cccc',
spaceName: 'Project1',
spaceId: 'space-001',
claudeSessionId: 'session-c',
timestamp: '2099-06-02T10:00:00.000Z',
})
await writeCoworkSession({
desktopSessionsDir,
appId: 'app-abc',
workspaceId: 'ws-002',
sessionId: 'local_dddd',
spaceName: 'Project2',
spaceId: 'space-002',
claudeSessionId: 'session-d',
timestamp: '2099-06-02T11:00:00.000Z',
})
const projects = await parseAllSessions(dayRange('2099-06-02'), 'claude')
expect(projects).toHaveLength(2)
const names = projects.map(p => p.project).sort()
expect(names).toEqual(['Project1', 'Project2'])
})
it('falls back to userSelectedFolders[0] basename when no spaceId is set', async () => {
const desktopSessionsDir = join(tmpDir, 'desktop-sessions')
await writeCoworkSession({
desktopSessionsDir,
appId: 'app-abc',
workspaceId: 'ws-003',
sessionId: 'local_eeee',
userSelectedFolders: ['/home/user/projects/ParentFolder/SubFolder'],
title: 'Some session title',
claudeSessionId: 'session-e',
timestamp: '2099-06-03T10:00:00.000Z',
})
const projects = await parseAllSessions(dayRange('2099-06-03'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe('SubFolder')
expect(projects[0]!.sessions).toHaveLength(1)
})
it('falls back to title when no spaceId and no userSelectedFolders', async () => {
const desktopSessionsDir = join(tmpDir, 'desktop-sessions')
await writeCoworkSession({
desktopSessionsDir,
appId: 'app-abc',
workspaceId: 'ws-004',
sessionId: 'local_ffff',
title: 'A standalone session task',
claudeSessionId: 'session-f',
timestamp: '2099-06-04T10:00:00.000Z',
})
const projects = await parseAllSessions(dayRange('2099-06-04'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe('A standalone session task')
expect(projects[0]!.sessions).toHaveLength(1)
})
it('groups container-mode sessions (cwd=/sessions/<name>) under the space name', async () => {
const desktopSessionsDir = join(tmpDir, 'desktop-sessions')
const workspaceDir = join(desktopSessionsDir, 'app-abc', 'ws-006')
const sessionId = 'local_hhhh'
// Set up workspace metadata (spaces.json + session .json with spaceId)
await mkdir(workspaceDir, { recursive: true })
await writeFile(join(workspaceDir, 'spaces.json'), JSON.stringify({
spaces: [{ id: 'space-001', name: 'Project1', folders: [], projects: [] }],
}))
const containerCwd = '/sessions/trusting-inspiring-ritchie'
await writeFile(join(workspaceDir, `${sessionId}.json`), JSON.stringify({
sessionId, spaceId: 'space-001', cwd: containerCwd, title: 'Container session',
}))
// Container-mode: project slug is derived from the container cwd, not outputs/
const containerSlug = containerCwd.replace(/\//g, '-').replace(/^-/, '')
const projectDir = join(workspaceDir, sessionId, '.claude', 'projects', containerSlug)
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, 'container-session.jsonl')
await writeFile(filePath, JSON.stringify({
type: 'assistant',
sessionId: 'container-session',
timestamp: '2099-06-06T10:00:00.000Z',
cwd: containerCwd,
message: {
id: 'msg-container', type: 'message', role: 'assistant',
model: 'claude-sonnet-4-5', content: [],
usage: { input_tokens: 100, output_tokens: 50 },
},
}) + '\n')
await utimes(filePath, new Date('2099-06-06T10:00:00.000Z'), new Date('2099-06-06T10:00:00.000Z'))
const projects = await parseAllSessions(dayRange('2099-06-06'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe('Project1')
expect(projects[0]!.projectPath).toBe('Project1')
expect(projects[0]!.sessions).toHaveLength(1)
})
it('falls back to sanitized directory slug when no session metadata exists', async () => {
const desktopSessionsDir = join(tmpDir, 'desktop-sessions')
const workspaceDir = join(desktopSessionsDir, 'app-abc', 'ws-005')
const sessionId = 'local_gggg'
const outputsDir = join(workspaceDir, sessionId, 'outputs')
const projectSlug = outputsDir.replace(/[/\\:]/g, '-').replace(/^-/, '')
const projectDir = join(workspaceDir, sessionId, '.claude', 'projects', projectSlug)
await mkdir(projectDir, { recursive: true })
// No spaces.json or session .json at all
const filePath = join(projectDir, 'no-meta-session.jsonl')
await writeFile(filePath, JSON.stringify({
type: 'assistant',
sessionId: 'no-meta-session',
timestamp: '2099-06-05T10:00:00.000Z',
cwd: outputsDir,
message: {
id: 'msg-no-meta', type: 'message', role: 'assistant',
model: 'claude-sonnet-4-5', content: [],
usage: { input_tokens: 100, output_tokens: 50 },
},
}) + '\n')
await utimes(filePath, new Date('2099-06-05T10:00:00.000Z'), new Date('2099-06-05T10:00:00.000Z'))
const projects = await parseAllSessions(dayRange('2099-06-05'), 'claude')
expect(projects).toHaveLength(1)
expect(projects[0]!.project).toBe(projectSlug)
expect(projects[0]!.sessions).toHaveLength(1)
})
})