Skip to content
38 changes: 38 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Tests

on:
pull_request:
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: 22.13.0
cache: npm
- run: npm ci
- name: Typecheck
run: npx tsc --noEmit
# The cache-refresh-lock files exercise a cross-process file lock and are
# parallelism-sensitive (they fail under full worker pressure and pass serially -
# reproduced repeatedly on unmodified main), so they run in their own serial step
# below instead of making every PR roll dice.
# Scoped to tests/: the Electron app's renderer tests under app/ carry
# their own vitest config and jsdom dependency (app/node_modules) and
# cannot run from the root install - the root default glob picking them
# up is exactly what failed run #2 with ERR_MODULE_NOT_FOUND: jsdom.
- name: Test suite (parallel)
run: npx vitest run tests --exclude "tests/cache-refresh-lock*"
# Single forked worker, so lock contention comes only from the child processes the
# tests spawn deliberately. Quarantined (reports, never gates): the process
# suite still races its own takeover window even serially on slow runners -
# tracked in #904; drop continue-on-error once that race is settled.
- name: Cache-lock suite (serial, quarantined)
continue-on-error: true
run: npx vitest run tests/cache-refresh-lock.test.ts tests/cache-refresh-lock-corrupt-body.test.ts tests/cache-refresh-lock-process.test.ts --poolOptions.forks.singleFork=true
18 changes: 10 additions & 8 deletions tests/cli-durable-totals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,16 @@ async function seedLiveTodaySession(): Promise<void> {
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p')
await mkdir(projectDir, { recursive: true })
const now = new Date()
// A couple of hours ago, clamped to never precede midnight nor exceed now, so
// the events always land inside today's [midnight, now] window whatever time
// the suite runs. A fixed noon literal silently fell outside that window on a
// pre-noon run, so the durable today slice (which ends at now) never saw them.
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const base = Math.max(todayStart, now.getTime() - 2 * 60 * 60 * 1000)
const ts = new Date(base).toISOString()
const ts2 = new Date(Math.min(base + 60_000, now.getTime())).toISOString()
// Timestamps a few minutes OLD, clamped into today: a fixed wall-clock hour
// (12:00) is in the future whenever the suite runs before noon, and the
// instant-granular provider-filtered path drops future calls while the
// day-granular all-provider path keeps them, so the parity assertion failed
// for every before-noon run (ubuntu CI at 00:17 UTC included). Same fix as
// project-filter-durable-totals got in 1596220.
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const minutesAgo = (m: number): string => new Date(Math.max(midnight, now.getTime() - m * 60_000)).toISOString()
const ts = minutesAgo(40)
const ts2 = minutesAgo(10)
const line = (id: string, t: string): string => JSON.stringify({
type: 'assistant',
timestamp: t,
Expand Down
43 changes: 34 additions & 9 deletions tests/cli-status-menubar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ 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'
import { describe, expect, it, vi } from 'vitest'

// Every case here spawns the real CLI and does genuine multi-provider parse
// work; the 5s default is fine on a dev laptop and not on a shared 2-core
// runner, where individual cases have been observed needing 6-8s.
vi.setConfig({ testTimeout: 30_000 })

function runCli(args: string[], home: string, extraEnv: Record<string, string | undefined> = {}) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
Expand Down Expand Up @@ -58,8 +63,13 @@ describe('codeburn status --format menubar-json', () => {
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)
// Two hours back, clamped inside the current UTC day (runCli pins
// TZ=UTC): a plain now-2h leaves today during the first two hours of
// the day, and the old hour-guard still escaped into yesterday during
// the first five minutes of hours 0 and 1, zeroing every "today" query
// on runs that started just past the top of those hours.
const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_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')
Expand Down Expand Up @@ -420,8 +430,13 @@ describe('codeburn status --format menubar-json', () => {
}))

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)
// Two hours back, clamped inside the current UTC day (runCli pins
// TZ=UTC): a plain now-2h leaves today during the first two hours of
// the day, and the old hour-guard still escaped into yesterday during
// the first five minutes of hours 0 and 1, zeroing every "today" query
// on runs that started just past the top of those hours.
const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_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')
Expand Down Expand Up @@ -478,8 +493,13 @@ describe('codeburn status --format menubar-json', () => {
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)
// Two hours back, clamped inside the current UTC day (runCli pins
// TZ=UTC): a plain now-2h leaves today during the first two hours of
// the day, and the old hour-guard still escaped into yesterday during
// the first five minutes of hours 0 and 1, zeroing every "today" query
// on runs that started just past the top of those hours.
const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000))
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')

Expand Down Expand Up @@ -636,8 +656,13 @@ describe('codeburn status --format menubar-json', () => {
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)
// Two hours back, clamped inside the current UTC day (runCli pins
// TZ=UTC): a plain now-2h leaves today during the first two hours of
// the day, and the old hour-guard still escaped into yesterday during
// the first five minutes of hours 0 and 1, zeroing every "today" query
// on runs that started just past the top of those hours.
const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_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'))
Expand Down
8 changes: 6 additions & 2 deletions tests/context-tree-api-prefix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ describe('web dashboard /api/context/tree: session id prefix', () => {

afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()))
await rm(homeDir, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
// close() only stops new connections; a request handler's fire-and-forget
// cache save can still land a file mid-recursive-rm, which surfaces as
// ENOTEMPTY on slower runners. fs.rm's built-in retries absorb exactly
// that window.
await rm(homeDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 })
await rm(cacheDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 })
})

