mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
os.homedir() reads USERPROFILE on Windows and never HOME, so the isolation setup and every test that moved home were still resolving the developer's real home directory. Fixtures came back empty, real sessions leaked into counts, and the code under test wrote plans, receipts and a sync ledger into the real ~/.config/codeburn during a run. USERPROFILE now joins the redirected variables and a shared setHome() helper moves HOME and USERPROFILE together; every spawn env carries both. The remaining Windows-only failures were POSIX-shaped fixtures: chmod tricks that only toggle a read-only attribute on NTFS, pid 1 as an always-alive lock holder, splitting paths on '/', bare tar, mode-bit expectations, and per-file timeouts too short for the cost of a tsx spawn. Each gets a platform-aware equivalent so nothing is skipped on Windows and the POSIX branches stay byte-for-byte what they were. Full run on Windows: 267 files passed, 2 skipped, 3592 tests passed.
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
import { execFileSync } from 'node:child_process'
|
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { createRequire } from 'node:module'
|
|
import { join } from 'node:path'
|
|
import { tmpdir } from 'node:os'
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
isSqliteReadonlyError,
|
|
openDatabase,
|
|
sqliteSupportsUriFilenames,
|
|
} from '../src/sqlite.js'
|
|
import {
|
|
discoverSqliteSessions,
|
|
type SqliteProviderConfig,
|
|
} from '../src/providers/sqlite-session-parser.js'
|
|
|
|
const requireForTest = createRequire(import.meta.url)
|
|
|
|
type NativeDatabase = {
|
|
exec(sql: string): void
|
|
prepare(sql: string): { run(...params: unknown[]): void; all(...params: unknown[]): unknown[] }
|
|
close(): void
|
|
}
|
|
|
|
type NativeDatabaseCtor = new (path: string) => NativeDatabase
|
|
|
|
const { DatabaseSync: NativeDatabase } = requireForTest('node:sqlite') as {
|
|
DatabaseSync: NativeDatabaseCtor
|
|
}
|
|
|
|
let sourceRoot: string
|
|
let cacheRoot: string
|
|
let previousCacheDir: string | undefined
|
|
const openWriters: NativeDatabase[] = []
|
|
|
|
beforeEach(async () => {
|
|
sourceRoot = await mkdtemp(join(tmpdir(), 'codeburn-sqlite-source-'))
|
|
cacheRoot = await mkdtemp(join(tmpdir(), 'codeburn-sqlite-cache-'))
|
|
previousCacheDir = process.env['CODEBURN_CACHE_DIR']
|
|
process.env['CODEBURN_CACHE_DIR'] = cacheRoot
|
|
})
|
|
|
|
afterEach(async () => {
|
|
allowDirectoryWrites(sourceRoot)
|
|
allowDirectoryWrites(cacheRoot)
|
|
for (const writer of openWriters.splice(0)) writer.close()
|
|
await rm(sourceRoot, { recursive: true, force: true })
|
|
await rm(cacheRoot, { recursive: true, force: true })
|
|
if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
|
|
else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir
|
|
})
|
|
|
|
function createClosedWalDatabase(dbPath: string): void {
|
|
const db = new NativeDatabase(dbPath)
|
|
db.exec('PRAGMA journal_mode=WAL')
|
|
db.exec('CREATE TABLE values_table (c INTEGER)')
|
|
db.prepare('INSERT INTO values_table (c) VALUES (?)').run(1)
|
|
db.close()
|
|
}
|
|
|
|
function createOpenWalDatabase(dbPath: string): NativeDatabase {
|
|
const db = new NativeDatabase(dbPath)
|
|
db.exec('PRAGMA journal_mode=WAL')
|
|
db.exec('CREATE TABLE values_table (c INTEGER)')
|
|
db.prepare('INSERT INTO values_table (c) VALUES (?)').run(1)
|
|
expect(existsSync(dbPath + '-wal')).toBe(true)
|
|
expect(existsSync(dbPath + '-shm')).toBe(true)
|
|
openWriters.push(db)
|
|
return db
|
|
}
|
|
|
|
/// A database plus a non-empty -wal and no -shm: what a snapshot, an rsync or an
|
|
/// unclean unmount of a live source leaves behind. The second row exists only in
|
|
/// the -wal, so dropping it would be silent data loss rather than an error.
|
|
function writeUncheckpointedWalDatabase(dbPath: string): void {
|
|
const originDir = join(sourceRoot, `origin-${readdirSync(sourceRoot).length}`)
|
|
mkdirSync(originDir)
|
|
const originPath = join(originDir, 'state.vscdb')
|
|
const writer = new NativeDatabase(originPath)
|
|
writer.exec('PRAGMA journal_mode=WAL')
|
|
writer.exec('CREATE TABLE values_table (c INTEGER)')
|
|
writer.prepare('INSERT INTO values_table (c) VALUES (?)').run(1)
|
|
writer.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
|
writer.exec('PRAGMA wal_autocheckpoint=0')
|
|
writer.prepare('INSERT INTO values_table (c) VALUES (?)').run(2)
|
|
copyFileSync(originPath, dbPath)
|
|
copyFileSync(originPath + '-wal', dbPath + '-wal')
|
|
writer.close()
|
|
expect(statSync(dbPath + '-wal').size).toBeGreaterThan(0)
|
|
expect(existsSync(dbPath + '-shm')).toBe(false)
|
|
}
|
|
|
|
function createDiscoveryDatabase(dbPath: string): void {
|
|
const db = new NativeDatabase(dbPath)
|
|
db.exec('PRAGMA journal_mode=WAL')
|
|
db.exec(`
|
|
CREATE TABLE session (
|
|
id TEXT PRIMARY KEY,
|
|
directory TEXT,
|
|
title TEXT,
|
|
time_created INTEGER,
|
|
parent_id TEXT,
|
|
time_archived INTEGER
|
|
)
|
|
`)
|
|
db.exec('CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data BLOB)')
|
|
db.exec('CREATE TABLE part (id INTEGER PRIMARY KEY, message_id TEXT, session_id TEXT, data BLOB)')
|
|
db.prepare(
|
|
'INSERT INTO session (id, directory, title, time_created, parent_id, time_archived) VALUES (?, ?, ?, ?, ?, ?)',
|
|
).run('session-1', '/tmp/project', 'Read-only fixture', Date.now(), null, null)
|
|
db.close()
|
|
}
|
|
|
|
const system32 = join(process.env['SystemRoot'] ?? 'C:\\Windows', 'System32')
|
|
|
|
/// Windows has no mode bits: chmod there only toggles the read-only attribute,
|
|
/// which directories ignore, so a chmod 0555 parent still accepts new files.
|
|
/// The ACL is where a Windows directory says no, and dropping inheritance in
|
|
/// favour of a read-and-execute grant for the current user is the equivalent of
|
|
/// 0555. Both halves are reversed by allowDirectoryWrites before the fixture is
|
|
/// removed, since a directory nothing may write to is also one nothing may
|
|
/// delete out of.
|
|
function denyDirectoryWrites(dir: string): void {
|
|
if (process.platform !== 'win32') {
|
|
chmodSync(dir, 0o555)
|
|
return
|
|
}
|
|
const user = execFileSync(join(system32, 'whoami.exe'), { encoding: 'utf-8' }).trim()
|
|
execFileSync(join(system32, 'icacls.exe'), [dir, '/inheritance:r', '/grant', `${user}:(OI)(CI)(RX)`], { stdio: 'ignore' })
|
|
}
|
|
|
|
function allowDirectoryWrites(dir: string): void {
|
|
if (process.platform !== 'win32') {
|
|
chmodSync(dir, 0o755)
|
|
return
|
|
}
|
|
execFileSync(join(system32, 'icacls.exe'), [dir, '/reset'], { stdio: 'ignore' })
|
|
}
|
|
|
|
/// Ask the filesystem rather than the metadata. root ignores 0555, and a
|
|
/// sandboxed or ACL-less filesystem can ignore the Windows deny, so the only
|
|
/// answer worth acting on is whether a file can actually be created.
|
|
function acceptsNewFiles(dir: string): boolean {
|
|
const probe = join(dir, '.codeburn-write-probe')
|
|
try {
|
|
writeFileSync(probe, '')
|
|
} catch {
|
|
return false
|
|
}
|
|
unlinkSync(probe)
|
|
return true
|
|
}
|
|
|
|
function makeSourceParentReadOnly(skip: (reason?: string) => void): boolean {
|
|
denyDirectoryWrites(sourceRoot)
|
|
if (acceptsNewFiles(sourceRoot)) {
|
|
allowDirectoryWrites(sourceRoot)
|
|
skip('SKIP: the fixture parent still accepts new files after being made read-only')
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function makeSourceParentWritable(): void {
|
|
allowDirectoryWrites(sourceRoot)
|
|
}
|
|
|
|
function cachedDatabaseFiles(): string[] {
|
|
try {
|
|
return readdirSync(join(cacheRoot, 'sqlite-ro')).filter(name => name.endsWith('.db'))
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function readValue(dbPath: string): number {
|
|
const db = openDatabase(dbPath)
|
|
try {
|
|
const rows = db.query<{ c: number }>('SELECT c FROM values_table')
|
|
return rows[0]?.c ?? -1
|
|
} finally {
|
|
db.close()
|
|
}
|
|
}
|
|
|
|
describe('SQLite read-only parent fallback', () => {
|
|
it('keeps the existing writable-parent open behaviour', () => {
|
|
const dbPath = join(sourceRoot, 'state.vscdb')
|
|
createClosedWalDatabase(dbPath)
|
|
expect(existsSync(dbPath + '-wal')).toBe(false)
|
|
expect(existsSync(dbPath + '-shm')).toBe(false)
|
|
|
|
expect(readValue(dbPath)).toBe(1)
|
|
|
|
expect(existsSync(dbPath + '-wal')).toBe(true)
|
|
expect(existsSync(dbPath + '-shm')).toBe(true)
|
|
expect(cachedDatabaseFiles()).toEqual([])
|
|
})
|
|
|
|
it('reads a read-only parent with no -wal, in place where it can and by copy where it cannot', ({ skip }) => {
|
|
const dbPath = join(sourceRoot, 'state.vscdb')
|
|
createClosedWalDatabase(dbPath)
|
|
expect(existsSync(dbPath + '-wal')).toBe(false)
|
|
expect(existsSync(dbPath + '-shm')).toBe(false)
|
|
if (!makeSourceParentReadOnly(skip)) return
|
|
|
|
expect(readValue(dbPath)).toBe(1)
|
|
|
|
expect(existsSync(dbPath + '-wal')).toBe(false)
|
|
expect(existsSync(dbPath + '-shm')).toBe(false)
|
|
// No WAL frames exist, so there is nothing to go stale and nothing worth
|
|
// copying: immutable reads the source in place. node:sqlite only honours
|
|
// URI filenames on newer builds, and on the 22.13 floor the copy stands in.
|
|
expect(cachedDatabaseFiles()).toHaveLength(sqliteSupportsUriFilenames() ? 0 : 1)
|
|
})
|
|
|
|
it('opens directly when a read-only parent already has WAL sidecars', ({ skip }) => {
|
|
const dbPath = join(sourceRoot, 'state.vscdb')
|
|
createOpenWalDatabase(dbPath)
|
|
if (!makeSourceParentReadOnly(skip)) return
|
|
|
|
expect(readValue(dbPath)).toBe(1)
|
|
expect(cachedDatabaseFiles()).toEqual([])
|
|
})
|
|
|
|
it('reads un-checkpointed WAL rows when the parent is read-only and the -shm is absent', ({ skip }) => {
|
|
// SQLite reports a -wal without its -shm as SQLITE_CANTOPEN, not
|
|
// SQLITE_READONLY, and the un-checkpointed row lives only in the -wal.
|
|
const dbPath = join(sourceRoot, 'state.vscdb')
|
|
writeUncheckpointedWalDatabase(dbPath)
|
|
if (!makeSourceParentReadOnly(skip)) return
|
|
|
|
const db = openDatabase(dbPath)
|
|
try {
|
|
expect(db.query<{ c: number }>('SELECT c FROM values_table ORDER BY c')).toEqual([{ c: 1 }, { c: 2 }])
|
|
} finally {
|
|
db.close()
|
|
}
|
|
expect(existsSync(dbPath + '-shm')).toBe(false)
|
|
expect(cachedDatabaseFiles()).toHaveLength(1)
|
|
})
|
|
|
|
it('reuses an unchanged fallback copy instead of copying the database again', ({ skip }) => {
|
|
const dbPath = join(sourceRoot, 'state.vscdb')
|
|
writeUncheckpointedWalDatabase(dbPath)
|
|
if (!makeSourceParentReadOnly(skip)) return
|
|
|
|
expect(readValue(dbPath)).toBe(1)
|
|
const first = cachedDatabaseFiles()
|
|
expect(first).toHaveLength(1)
|
|
const cachedPath = join(cacheRoot, 'sqlite-ro', first[0]!)
|
|
const firstIno = statSync(cachedPath).ino
|
|
expect(readValue(dbPath)).toBe(1)
|
|
expect(cachedDatabaseFiles()).toEqual(first)
|
|
expect(statSync(cachedPath).ino).toBe(firstIno)
|
|
})
|
|
|
|
it('publishes a refreshed copy beside the old one and keeps at most one predecessor', ({ skip }) => {
|
|
const dbPath = join(sourceRoot, 'state.vscdb')
|
|
writeUncheckpointedWalDatabase(dbPath)
|
|
if (!makeSourceParentReadOnly(skip)) return
|
|
expect(readValue(dbPath)).toBe(1)
|
|
const [first] = cachedDatabaseFiles()
|
|
const firstIno = statSync(join(cacheRoot, 'sqlite-ro', first!)).ino
|
|
|
|
// A changed source must not overwrite the copy a concurrent reader may still
|
|
// have open: Windows cannot unlink it, and the name is the fingerprint.
|
|
makeSourceParentWritable()
|
|
writeUncheckpointedWalDatabase(dbPath)
|
|
if (!makeSourceParentReadOnly(skip)) return
|
|
expect(readValue(dbPath)).toBe(1)
|
|
const second = cachedDatabaseFiles()
|
|
expect(second).toHaveLength(2)
|
|
expect(second).toContain(first)
|
|
expect(statSync(join(cacheRoot, 'sqlite-ro', first!)).ino).toBe(firstIno)
|
|
|
|
makeSourceParentWritable()
|
|
writeUncheckpointedWalDatabase(dbPath)
|
|
if (!makeSourceParentReadOnly(skip)) return
|
|
expect(readValue(dbPath)).toBe(1)
|
|
const third = cachedDatabaseFiles()
|
|
expect(third).toHaveLength(2)
|
|
expect(third).not.toContain(first)
|
|
})
|
|
|
|
it('evicts a copy left untouched for a day, including one whose source is gone', ({ skip }) => {
|
|
const dbPath = join(sourceRoot, 'state.vscdb')
|
|
writeUncheckpointedWalDatabase(dbPath)
|
|
if (!makeSourceParentReadOnly(skip)) return
|
|
expect(readValue(dbPath)).toBe(1)
|
|
const cacheDir = join(cacheRoot, 'sqlite-ro')
|
|
const predecessor = join(cacheDir, cachedDatabaseFiles()[0]!)
|
|
|
|
// A copy of a database that no longer exists is simply one nothing touches.
|
|
const orphan = join(cacheDir, `${'0'.repeat(32)}.deadbeefdeadbeef.db`)
|
|
writeFileSync(orphan, 'orphan')
|
|
const aDayAndAnHourAgo = new Date(Date.now() - 25 * 60 * 60 * 1000)
|
|
utimesSync(orphan, aDayAndAnHourAgo, aDayAndAnHourAgo)
|
|
|
|
makeSourceParentWritable()
|
|
writeUncheckpointedWalDatabase(dbPath)
|
|
if (!makeSourceParentReadOnly(skip)) return
|
|
expect(readValue(dbPath)).toBe(1)
|
|
expect(existsSync(orphan)).toBe(false)
|
|
expect(existsSync(predecessor)).toBe(true)
|
|
|
|
// A day without a read and the superseded copy goes too.
|
|
utimesSync(predecessor, aDayAndAnHourAgo, aDayAndAnHourAgo)
|
|
expect(readValue(dbPath)).toBe(1)
|
|
|
|
expect(existsSync(predecessor)).toBe(false)
|
|
expect(cachedDatabaseFiles()).toHaveLength(1)
|
|
})
|
|
|
|
it('says so instead of going quiet when the cache copy cannot be written', ({ skip }) => {
|
|
const dbPath = join(sourceRoot, 'state.vscdb')
|
|
writeUncheckpointedWalDatabase(dbPath)
|
|
if (!makeSourceParentReadOnly(skip)) return
|
|
denyDirectoryWrites(cacheRoot)
|
|
if (acceptsNewFiles(cacheRoot)) {
|
|
allowDirectoryWrites(cacheRoot)
|
|
skip('SKIP: the cache root still accepts new files after being made read-only')
|
|
return
|
|
}
|
|
|
|
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
|
try {
|
|
expect(() => readValue(dbPath)).toThrow()
|
|
const notices = stderr.mock.calls.filter(([chunk]) => String(chunk).includes('cache copy could not be written'))
|
|
expect(notices).toHaveLength(1)
|
|
expect(String(notices[0]?.[0])).toContain(dbPath)
|
|
} finally {
|
|
stderr.mockRestore()
|
|
allowDirectoryWrites(cacheRoot)
|
|
}
|
|
})
|
|
|
|
it('keeps a genuinely missing database distinguishable from SQLITE_READONLY', () => {
|
|
let thrown: unknown
|
|
try {
|
|
openDatabase(join(sourceRoot, 'missing.vscdb'))
|
|
} catch (err) {
|
|
thrown = err
|
|
}
|
|
|
|
expect(thrown).toBeDefined()
|
|
expect(isSqliteReadonlyError(thrown)).toBe(false)
|
|
expect(thrown).toMatchObject({ errcode: 14, message: 'unable to open database file' })
|
|
})
|
|
|
|
it('surfaces one read-only notice in SQLite discovery and still finds the session', async ({ skip }) => {
|
|
const dbPath = join(sourceRoot, 'state.db')
|
|
createDiscoveryDatabase(dbPath)
|
|
if (!makeSourceParentReadOnly(skip)) return
|
|
|
|
const config: SqliteProviderConfig = {
|
|
providerName: 'opencode',
|
|
displayName: 'OpenCode',
|
|
dbDir: sourceRoot,
|
|
dbFilePrefix: 'state',
|
|
}
|
|
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
|
try {
|
|
const sessions = await discoverSqliteSessions(config)
|
|
expect(sessions).toHaveLength(1)
|
|
expect(sessions[0]?.path).toBe(`${dbPath}:session-1`)
|
|
expect(stderr.mock.calls.filter(([chunk]) => String(chunk).includes('read-only directory'))).toHaveLength(1)
|
|
|
|
await discoverSqliteSessions(config)
|
|
expect(stderr.mock.calls.filter(([chunk]) => String(chunk).includes('read-only directory'))).toHaveLength(1)
|
|
} finally {
|
|
stderr.mockRestore()
|
|
}
|
|
})
|
|
})
|