codeburn/tests/session-count-label.test.ts
Resham Joshi 3f14d95118
fix(menubar): older menubar apps load again, and in-app Update restarts the app (#1548)
* fix(menubar): always emit avgCostPerSession for older menubar apps

Menubar 0.9.14-0.9.24 decode topProjects[].avgCostPerSession as required,
so omitting it for partial session counts broke Today with any 0.9.25 CLI.
Emit the 0.9.24 value again and keep sessionCountBasis so 0.9.25+ hides
a partial average. Add a test that checks the payload against the
required keys of every released menubar app.

* fix(menubar): restart the app after an in-app update

The app runs `codeburn menubar --force` as its child, and pgrep/pkill
skip their own ancestors, so the running app was never stopped and
`open` re-activated the old copy. Find the app with pgrep -a; when it is
an ancestor, swap the bundle while it runs, exit, and let a detached
helper stop it and open the new bundle. The terminal path stops the app
first as before.

Reported in #1541, thanks @AleksandrLiadov.
2026-09-24 20:07:25 +02:00

119 lines
4.3 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { buildMenubarPayload } from '../src/menubar-json.js'
import { formatSessionCount, sessionCountIsExact, SESSION_COUNT_HELP } from '../src/session-count-label.js'
import type { PeriodData } from '../src/menubar-json.js'
describe('session count labels', () => {
it('uses plain lower-bound phrasing and never names occupancy or cache', () => {
expect(formatSessionCount(3, 'partial')).toBe('At least 3 sessions')
expect(formatSessionCount(1, 'partial')).toBe('At least 1 session')
expect(formatSessionCount(0, 'partial')).toBe('Session count unavailable')
expect(formatSessionCount(1, 'identity')).toBe('1 session')
expect(formatSessionCount(2, 'identity')).toBe('2 sessions')
expect(formatSessionCount(0, 'identity')).toBe('0 sessions')
expect(formatSessionCount(3, undefined)).toBe('At least 3 sessions')
expect(SESSION_COUNT_HELP).toContain('Older session logs')
expect(formatSessionCount(3, 'partial')).not.toMatch(/occupancy|canonical|cache/i)
})
})
describe('payload serialization', () => {
const emptyPeriod = (): PeriodData => ({
label: 'Last 7 days',
cost: 4,
savingsUSD: 0,
calls: 4,
sessions: 3,
sessionCountBasis: 'partial',
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
categories: [],
models: [],
projects: [{
id: '/tmp/count-boundary',
name: 'vault',
cost: 4,
savingsUSD: 0,
sessions: 3,
sessionCountBasis: 'partial',
}],
})
it('keeps the average numeric for a bound count and labels it partial', () => {
const payload = buildMenubarPayload(emptyPeriod(), [], null)
const json = JSON.parse(JSON.stringify(payload)) as {
current: { sessions: number, sessionCountBasis?: string, topProjects: Array<{ sessions: number, avgCostPerSession?: number, sessionCountBasis?: string }> }
}
expect(json.current.sessions).toBe(3)
expect(json.current.sessionCountBasis).toBe('partial')
expect(json.current.topProjects[0]!.sessions).toBe(3)
expect(json.current.topProjects[0]!.sessionCountBasis).toBe('partial')
expect(json.current.topProjects[0]!.avgCostPerSession).toBeCloseTo(4 / 3)
expect(sessionCountIsExact('partial')).toBe(false)
})
it('emits average only for source-only exact identity', () => {
const period = emptyPeriod()
period.sessionCountBasis = 'identity'
period.sessions = 1
period.cost = 0.012
period.projects = [{
id: '/tmp/same-session-project',
name: 'vault',
cost: 0.012,
savingsUSD: 0,
sessions: 1,
sessionCountBasis: 'identity',
}]
const payload = buildMenubarPayload(period, [], null)
expect(payload.current.topProjects[0]!.avgCostPerSession).toBeCloseTo(0.012)
expect(payload.current.topProjects[0]!.sessionCountBasis).toBe('identity')
})
it('still decodes an older payload that required a numeric average', () => {
const legacy = {
generated: '2026-09-07T00:00:00Z',
current: {
label: 'Last 7 days',
cost: 4,
calls: 4,
sessions: 2,
oneShotRate: null,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheHitPercent: 0,
codexCredits: 0,
topActivities: [],
topModels: [],
localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
providers: {},
providerDetails: [],
topProjects: [{
name: 'vault',
cost: 4,
savingsUSD: 0,
sessions: 2,
avgCostPerSession: 2,
sessionDetails: [],
}],
modelEfficiency: [],
topSessions: [],
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
tools: [],
skills: [],
subagents: [],
mcpServers: [],
},
optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] },
history: { daily: [] },
}
expect(legacy.current.topProjects[0]!.avgCostPerSession).toBe(2)
expect(legacy.current.topProjects[0]!.sessions).toBe(2)
expect(formatSessionCount(legacy.current.topProjects[0]!.sessions, undefined)).toBe('At least 2 sessions')
})
})