codeburn/tests/fs-utils.test.ts
iamtoruk bd41fa3962
Some checks are pending
CI / semgrep (push) Waiting to run
Add persistent disk cache for parsed session data
Cache normalized turns/calls to ~/.cache/codeburn/session-cache.json so
the CLI skips re-parsing unchanged JSONL files on subsequent runs.
File reconciliation uses dev+ino+mtime+size fingerprinting; cost,
classification, and summaries are recomputed at query time. Atomic
writes via temp+fsync+rename, deep structural validation on load,
per-provider env fingerprinting, and best-effort save so cache failures
never break the CLI. ~6x speedup on warm cache.
2026-05-16 01:04:13 -07:00

172 lines
6.3 KiB
TypeScript

import { describe, it, expect, afterEach, vi } from 'vitest'
import { mkdtemp, writeFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import {
MAX_SESSION_FILE_BYTES,
readSessionFile,
readSessionLines,
} from '../src/fs-utils.js'
describe('readSessionFile', () => {
const tmpDirs: string[] = []
afterEach(async () => {
delete process.env.CODEBURN_VERBOSE
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function tmpPath(content: string | Buffer): Promise<string> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-fs-'))
tmpDirs.push(base)
const p = join(base, 'x.jsonl')
await writeFile(p, content)
return p
}
it('returns content for small files via readFile fast path', async () => {
const p = await tmpPath('hello\nworld\n')
expect(await readSessionFile(p)).toBe('hello\nworld\n')
})
it('returns content for large files under the full-file cap', async () => {
const size = 8 * 1024 * 1024
const p = await tmpPath(Buffer.alloc(size, 'a'))
const got = await readSessionFile(p)
expect(got).not.toBeNull()
expect(got!.length).toBe(size)
})
it('returns null and skips files over the cap', async () => {
const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'b'))
expect(await readSessionFile(p)).toBeNull()
})
it('emits stderr warning under CODEBURN_VERBOSE=1 for skipped file', async () => {
process.env.CODEBURN_VERBOSE = '1'
const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'c'))
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
await readSessionFile(p)
expect(spy).toHaveBeenCalled()
const msg = (spy.mock.calls[0][0] as string)
expect(msg).toContain('codeburn')
expect(msg).toContain('oversize')
spy.mockRestore()
})
it('returns null on stat failure without throwing', async () => {
expect(await readSessionFile('/nonexistent/path/x.jsonl')).toBeNull()
})
})
describe('readSessionLines', () => {
const tmpDirs: string[] = []
afterEach(async () => {
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function tmpPath(content: string): Promise<string> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-lines-'))
tmpDirs.push(base)
const p = join(base, 'session.jsonl')
await writeFile(p, content)
return p
}
it('yields all lines from a file', async () => {
const p = await tmpPath('line1\nline2\nline3\n')
const lines: string[] = []
for await (const line of readSessionLines(p)) lines.push(line)
expect(lines).toEqual(['line1', 'line2', 'line3'])
})
it('skips old large lines before materializing the full line', async () => {
const oldLine = `{"type":"assistant","timestamp":"2026-01-01T00:00:00Z","payload":"${'x'.repeat(100_000)}"}`
const newLine = '{"type":"assistant","timestamp":"2026-05-01T00:00:00Z"}'
const p = await tmpPath(`${oldLine}\n${newLine}\n`)
const lines: string[] = []
for await (const line of readSessionLines(p, head => head.includes('2026-01-01'))) {
lines.push(line)
}
expect(lines).toEqual([newLine])
})
it('yields large lines as Buffers when requested', async () => {
const largeLine = `{"type":"assistant","timestamp":"2026-05-01T00:00:00Z","payload":"${'x'.repeat(100_000)}"}`
const p = await tmpPath(`${largeLine}\nsmall\n`)
const lines: Array<string | Buffer> = []
for await (const line of readSessionLines(p, undefined, { largeLineAsBuffer: true })) {
lines.push(line)
}
expect(Buffer.isBuffer(lines[0])).toBe(true)
expect(lines[1]).toBe('small')
})
it('does not leak file descriptors when generator is abandoned early', async () => {
const content = Array.from({ length: 1000 }, (_, i) => `line-${i}`).join('\n')
const p = await tmpPath(content)
const gen = readSessionLines(p)
await gen.next()
await gen.return(undefined)
})
it('reads from startByteOffset, yielding only lines after the offset', async () => {
const content = 'line1\nline2\nline3\n'
const p = await tmpPath(content)
const offset = Buffer.byteLength('line1\n')
const lines: string[] = []
for await (const line of readSessionLines(p, undefined, { startByteOffset: offset })) {
lines.push(line)
}
expect(lines).toEqual(['line2', 'line3'])
})
it('byteOffsetTracker tracks position after last complete newline', async () => {
const content = 'aaa\nbbb\nccc\n'
const p = await tmpPath(content)
const tracker = { lastCompleteLineOffset: 0 }
const lines: string[] = []
for await (const line of readSessionLines(p, undefined, { byteOffsetTracker: tracker })) {
lines.push(line)
}
expect(lines).toEqual(['aaa', 'bbb', 'ccc'])
expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength(content))
})
it('byteOffsetTracker accounts for startByteOffset', async () => {
const content = 'line1\nline2\nline3\n'
const p = await tmpPath(content)
const offset = Buffer.byteLength('line1\n')
const tracker = { lastCompleteLineOffset: 0 }
for await (const _line of readSessionLines(p, undefined, { startByteOffset: offset, byteOffsetTracker: tracker })) {}
expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength(content))
})
it('byteOffsetTracker excludes trailing partial line (no final newline)', async () => {
const content = 'line1\nline2\npartial'
const p = await tmpPath(content)
const tracker = { lastCompleteLineOffset: 0 }
for await (const _line of readSessionLines(p, undefined, { byteOffsetTracker: tracker })) {}
expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength('line1\nline2\n'))
})
it('byteOffsetTracker updates for skipped lines too', async () => {
const content = 'skip-me\nkeep-me\n'
const p = await tmpPath(content)
const tracker = { lastCompleteLineOffset: 0 }
const lines: string[] = []
for await (const line of readSessionLines(p, head => head.includes('skip-me'), { byteOffsetTracker: tracker })) {
lines.push(line)
}
expect(lines).toEqual(['keep-me'])
expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength(content))
})
})