diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..08a24a88 --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/tests/cli-durable-totals.test.ts b/tests/cli-durable-totals.test.ts index 37c6a450..f50e9415 100644 --- a/tests/cli-durable-totals.test.ts +++ b/tests/cli-durable-totals.test.ts @@ -89,14 +89,16 @@ async function seedLiveTodaySession(): Promise { 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, diff --git a/tests/cli-status-menubar.test.ts b/tests/cli-status-menubar.test.ts index 0086212d..292385da 100644 --- a/tests/cli-status-menubar.test.ts +++ b/tests/cli-status-menubar.test.ts @@ -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 = {}) { return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { @@ -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') @@ -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') @@ -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') @@ -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')) diff --git a/tests/context-tree-api-prefix.test.ts b/tests/context-tree-api-prefix.test.ts index f95ee096..2c7f0d13 100644 --- a/tests/context-tree-api-prefix.test.ts +++ b/tests/context-tree-api-prefix.test.ts @@ -56,8 +56,12 @@ describe('web dashboard /api/context/tree: session id prefix', () => { afterEach(async () => { await new Promise((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 () => { diff --git a/tests/parser-incremental-append.test.ts b/tests/parser-incremental-append.test.ts index 858c96d4..3c192146 100644 --- a/tests/parser-incremental-append.test.ts +++ b/tests/parser-incremental-append.test.ts @@ -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' @@ -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 diff --git a/tests/parser-proxy-pricing.test.ts b/tests/parser-proxy-pricing.test.ts index 26df51b1..a4a2ebd0 100644 --- a/tests/parser-proxy-pricing.test.ts +++ b/tests/parser-proxy-pricing.test.ts @@ -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', () => { diff --git a/tests/parser.test.ts b/tests/parser.test.ts index ca254247..b4dd0631 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -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') diff --git a/vitest.config.ts b/vitest.config.ts index 6c7155f4..b56c0158 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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, }, })