codeburn/app/electron/tray-settings.test.ts
ozymandiashh 59ed66b977
feat(dock): one Claude ring per config directory, with its own sessions and today (#1570)
* feat(dock): one Claude ring per config directory, with its own sessions and today (#1523)

Rate limits are per account, so a machine with a work and a personal
~/.claude cannot be served by one Claude ring. When the CLI knows two or
more Claude config directories:

- `codeburn quota --format json` adds a top-level `claudeProfiles` array,
  one entry per directory (`providers[]` unchanged). Each entry carries
  the directory's own windows, read from its own `<dir>/.credentials.json`
  (the macOS Keychain fallback stays reserved for the default ~/.claude),
  plus that directory's own today totals from a single Claude-only,
  cache-backed parse of today's range, filtered per source id exactly like
  the menubar's per-config payloads.
- Live sessions carry `claudeConfigSourceId`, so a per-profile ring claims
  only its own sessions. Claude Desktop sessions keep their own bucket id
  and belong to no profile.
- The Windows Capacity Dock gains a `claudeProfiles` preference (combined,
  default; separate draws one captioned ring per directory). Profile rings
  keep addressing the `claude` provider id for the provider set, glyphs,
  colors and the connect button; the settings row appears only when the
  CLI reports more than one profile.
- Captioned rows are taller by two new metrics (caption line + gap),
  mirrored in TS and Rust: the page tells Rust the rail is captioned in
  the layout request, and the frame carries the effective row extent the
  hit-test uses. Non-captioned rails are pixel-identical to before.
- The desktop app's shared dock-prefs schema parses and sanitizes the new
  key, so its pane cannot strip it from windows-dock.json.

Closes #1523

* review: opt-in profile reads, today timeout, missing-dir filter (#1570)

- `quota --claude-profiles` gates the per-directory reads: without it the
  payload lists profiles ({id,label,path}) only, so a combined rail and
  WSL users pay nothing; the Windows dock passes the flag only while its
  claudeProfiles preference is separate, and an unknown option from an
  older CLI reads as CliOutdated.
- The default ~/.claude profile reuses the claude provider row's result —
  no second request, no second macOS Keychain read.
- claudeTodayBySource races the same timeoutMs and answers an empty map
  on loss, so a silent child cannot cost the dock every provider's quota.
- Missing config directories are filtered before the >1 check.
- `captioned` applies only while a Claude ring is in the selected set;
  profile rows tolerate a list-mode answer; comments trimmed; merge with
  #1560 keeps both keepExpanded and claudeProfiles.

* test(quota): set USERPROFILE for the default-dir check; drop issue refs

---------

Co-authored-by: Resham Joshi <65915470+iamtoruk@users.noreply.github.com>
2026-09-28 19:06:53 +02:00

239 lines
9.5 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
DEFAULT_TRAY_APP_PREFS,
canDeselect,
isPatchObject,
normalizeScale,
normalizedPreferred,
parseTrayAppPrefs,
parseTrayDockPrefs,
patchTrayFile,
readTrayFile,
sanitizeAppPatch,
sanitizeDockPatch,
} from './tray-settings'
describe('reading the tray app files', () => {
let home: string
const settingsPath = () => join(home, '.config', 'codeburn', 'windows-settings.json')
const dockPath = () => join(home, '.config', 'codeburn', 'windows-dock.json')
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'tray-settings-'))
mkdirSync(join(home, '.config', 'codeburn'), { recursive: true })
})
afterEach(() => { rmSync(home, { recursive: true, force: true }) })
it('is empty for a machine where the tray app has never run', () => {
expect(readTrayFile('app', home)).toEqual({})
expect(readTrayFile('dock', home)).toEqual({})
})
it('reads each file from the path the tray app reads it from', () => {
writeFileSync(settingsPath(), JSON.stringify({ metric: 'tokens' }))
writeFileSync(dockPath(), JSON.stringify({ enabled: true }))
expect(readTrayFile('app', home)).toEqual({ metric: 'tokens' })
expect(readTrayFile('dock', home)).toEqual({ enabled: true })
})
it('survives a byte order mark and refuses anything that is not an object', () => {
writeFileSync(settingsPath(), `${JSON.stringify({ metric: 'tokens' })}`)
expect(readTrayFile('app', home)).toEqual({ metric: 'tokens' })
writeFileSync(settingsPath(), '[1, 2]')
expect(readTrayFile('app', home)).toEqual({})
})
// Both files hold keys this side has no business touching: the rail's placement, the tray
// app's own theme cache, the provider set it seeded for itself.
it('merges into what is already there rather than replacing it', () => {
writeFileSync(dockPath(), JSON.stringify({
enabled: true,
placement: { docked: 'right', x: 1, y: 0.37 },
scale: 0.6,
}))
patchTrayFile('dock', { scale: 1.1 }, home)
expect(JSON.parse(readFileSync(dockPath(), 'utf8'))).toEqual({
enabled: true,
placement: { docked: 'right', x: 1, y: 0.37 },
scale: 1.1,
})
})
it('creates the directory when nothing has written there yet', () => {
rmSync(join(home, '.config'), { recursive: true, force: true })
patchTrayFile('app', { metric: 'cost' }, home)
expect(JSON.parse(readFileSync(settingsPath(), 'utf8'))).toEqual({ metric: 'cost' })
})
})
// The tray app collapses a value it cannot read to a default, so sending one would silently
// lose the setting. Everything is checked against the same closed set it parses with.
describe('parseTrayAppPrefs', () => {
it('reads every key the Menu bar pane shows', () => {
expect(parseTrayAppPrefs({
metric: 'totalTokens',
menubarPeriod: 'week',
accent: 'green',
trayBadge: true,
usageRefreshSeconds: 300,
quotaCadenceSeconds: 900,
terminal: 'powershell',
})).toEqual({
metric: 'totalTokens',
menubarPeriod: 'week',
accent: 'green',
trayBadge: true,
usageRefreshSeconds: 300,
quotaCadenceSeconds: 900,
terminal: 'powershell',
})
})
it('falls back to the tray app defaults for anything it would not understand', () => {
expect(parseTrayAppPrefs({
metric: 'credits',
menubarPeriod: '30days',
accent: 'chartreuse',
trayBadge: 'yes',
usageRefreshSeconds: 45,
quotaCadenceSeconds: -5,
terminal: 'bash',
})).toEqual(DEFAULT_TRAY_APP_PREFS)
expect(parseTrayAppPrefs({})).toEqual(DEFAULT_TRAY_APP_PREFS)
})
})
describe('parseTrayDockPrefs', () => {
it('reads every key the Capacity Dock pane shows', () => {
expect(parseTrayDockPrefs({
enabled: true,
preferred: 'claude',
scale: 0.9,
theme: 'glass',
gaugeShape: 'squircle',
providers: ['claude', 'codex'],
manualSelection: true,
claudeProfiles: 'separate',
})).toEqual({
enabled: true,
preferred: 'claude',
scale: 0.9,
theme: 'glass',
gaugeShape: 'squircle',
providers: ['claude', 'codex'],
manualSelection: true,
claudeProfiles: 'separate',
})
})
it('still understands the appearance by the name it used to have', () => {
expect(parseTrayDockPrefs({ theme: 'acrylic' }).theme).toBe('glass')
})
it('clamps and snaps the size to the steps the slider offers', () => {
expect(normalizeScale(0.2)).toBe(0.6)
expect(normalizeScale(9)).toBe(1.2)
expect(normalizeScale(0.93)).toBe(0.95)
expect(normalizeScale('big')).toBe(0.6)
})
it('keeps the Claude profiles mode to its two spelled values', () => {
expect(parseTrayDockPrefs({ claudeProfiles: 'separate' }).claudeProfiles).toBe('separate')
expect(parseTrayDockPrefs({ claudeProfiles: 'Combined' }).claudeProfiles).toBe('combined')
expect(parseTrayDockPrefs({}).claudeProfiles).toBe('combined')
})
})
describe('sanitizeAppPatch', () => {
it('passes each key through, one at a time', () => {
expect(sanitizeAppPatch({ metric: 'iconOnly' })).toEqual({ metric: 'iconOnly' })
expect(sanitizeAppPatch({ menubarPeriod: 'month' })).toEqual({ menubarPeriod: 'month' })
expect(sanitizeAppPatch({ accent: 'purple' })).toEqual({ accent: 'purple' })
expect(sanitizeAppPatch({ trayBadge: true })).toEqual({ trayBadge: true })
expect(sanitizeAppPatch({ usageRefreshSeconds: 0 })).toEqual({ usageRefreshSeconds: 0 })
expect(sanitizeAppPatch({ quotaCadenceSeconds: 60 })).toEqual({ quotaCadenceSeconds: 60 })
expect(sanitizeAppPatch({ terminal: 'commandPrompt' })).toEqual({ terminal: 'commandPrompt' })
})
// The renderer cannot reach a key the tray app owns, and cannot store a value it would
// later fail to read.
it('drops keys it is not allowed to write and repairs values it is', () => {
expect(sanitizeAppPatch({ theme: 'dark', claudeConfigDirs: ['C:\\x'] })).toEqual({})
expect(sanitizeAppPatch({ accent: 'not-a-colour' })).toEqual({ accent: 'ember' })
expect(sanitizeAppPatch({ trayBadge: 'on' })).toEqual({ trayBadge: false })
})
})
describe('sanitizeDockPatch', () => {
it('passes each key through, one at a time', () => {
expect(sanitizeDockPatch({ enabled: true })).toEqual({ enabled: true })
expect(sanitizeDockPatch({ scale: 1.05 })).toEqual({ scale: 1.05 })
expect(sanitizeDockPatch({ theme: 'glass' })).toEqual({ theme: 'glass' })
expect(sanitizeDockPatch({ gaugeShape: 'squircle' })).toEqual({ gaugeShape: 'squircle' })
expect(sanitizeDockPatch({ claudeProfiles: 'separate' })).toEqual({ claudeProfiles: 'separate' })
expect(sanitizeDockPatch({ preferred: 'codex' })).toEqual({ preferred: 'codex' })
})
// Editing the set by hand is what stops the tray app seeding it from whatever is connected.
it('latches the manual selection whenever the provider set is written', () => {
expect(sanitizeDockPatch({ providers: ['claude', 'codex'] }))
.toEqual({ providers: ['claude', 'codex'], manualSelection: true })
})
it('refuses anything that is not a provider id, and the rail placement', () => {
expect(sanitizeDockPatch({ placement: { docked: 'left' } })).toEqual({})
expect(sanitizeDockPatch({ preferred: '../../etc' })).toEqual({ preferred: null })
expect(sanitizeDockPatch({ providers: ['claude', 'Bad Id', 42] }))
.toEqual({ providers: ['claude'], manualSelection: true })
// An unknown profiles mode is repaired to the default rather than written where the tray
// app would have to parse it back.
expect(sanitizeDockPatch({ claudeProfiles: 'one-ring-each' })).toEqual({ claudeProfiles: 'combined' })
})
})
// Both patches arrive over IPC, so the argument is whatever the renderer sent rather than the
// object the signature promises. `'metric' in patch` throws a TypeError on a string, a number
// and null, and on an array it answers about indices, so the shape is settled first.
describe('a patch that is not an object at all', () => {
const notObjects: unknown[] = [null, undefined, 'metric', 42, ['metric'], [], true]
it('is dropped whole by isPatchObject', () => {
for (const value of notObjects) expect(isPatchObject(value)).toBe(false)
expect(isPatchObject({})).toBe(true)
expect(isPatchObject({ metric: 'cost' })).toBe(true)
})
it('writes nothing through sanitizeAppPatch, and does not throw', () => {
for (const value of notObjects) expect(sanitizeAppPatch(value)).toEqual({})
})
it('writes nothing through sanitizeDockPatch, and does not throw', () => {
for (const value of notObjects) expect(sanitizeDockPatch(value)).toEqual({})
})
})
describe('the rail always has something to show', () => {
it('moves the resting provider when it leaves the set', () => {
expect(normalizedPreferred('claude', ['claude', 'codex'])).toBe('claude')
expect(normalizedPreferred('gemini', ['claude', 'codex'])).toBe('claude')
expect(normalizedPreferred('claude', [])).toBeNull()
})
it('keeps the last connected provider switched on', () => {
expect(canDeselect('claude', ['claude', 'codex'], ['claude', 'codex'])).toBe(true)
expect(canDeselect('claude', ['claude'], ['claude'])).toBe(false)
// One that is not connected is not what the rail is being kept alive by.
expect(canDeselect('codex', ['claude', 'codex'], ['claude'])).toBe(true)
expect(canDeselect('gemini', ['claude'], ['claude'])).toBe(true)
})
})