Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
12 changes: 10 additions & 2 deletions tests/cli-durable-totals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,16 @@ async function seedLiveTodaySession(): Promise<void> {
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const ts = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0).toISOString()
const ts2 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 30, 0).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
12 changes: 9 additions & 3 deletions tests/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +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`)
// 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: '2026-05-01T10:00:00Z', data: { newModel: 'gpt-4.1' } }),
JSON.stringify({ type: 'user.message', timestamp: '2026-05-01T10:00:05Z', data: { content: 'hello', interactionId: 'int-1' } }),
JSON.stringify({ type: 'assistant.message', timestamp: '2026-05-01T10:00:10Z', 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
Loading