it('resolves a full session id (control case)', async () => {
Expand Down
13 changes: 9 additions & 4 deletions tests/parser-incremental-append.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdtemp, mkdir, writeFile, appendFile, readFile, rm, stat, unlink } from 'fs/promises'
import { mkdtemp, mkdir, writeFile, appendFile, readFile, rename, rm, stat, unlink } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'

Expand Down Expand Up @@ -286,14 +286,19 @@ describe('incremental append parsing', () => {
await parseWith(warmCache)
const inoBefore = (await stat(sessionPath)).ino

// Replace the file (new inode) with different, LARGER content.
await unlink(sessionPath)
// Replace the file (new inode) with different, LARGER content. The
// replacement is created BESIDE the original and renamed over it: an
// unlink-then-create lets ext4 hand the freed inode straight back, which
// broke the new-inode premise on Linux CI. Two files alive at once are
// guaranteed distinct inodes, and rename keeps the replacement's.
const replaced = [
...baseLines(),
userLine('2026-05-01T12:00:00.000Z', 'brand new task'),
asstLine('msg-z', '2026-05-01T12:00:02.000Z', { input_tokens: 500, output_tokens: 120 }, [readBlock('/z.ts')]),
].join('\n') + '\n'
await writeFile(sessionPath, replaced)
const replacementPath = sessionPath + '.replacement'
await writeFile(replacementPath, replaced)
await rename(replacementPath, sessionPath)
expect((await stat(sessionPath)).ino).not.toBe(inoBefore)

readLineCalls.length = 0
Expand Down
9 changes: 7 additions & 2 deletions tests/parser-proxy-pricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,14 @@ describe('isProxiedPath: path matching rule', () => {
expect(isProxiedPath('/Users/me/work/')).toBe(true)
})

it('is case-insensitive (macOS/Windows default filesystems)', () => {
it('folds case exactly where the default filesystem does (macOS/Windows yes, Linux no)', () => {
// normalizeProxyPath lowercases only on darwin/win32, deliberately: ext4 is
// case-sensitive and folding there could credit unrelated spend. Assert the
// platform-correct behavior instead of hardcoding the macOS one, which made
// this case fail on Linux CI by design.
setProxyPaths(['/Users/Me/Work'])
expect(isProxiedPath('/users/me/work/acme')).toBe(true)
const foldsCase = process.platform === 'darwin' || process.platform === 'win32'
expect(isProxiedPath('/users/me/work/acme')).toBe(foldsCase)
})

it('matches a Windows-style config against a forward-slash cwd', () => {
Expand Down
17 changes: 9 additions & 8 deletions tests/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,16 @@ async function createJsonlSession(
const dir = join(sessionStateDir, sessionId)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'workspace.yaml'), `id: ${sessionId}\ncwd: /home/user/testproj\n`)
// Dated relative to now so the session stays inside the 90-day retention
// window whenever the suite runs; a fixed literal silently ages out (these
// events were `2026-05-01`, which prunes to zero once now is 90 days past it).
const base = Date.now() - 2 * 24 * 60 * 60 * 1000
const ts = (offsetSec: number) => new Date(base + offsetSec * 1000).toISOString()
// Relative timestamps: fixed calendar dates rot. The original '2026-05-01'
// crossed copilot's durable 90-day age-out on 2026-07-30, at which point the
// very first parse pruned the freshly-cached session and both durable tests
// started failing everywhere with "expected +0 to be 200".
const base = Date.now() - 5 * 24 * 60 * 60 * 1000
const at = (offsetSec: number): string => new Date(base + offsetSec * 1000).toISOString()
const lines = [
JSON.stringify({ type: 'session.model_change', timestamp: ts(0), data: { newModel: 'gpt-4.1' } }),
JSON.stringify({ type: 'user.message', timestamp: ts(5), data: { content: 'hello', interactionId: 'int-1' } }),
JSON.stringify({ type: 'assistant.message', timestamp: ts(10), data: { messageId: 'msg-1', outputTokens, interactionId: 'int-1', toolRequests: [] } }),
JSON.stringify({ type: 'session.model_change', timestamp: at(0), data: { newModel: 'gpt-4.1' } }),
JSON.stringify({ type: 'user.message', timestamp: at(5), data: { content: 'hello', interactionId: 'int-1' } }),
JSON.stringify({ type: 'assistant.message', timestamp: at(10), data: { messageId: 'msg-1', outputTokens, interactionId: 'int-1', toolRequests: [] } }),
]
await writeFile(join(dir, 'events.jsonl'), lines.join('\n') + '\n')
return join(dir, 'events.jsonl')
Expand Down
8 changes: 0 additions & 8 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,5 @@ export default defineConfig({
// session-discovery env vars (CLAUDE_CONFIG_DIRS, HOME, XDG_*, every
// provider-specific *_HOME) don't bleed real local data into fixtures.
setupFiles: ['./tests/setup/env-isolation.ts'],
// A handful of integration tests exercise real servers, spawned CLI
// subprocesses and real filesystem locks. Under a saturated full-suite run
// an fs/socket op can starve and the operation fails closed (correct, but
// an environmental blip, not a logic error), so a different one trips each
// run. A small retry rides out that starvation; a real regression is
// deterministic and fails every attempt. Tests that need more headroom set
// a higher retry locally (it overrides this).
retry: 2,
},
})
Loading