mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 09:39:24 +00:00
The 'By Project' panel collapsed sibling Claude Code projects to a parent
folder in two cases:
1) resolveCanonicalProjectPath walked up to ANY ancestor .git and used it as
the canonical project. A stray .git in a parent of several projects (a
dotfiles bare repo, an accidental git init) made every sibling resolve to
that parent ('Projects'/'home'). Now only a real linked worktree (.git is a
FILE pointing at <main>/.git/worktrees/<name>) canonicalizes to its main
repo; an ordinary repo or a stray ancestor .git keeps the session's own cwd
as the project. Genuine worktree grouping is preserved.
2) When a Claude session has no cwd metadata, the dir slug was unsanitized by
replacing every '-' with '/', inventing path segments that overview then
split on, so 'Projects-Content-OS' rendered as 'os'. The lossy slug is now
kept intact, and overview only basenames ABSOLUTE paths.
Fixes #493
118 lines
4.1 KiB
TypeScript
118 lines
4.1 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
|
||
import { renderOverview } from '../src/overview.js'
|
||
import type { ProjectSummary } from '../src/types.js'
|
||
|
||
function makeProject(opts: {
|
||
project: string
|
||
projectPath: string
|
||
cost: number
|
||
calls: number
|
||
model: string
|
||
provider: string
|
||
tokens: { input: number; output: number; cacheR: number; cacheW: number }
|
||
}): ProjectSummary {
|
||
const usage = {
|
||
inputTokens: opts.tokens.input,
|
||
outputTokens: opts.tokens.output,
|
||
cacheReadInputTokens: opts.tokens.cacheR,
|
||
cacheCreationInputTokens: opts.tokens.cacheW,
|
||
}
|
||
return {
|
||
project: opts.project,
|
||
projectPath: opts.projectPath,
|
||
totalCostUSD: opts.cost,
|
||
totalSavingsUSD: 0,
|
||
totalProxiedCostUSD: 0,
|
||
totalApiCalls: opts.calls,
|
||
sessions: [{
|
||
sessionId: 's1',
|
||
project: opts.project,
|
||
totalInputTokens: opts.tokens.input,
|
||
totalOutputTokens: opts.tokens.output,
|
||
totalCacheReadTokens: opts.tokens.cacheR,
|
||
totalCacheWriteTokens: opts.tokens.cacheW,
|
||
apiCalls: opts.calls,
|
||
modelBreakdown: { [opts.model]: { calls: opts.calls, costUSD: opts.cost, savingsUSD: 0, tokens: usage } },
|
||
categoryBreakdown: { coding: { turns: 1, costUSD: opts.cost, savingsUSD: 0, retries: 0, editTurns: 1, oneShotTurns: 1 } },
|
||
toolBreakdown: { Bash: { calls: 5 }, Read: { calls: 2 } },
|
||
mcpBreakdown: {},
|
||
bashBreakdown: {},
|
||
skillBreakdown: {},
|
||
subagentBreakdown: {},
|
||
turns: [{
|
||
userMessage: 'hi',
|
||
timestamp: '2026-06-15T10:00:00Z',
|
||
sessionId: 's1',
|
||
category: 'coding',
|
||
retries: 0,
|
||
hasEdits: true,
|
||
assistantCalls: [{ provider: opts.provider, model: opts.model, costUSD: opts.cost, usage }],
|
||
}],
|
||
}],
|
||
} as unknown as ProjectSummary
|
||
}
|
||
|
||
describe('renderOverview', () => {
|
||
it('renders the detailed sections from real aggregation', () => {
|
||
const out = renderOverview([makeProject({
|
||
project: 'myproject',
|
||
projectPath: '/Users/test/myproject',
|
||
cost: 12.5,
|
||
calls: 3,
|
||
model: 'claude-opus-4-8',
|
||
provider: 'claude',
|
||
tokens: { input: 1000, output: 200, cacheR: 5000, cacheW: 100 },
|
||
})], { label: 'June 2026', color: false })
|
||
|
||
for (const section of ['Totals', 'By tool', 'Top models', 'Highest-value days', 'Top projects', 'Daily', 'By activity', 'Tools']) {
|
||
expect(out).toContain(section)
|
||
}
|
||
expect(out).toContain('Opus 4.8') // model display name
|
||
expect(out).toContain('claude') // provider in By tool
|
||
expect(out).toContain('myproject') // clean project name from path basename
|
||
expect(out).toContain('$12.50')
|
||
expect(out).toContain('2026-06-15')
|
||
expect(out).toContain('Coding')
|
||
expect(out).toContain('Bash')
|
||
})
|
||
|
||
it('uses thousands separators and a B unit, and strips color in no-color mode', () => {
|
||
const out = renderOverview([makeProject({
|
||
project: 'big',
|
||
projectPath: '/Users/test/big',
|
||
cost: 1234.56,
|
||
calls: 10,
|
||
model: 'claude-opus-4-8',
|
||
provider: 'claude',
|
||
tokens: { input: 1_000_000, output: 1_000_000, cacheR: 2_000_000_000, cacheW: 0 },
|
||
})], { label: 'June 2026', color: false })
|
||
|
||
expect(out).toContain('$1,234.56')
|
||
// tokens render as full, comma-grouped numbers (not abbreviated)
|
||
expect(out).toContain('2,002,000,000')
|
||
// no-color mode must not emit ANSI escape codes
|
||
// eslint-disable-next-line no-control-regex
|
||
expect(out).not.toMatch(/\[/)
|
||
})
|
||
|
||
it('reports no usage for an empty range', () => {
|
||
const out = renderOverview([], { label: 'June 2026', color: false })
|
||
expect(out).toContain('No usage found for June 2026')
|
||
})
|
||
|
||
it('does not split a slug-only Claude project path into fake path segments', () => {
|
||
const out = renderOverview([makeProject({
|
||
project: 'Projects-Content-OS',
|
||
projectPath: 'Projects/Content/OS',
|
||
cost: 3.25,
|
||
calls: 1,
|
||
model: 'claude-sonnet-4-5',
|
||
provider: 'claude',
|
||
tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 },
|
||
})], { label: 'June 2026', color: false })
|
||
|
||
expect(out).toContain('Projects-Content-OS')
|
||
expect(out).not.toContain(' OS ')
|
||
})
|
||
})
|