Merge pull request #901 from ozymandiashh/ci/tests-job

ci: run tsc and the vitest suite on pull requests
This commit is contained in:
Resham Joshi 2026-08-04 05:15:27 -07:00 committed by GitHub
commit 668f9a8abb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 113 additions and 41 deletions

38
.github/workflows/tests.yml vendored Normal file
View file

@ -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

View file

@ -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,

View file

@ -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], {
@ -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'))

View file

@ -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 () => {

View file

@ -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

View file

@ -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', () => {

View file

@ -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')

View file

@ -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,
},
})