Files
getagentseal--codeburn/tests/cli-status-menubar.test.ts
2026-07-13 12:26:02 +08:00

644 lines
24 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter as pathDelimiter, join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
function runCli(args: string[], home: string, extraEnv: Record<string, string | undefined> = {}) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'),
HOME: home,
TZ: 'UTC',
...extraEnv,
},
encoding: 'utf-8',
timeout: 30_000,
})
}
function userLine(sessionId: string, timestamp: string): string {
return JSON.stringify({
type: 'user',
sessionId,
timestamp,
message: { role: 'user', content: 'do the thing' },
})
}
function assistantLine(sessionId: string, timestamp: string, messageId: string): string {
return JSON.stringify({
type: 'assistant',
sessionId,
timestamp,
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [
{ type: 'text', text: 'done' },
{ type: 'tool_use', id: 'tu-1', name: 'Edit', input: { file_path: '/tmp/x', old_string: 'a', new_string: 'b' } },
],
usage: { input_tokens: 500, output_tokens: 50 },
},
})
}
describe('codeburn status --format menubar-json', () => {
it('returns valid MenubarPayload with expected top-level fields', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const h = now.getUTCHours()
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z')
const ts4 = new Date(base.getTime() + 180_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('s1', ts1),
assistantLine('s1', ts2, 'msg-1'),
userLine('s1', ts3),
assistantLine('s1', ts4, 'msg-2'),
].join('\n'),
)
const result = runCli([
'status',
'--format', 'menubar-json',
'--period', 'today',
'--provider', 'all',
'--no-optimize',
], home)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as Record<string, unknown>
expect(payload).toHaveProperty('generated')
expect(payload).toHaveProperty('current')
expect(payload).toHaveProperty('optimize')
expect(payload).toHaveProperty('history')
const current = payload['current'] as Record<string, unknown>
expect(current['cost']).toBeGreaterThan(0)
expect(current['calls']).toBe(2)
expect(current['sessions']).toBe(1)
expect(current).toHaveProperty('oneShotRate')
expect(current).toHaveProperty('topActivities')
expect(current).toHaveProperty('topModels')
expect(current).toHaveProperty('providers')
const history = payload['history'] as { daily: unknown[] }
expect(Array.isArray(history.daily)).toBe(true)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('filters menubar payloads to a selected review day with --day', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-day-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('before', '2026-04-09T23:58:00Z'),
assistantLine('before', '2026-04-09T23:59:00Z', 'msg-before'),
userLine('selected', '2026-04-10T11:59:00Z'),
assistantLine('selected', '2026-04-10T12:00:00Z', 'msg-selected'),
userLine('after', '2026-04-11T00:00:00Z'),
assistantLine('after', '2026-04-11T00:01:00Z', 'msg-after'),
].join('\n'),
)
const result = runCli([
'status',
'--format', 'menubar-json',
'--day', '2026-04-10',
'--provider', 'all',
'--no-optimize',
], home)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: {
label: string
calls: number
sessions: number
topProjects: Array<{ sessions: number; sessionDetails: Array<{ date: string }> }>
}
}
expect(payload.current.label).toBe('Day (2026-04-10)')
expect(payload.current.calls).toBe(1)
expect(payload.current.sessions).toBe(1)
expect(payload.current.topProjects).toHaveLength(1)
expect(payload.current.topProjects[0]?.sessions).toBe(1)
expect(payload.current.topProjects[0]?.sessionDetails.map(s => s.date)).toEqual(['2026-04-10'])
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('filters the whole menubar payload to a selected Claude config source', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-claude-config-'))
try {
const work = join(home, 'claude-work')
const personal = join(home, 'claude-personal')
const slug = 'shared-app'
await mkdir(join(work, 'projects', slug), { recursive: true })
await mkdir(join(personal, 'projects', slug), { recursive: true })
await writeFile(
join(work, 'projects', slug, 'work.jsonl'),
[
userLine('work', '2026-04-10T11:59:00Z'),
assistantLine('work', '2026-04-10T12:00:00Z', 'msg-work'),
].join('\n'),
)
await writeFile(
join(personal, 'projects', slug, 'personal.jsonl'),
[
userLine('personal', '2026-04-10T12:59:00Z'),
assistantLine('personal', '2026-04-10T13:00:00Z', 'msg-personal'),
].join('\n'),
)
const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter) }
const allResult = runCli([
'status',
'--format', 'menubar-json',
'--period', 'all',
'--provider', 'all',
'--no-optimize',
], home, env)
expect(allResult.status, `stderr: ${allResult.stderr}`).toBe(0)
const allPayload = JSON.parse(allResult.stdout) as {
current: { calls: number; sessions: number }
claudeConfigs?: { selectedId: string | null; options: Array<{ id: string; label: string; path: string }> }
}
expect(allPayload.current.calls).toBe(2)
expect(allPayload.current.sessions).toBe(2)
expect(allPayload.claudeConfigs?.selectedId).toBeNull()
expect(allPayload.claudeConfigs?.options.map(o => o.label).sort()).toEqual(['claude-personal', 'claude-work'])
const workSourceId = allPayload.claudeConfigs!.options.find(o => o.label === 'claude-work')!.id
const workResult = runCli([
'status',
'--format', 'menubar-json',
'--period', 'all',
'--provider', 'all',
'--claude-config-source', workSourceId,
'--no-optimize',
], home, env)
expect(workResult.status, `stderr: ${workResult.stderr}`).toBe(0)
const workPayload = JSON.parse(workResult.stdout) as {
current: { calls: number; sessions: number; providers: Record<string, number> }
history: { daily: Array<{ calls: number }> }
claudeConfigs?: { selectedId: string | null }
}
expect(workPayload.claudeConfigs?.selectedId).toBe(workSourceId)
expect(workPayload.current.calls).toBe(1)
expect(workPayload.current.sessions).toBe(1)
expect(Object.keys(workPayload.current.providers)).toEqual(['claude'])
expect(workPayload.history.daily.reduce((sum, d) => sum + d.calls, 0)).toBe(1)
const invalidResult = runCli([
'status',
'--format', 'menubar-json',
'--period', 'all',
'--provider', 'all',
'--claude-config-source', 'claude-config:missing',
'--no-optimize',
], home, env)
expect(invalidResult.status, `stderr: ${invalidResult.stderr}`).toBe(0)
const invalidPayload = JSON.parse(invalidResult.stdout) as {
current: { calls: number }
claudeConfigs?: { selectedId: string | null }
}
expect(invalidPayload.current.calls).toBe(2)
expect(invalidPayload.claudeConfigs?.selectedId).toBeNull()
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('keeps idle Claude config options visible for the selected period', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-claude-config-idle-'))
try {
const work = join(home, 'claude-work')
const personal = join(home, 'claude-personal')
await mkdir(join(work, 'projects', 'app'), { recursive: true })
await mkdir(join(personal, 'projects', 'app'), { recursive: true })
await writeFile(
join(work, 'projects', 'app', 'work.jsonl'),
[
userLine('work', '2026-04-10T11:59:00Z'),
assistantLine('work', '2026-04-10T12:00:00Z', 'msg-work'),
].join('\n'),
)
await writeFile(
join(personal, 'projects', 'app', 'personal.jsonl'),
[
userLine('personal', '2026-04-09T11:59:00Z'),
assistantLine('personal', '2026-04-09T12:00:00Z', 'msg-personal'),
].join('\n'),
)
const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter) }
const allResult = runCli([
'status',
'--format', 'menubar-json',
'--day', '2026-04-10',
'--provider', 'all',
'--no-optimize',
], home, env)
expect(allResult.status, `stderr: ${allResult.stderr}`).toBe(0)
const allPayload = JSON.parse(allResult.stdout) as {
current: { calls: number }
claudeConfigs?: { options: Array<{ id: string; label: string }> }
}
expect(allPayload.current.calls).toBe(1)
expect(allPayload.claudeConfigs?.options.map(o => o.label).sort()).toEqual(['claude-personal', 'claude-work'])
const personalSourceId = allPayload.claudeConfigs!.options.find(o => o.label === 'claude-personal')!.id
const personalResult = runCli([
'status',
'--format', 'menubar-json',
'--day', '2026-04-10',
'--provider', 'all',
'--claude-config-source', personalSourceId,
'--no-optimize',
], home, env)
expect(personalResult.status, `stderr: ${personalResult.stderr}`).toBe(0)
const personalPayload = JSON.parse(personalResult.stdout) as {
current: { calls: number; sessions: number; providers: Record<string, number> }
claudeConfigs?: { selectedId: string | null }
}
expect(personalPayload.claudeConfigs?.selectedId).toBe(personalSourceId)
expect(personalPayload.current.calls).toBe(0)
expect(personalPayload.current.sessions).toBe(0)
expect(personalPayload.current.providers).toEqual({ claude: 0 })
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('rejects --claude-config-source combined with a non-Claude --provider', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-cfg-provider-'))
try {
const work = join(home, 'claude-work')
const personal = join(home, 'claude-personal')
await mkdir(join(work, 'projects', 'app'), { recursive: true })
await mkdir(join(personal, 'projects', 'app'), { recursive: true })
const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter) }
const result = runCli([
'status', '--format', 'menubar-json', '--period', 'all',
'--provider', 'codex', '--claude-config-source', 'claude-config:whatever',
'--no-optimize',
], home, env)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('--claude-config-source cannot be combined with --provider codex')
// 'claude' is allowed (a config scopes Claude usage).
const okClaude = runCli([
'status', '--format', 'menubar-json', '--period', 'all',
'--provider', 'claude', '--no-optimize',
], home, env)
expect(okClaude.status, `stderr: ${okClaude.stderr}`).toBe(0)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('surfaces Claude Desktop sessions as their own bucket so config sum equals All', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-desktop-'))
try {
const work = join(home, 'claude-work')
const personal = join(home, 'claude-personal')
await mkdir(join(work, 'projects', 'app'), { recursive: true })
await mkdir(join(personal, 'projects', 'app'), { recursive: true })
await writeFile(join(work, 'projects', 'app', 'w.jsonl'),
[userLine('w', '2026-04-10T11:59:00Z'), assistantLine('w', '2026-04-10T12:00:00Z', 'mw')].join('\n'))
await writeFile(join(personal, 'projects', 'app', 'p.jsonl'),
[userLine('p', '2026-04-10T12:59:00Z'), assistantLine('p', '2026-04-10T13:00:00Z', 'mp')].join('\n'))
// A fake Claude Desktop sessions tree.
const desktop = join(home, 'desktop-sessions')
const dProj = join(desktop, 'appid', 'ws', 'local_s1', '.claude', 'projects', 'space')
await mkdir(dProj, { recursive: true })
await writeFile(join(dProj, 'd.jsonl'),
[userLine('d', '2026-04-10T13:59:00Z'), assistantLine('d', '2026-04-10T14:00:00Z', 'md')].join('\n'))
const env = {
CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter),
CODEBURN_DESKTOP_SESSIONS_DIR: desktop,
}
const result = runCli([
'status', '--format', 'menubar-json', '--period', 'all', '--provider', 'all', '--no-optimize',
], home, env)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: { calls: number }
claudeConfigs?: { options: Array<{ id: string; label: string }> }
}
// 3 sessions total: work + personal + desktop.
expect(payload.current.calls).toBe(3)
// The selector lists all three, including a Claude Desktop bucket.
const labels = payload.claudeConfigs?.options.map(o => o.label).sort()
expect(labels).toContain('Claude Desktop')
expect(labels).toHaveLength(3)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('includes LingTai TUI usage and activity categories in menubar payloads', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-lingtai-'))
try {
const lingtaiHome = join(home, '.lingtai')
const agentDir = join(lingtaiHome, 'agent')
await mkdir(join(agentDir, 'logs'), { recursive: true })
await writeFile(join(agentDir, '.agent.json'), JSON.stringify({
agent_id: 'agent-1',
agent_name: 'LingTai Agent',
llm: { model: 'gpt-4o' },
}))
const now = new Date()
const h = now.getUTCHours()
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(agentDir, 'logs', 'token_ledger.jsonl'),
[
JSON.stringify({ source: 'main', ts: ts1, input: 1000, cached: 100, output: 100, model: 'gpt-4o' }),
JSON.stringify({ source: 'tc_wake', ts: ts2, input: 2000, cached: 500, output: 200, model: 'gpt-4o' }),
JSON.stringify({ source: 'summarize_apriori', ts: ts3, input: 1500, cached: 300, output: 150, model: 'gpt-4o' }),
].join('\n') + '\n',
)
const result = runCli([
'status',
'--format', 'menubar-json',
'--period', 'today',
'--provider', 'lingtai-tui',
'--no-optimize',
], home, {
LINGTAI_HOME: lingtaiHome,
LINGTAI_TUI_GLOBAL_DIR: join(home, '.lingtai-tui'),
})
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: {
cost: number
calls: number
providers: Record<string, number>
topActivities: Array<{ name: string; turns: number }>
}
}
expect(payload.current.cost).toBeGreaterThan(0)
expect(payload.current.calls).toBe(3)
expect(payload.current.providers['lingtai tui']).toBeGreaterThan(0)
expect(payload.current.topActivities.map(a => a.name).sort()).toEqual([
'Conversation',
'Delegation',
'Planning',
])
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('attaches combined local-only usage for --scope combined and omits it for local scope', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const h = now.getUTCHours()
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('s1', ts1),
assistantLine('s1', ts2, 'msg-1'),
].join('\n'),
)
const combinedResult = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'combined',
'--period', 'today',
'--provider', 'all',
'--no-optimize',
], home)
expect(combinedResult.status, `stderr: ${combinedResult.stderr}`).toBe(0)
const payload = JSON.parse(combinedResult.stdout) as {
current: {
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
}
history: {
daily: Array<{ cacheWriteTokens?: number; cacheReadTokens?: number }>
}
combined?: {
perDevice: Array<{
id: string
local: boolean
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheCreateTokens: number
cacheReadTokens: number
totalTokens: number
}>
combined: {
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheCreateTokens: number
cacheReadTokens: number
totalTokens: number
deviceCount: number
reachableCount: number
}
}
}
expect(payload.combined).toBeDefined()
expect(payload.combined!.perDevice).toHaveLength(1)
const local = payload.combined!.perDevice[0]!
const cacheCreateTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheWriteTokens ?? 0), 0)
const cacheReadTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheReadTokens ?? 0), 0)
const totalTokens = payload.current.inputTokens + payload.current.outputTokens + cacheCreateTokens + cacheReadTokens
expect(local).toMatchObject({
id: 'local',
local: true,
cost: payload.current.cost,
calls: payload.current.calls,
sessions: payload.current.sessions,
inputTokens: payload.current.inputTokens,
outputTokens: payload.current.outputTokens,
cacheCreateTokens,
cacheReadTokens,
totalTokens,
})
expect(payload.combined!.combined).toEqual({
cost: payload.current.cost,
calls: payload.current.calls,
sessions: payload.current.sessions,
inputTokens: payload.current.inputTokens,
outputTokens: payload.current.outputTokens,
cacheCreateTokens,
cacheReadTokens,
totalTokens,
deviceCount: 1,
reachableCount: 1,
})
const localResult = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'local',
'--period', 'today',
'--provider', 'all',
'--no-optimize',
], home)
expect(localResult.status, `stderr: ${localResult.stderr}`).toBe(0)
expect(JSON.parse(localResult.stdout)).not.toHaveProperty('combined')
} finally {
await rm(home, { recursive: true, force: true })
}
})
it.each([
['--provider', ['--provider', 'claude']],
['--project', ['--project', 'x']],
['--exclude', ['--exclude', 'y']],
])('rejects combined scope with filtered local payloads from %s', async (_name, filterArgs) => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-filter-'))
try {
const result = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'combined',
...filterArgs,
'--no-optimize',
], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('error: --scope combined cannot be combined with --provider, --project, or --exclude (paired devices report unfiltered usage)')
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('rejects invalid menubar-json scope values', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-scope-'))
try {
const result = runCli([
'status',
'--format', 'menubar-json',
'--scope', 'remote',
'--no-optimize',
], home)
expect(result.status).toBe(1)
expect(result.stderr).toContain('unknown scope "remote"')
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('still emits a valid combined menubar payload when the remotes store is corrupt', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-corrupt-remotes-'))
try {
const projectDir = join(home, '.claude', 'projects', 'myapp')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const h = now.getUTCHours()
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
await writeFile(join(projectDir, 'session.jsonl'), [userLine('s1', ts1), assistantLine('s1', ts2, 'msg-1')].join('\n'))
// Corrupt the remotes store the combined path reads. The menubar must
// still get a valid payload (combined degrades to local-only).
const sharingDir = join(home, '.config', 'codeburn', 'sharing')
await mkdir(sharingDir, { recursive: true })
await writeFile(join(sharingDir, 'remote-devices.json'), '{ this is : not valid json ]')
const result = runCli([
'status', '--format', 'menubar-json', '--scope', 'combined',
'--period', 'today', '--no-optimize',
], home)
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
const payload = JSON.parse(result.stdout) as {
current: { cost: number }
combined?: { perDevice: unknown[]; combined: { deviceCount: number; reachableCount: number } }
}
expect(payload.combined).toBeDefined()
expect(payload.combined!.perDevice).toHaveLength(1)
expect(payload.combined!.combined.deviceCount).toBe(1)
expect(payload.combined!.combined.reachableCount).toBe(1)
} finally {
await rm(home, { recursive: true, force: true })
}
})
})