From 0e3a1125c9dccdd18e093aa9ea719e5ad3e5b270 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:17:11 +0300 Subject: [PATCH 1/7] ci: run tsc and the vitest suite on pull requests Implements the #898 proposal. The parallel-sensitive cache-refresh-lock files run serially in their own step; everything else runs in the default forks pool. Flag syntax verified locally against vitest 3.2.6. --- .github/workflows/tests.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..72383284 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,31 @@ +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. + - name: Test suite (parallel) + run: npx vitest run --exclude "tests/cache-refresh-lock*" + # Single forked worker, so lock contention comes only from the child processes the + # tests spawn deliberately. + - name: Cache-lock suite (serial) + 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 From bcf115525519866247eb4a0ce1977771a74d2ad5 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:38:18 +0300 Subject: [PATCH 2/7] test: fix the five environment-sensitive failures the first ubuntu run exposed - cli-durable-totals: the live fixture session was stamped at noon today, so every before-noon run saw it in the future; the provider-filtered path drops future instants while the all-provider path keeps the whole day, failing the parity assertion. Relative-and-clamped timestamps, the same fix project-filter-durable-totals got in 1596220. - parser (copilot, 2 cases): the fixture's fixed 2026-05-01 dates crossed copilot's durable 90-day age-out on 2026-07-30, so the first parse pruned the freshly-cached session. Relative timestamps. - parser-incremental-append: unlink-then-create let ext4 hand the freed inode straight back, breaking the new-inode premise. The replacement is now created beside the original and renamed over it. - parser-proxy-pricing: normalizeProxyPath folds case only on darwin and win32, deliberately; the test now asserts the platform-correct behavior on both kinds of filesystem instead of hardcoding macOS. - cli-status-menubar: the config-source filter case does real multi-parse work and needs more than the 5s default on shared runners; 30s cap. --- tests/cli-durable-totals.test.ts | 12 ++++++++++-- tests/cli-status-menubar.test.ts | 2 +- tests/parser-incremental-append.test.ts | 13 +++++++++---- tests/parser-proxy-pricing.test.ts | 9 +++++++-- tests/parser.test.ts | 12 +++++++++--- 5 files changed, 36 insertions(+), 12 deletions(-) diff --git a/tests/cli-durable-totals.test.ts b/tests/cli-durable-totals.test.ts index f6b51f34..f50e9415 100644 --- a/tests/cli-durable-totals.test.ts +++ b/tests/cli-durable-totals.test.ts @@ -89,8 +89,16 @@ async function seedLiveTodaySession(): Promise { 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, diff --git a/tests/cli-status-menubar.test.ts b/tests/cli-status-menubar.test.ts index 0086212d..954f2a07 100644 --- a/tests/cli-status-menubar.test.ts +++ b/tests/cli-status-menubar.test.ts @@ -268,7 +268,7 @@ describe('codeburn status --format menubar-json', () => { } finally { await rm(home, { recursive: true, force: true }) } - }) + }, 30_000) it('keeps idle Claude config options visible for the selected period', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-claude-config-idle-')) 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 211c41fa..b4dd0631 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -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') From fe8b576516412fe3c6c50153494141a1a8fc17ed Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:46:33 +0300 Subject: [PATCH 3/7] ci: scope the parallel suite to tests/, the app suite has its own harness The root vitest glob also matched app/renderer/*.test.tsx, whose jsdom environment lives in app/node_modules and cannot resolve from the root install; ERR_MODULE_NOT_FOUND took down the whole parallel step. --- .github/workflows/tests.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 72383284..f05695e8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,8 +23,12 @@ jobs: # 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 --exclude "tests/cache-refresh-lock*" + 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. - name: Cache-lock suite (serial) From 8c758ddf5a5f4dba40ab9f717a30d42a779756cb Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:51:50 +0300 Subject: [PATCH 4/7] test: file-level 30s timeout for the CLI menubar suite Every case spawns the real CLI and does genuine multi-provider parse work; run #3 showed a second sibling crossing the 5s default on the shared runner. File-level cap replaces the earlier single-test one. --- tests/cli-status-menubar.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/cli-status-menubar.test.ts b/tests/cli-status-menubar.test.ts index 954f2a07..d2186b46 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], { @@ -268,7 +273,7 @@ describe('codeburn status --format menubar-json', () => { } finally { await rm(home, { recursive: true, force: true }) } - }, 30_000) + }) it('keeps idle Claude config options visible for the selected period', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-claude-config-idle-')) From 7909af10358bce7eccb6f660783744736d4bd95d Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:56:20 +0300 Subject: [PATCH 5/7] ci: quarantine the serial cache-lock step behind continue-on-error Run #4 showed cache-refresh-lock-process racing its own takeover window even in the serial single-fork step (#904). The enforced signal stays the main suite; the lock suite reports without gating until the race semantics are settled. --- .github/workflows/tests.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f05695e8..08a24a88 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,6 +30,9 @@ jobs: - 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. - - name: Cache-lock suite (serial) + # 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 From f9669cee33dbc38095de47bbd9d141d8fe6e9dd9 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:00:39 +0300 Subject: [PATCH 6/7] test: absorb the teardown write race in the context-tree API suite server.close() only stops new connections; an in-flight fire-and-forget cache save can land a file mid-recursive-rm, surfacing as ENOTEMPTY on slower runners (run #5). fs.rm's built-in retries absorb the window. --- tests/context-tree-api-prefix.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 () => { From 05c38dfec1fce331c473ad30daf8ba8667c06dc9 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:11:25 +0300 Subject: [PATCH 7/7] test: clamp all four relative-time fixtures to the current UTC day The shared base computation guarded hours >= 2 but its h < 2 branch still subtracted five minutes past midnight, escaping into yesterday during the first five minutes of UTC hours 0 and 1 and zeroing every 'today' assertion - which is exactly when runs #6 landed. Midnight clamp replaces the guard at all four sites. --- tests/cli-status-menubar.test.ts | 36 +++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/tests/cli-status-menubar.test.ts b/tests/cli-status-menubar.test.ts index d2186b46..292385da 100644 --- a/tests/cli-status-menubar.test.ts +++ b/tests/cli-status-menubar.test.ts @@ -63,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') @@ -425,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') @@ -483,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') @@ -641,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'))