mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-28 18:13:16 +00:00
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.
620 lines
22 KiB
TypeScript
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)
|
|
})
|
|
})
|