mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-11 01:25:03 +00:00
Merge pull request #901 from ozymandiashh/ci/tests-job
ci: run tsc and the vitest suite on pull requests
This commit is contained in:
commit
668f9a8abb
8 changed files with 113 additions and 41 deletions
38
.github/workflows/tests.yml
vendored
Normal file
38
.github/workflows/tests.yml
vendored
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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…
Add table
Add a link
Reference in a new issue