codeburn/tests/providers/cursor.test.ts
Resham Joshi 151509a1fb
fix(cache-lock): coalesce heartbeat ticks; make time-sensitive tests deterministic (#1559)
* fix(cache-lock): coalesce heartbeat ticks so the fence never waits behind a backlog

Each interval tick queued a full heartbeat in the owner serializer, so an
interval firing faster than a tick completes grew the queue without bound and
verifyStillOwner waited behind all of it. The 1ms-heartbeat fence test hit its
30s timeout on a loaded runner and retried up to 10 times, which is the silent
cache-lock suite hang in CI.

* test(menubar): hold off the helper's SIGTERM until the stand-in app records its status

The relaunch helper signals the app as soon as the CLI child is reaped, which
could land between the stand-in's fopen and fclose and leave an empty status
file. Also wait for the recorder's full line instead of a fixed sleep.

* test: pin day-boundary tests so they pass at any time of day

CLI children that query "today" now run in a fixed-offset zone where it is
about noon on the UTC date (tests/fixtures/noon-tz.ts), so fixtures a little in
the past are always today; the old clamp to UTC midnight put them in the future
for the first minutes of the day and zeroed the counts. The month-scoped budget
and project-filter fixtures get the same treatment (the latter also failed on
days 1-5 of every month), the cursor lookback case derives its start from now
instead of a date that ages out on 1 Oct, and the renderer tests that format a
stamp from a few hours back pin the clock to mid-day. The session drawer date
is formatted in the local zone, so its expectation is too.

* test: derive dated fixtures from the clock so rolling windows never age them out

The six-month --period all window and the sync ledger's six-month prune
dropped fixtures fixed at April-July 2026 from 1 Nov 2026 on. CLI fixtures now
sit 30 days back (utcDaysAgo), the ledger and push tests pin Date to July 2026,
the --to-only range uses yesterday, and the date-picker tests pin mid-month so
two days back is on the calendar they open (they failed on the 1st and 2nd of
every month). The noon-tz helper moves to tests/fixtures/clock.ts.

* test(desktop): give spawned stand-ins room to boot before the silence window

The watchdog and graceful-kill tests used 1-1.5s silence windows, and the
window before a child's first byte includes node's boot. On a loaded machine
the child was signalled before it installed its handler or wrote its byte.
The windows are now 3s and the last-byte test measures from the byte the child
stamps. The killAll and fallback-scheduler tests wait on their condition with
a 15s deadline instead of a 2-3s one.
2026-09-25 05:06:43 +02:00

200 lines
8.1 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { createRequire } from 'node:module'
import { getAllProviders } from '../../src/providers/index.js'
import { getCursorTimeFloor, createCursorProvider, clearCursorWorkspaceMapCache } from '../../src/providers/cursor.js'
import { isSqliteAvailable } from '../../src/sqlite.js'
import type { Provider } from '../../src/providers/types.js'
describe('cursor provider', () => {
let cursorProvider: Provider
beforeEach(async () => {
const all = await getAllProviders()
cursorProvider = all.find(p => p.name === 'cursor')!
})
it('is registered', () => {
expect(cursorProvider).toBeDefined()
expect(cursorProvider.name).toBe('cursor')
expect(cursorProvider.displayName).toBe('Cursor')
})
describe('model display names', () => {
it('maps cursor-auto to Cursor (auto) label', () => {
expect(cursorProvider.modelDisplayName('cursor-auto')).toBe('Cursor (auto)')
})
it('maps known models to readable names', () => {
expect(cursorProvider.modelDisplayName('claude-4.5-opus-high-thinking')).toBe('Opus 4.5 (Thinking)')
expect(cursorProvider.modelDisplayName('claude-4-sonnet-thinking')).toBe('Sonnet 4 (Thinking)')
expect(cursorProvider.modelDisplayName('grok-code-fast-1')).toBe('Grok Code Fast')
expect(cursorProvider.modelDisplayName('gemini-3-pro')).toBe('Gemini 3 Pro')
expect(cursorProvider.modelDisplayName('gpt-5')).toBe('GPT-5')
expect(cursorProvider.modelDisplayName('composer-1')).toBe('Composer 1')
})
it('returns raw name for unknown models', () => {
expect(cursorProvider.modelDisplayName('some-future-model')).toBe('some-future-model')
})
})
describe('tool display names', () => {
it('returns raw tool name as identity', () => {
expect(cursorProvider.toolDisplayName('some_tool')).toBe('some_tool')
})
})
describe('time floor', () => {
it('uses dateRange.start when within the six-month cap', () => {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth() - 2, 1)
expect(getCursorTimeFloor({ start, end: now })).toBe(start.toISOString())
})
})
describe('session discovery', () => {
it('returns empty when sqlite is not available', async () => {
const sessions = await cursorProvider.discoverSessions()
expect(Array.isArray(sessions)).toBe(true)
})
it('returns empty when db does not exist', async () => {
const sessions = await cursorProvider.discoverSessions()
expect(sessions.every(s => s.provider === 'cursor')).toBe(true)
})
})
})
describe('cursor sqlite adapter', () => {
it('reports availability', async () => {
const { isSqliteAvailable } = await import('../../src/sqlite.js')
const available = isSqliteAvailable()
expect(typeof available).toBe('boolean')
})
it('provides error message when not available', async () => {
const { getSqliteLoadError } = await import('../../src/sqlite.js')
const error = getSqliteLoadError()
expect(typeof error).toBe('string')
expect(error.length).toBeGreaterThan(0)
})
})
describe('cursor cache', () => {
it('returns null when no cache exists', async () => {
const { readCachedResults } = await import('../../src/cursor-cache.js')
const result = await readCachedResults('/nonexistent/path.db', new Date(0).toISOString())
expect(result).toBeNull()
})
it('honors CODEBURN_CACHE_DIR at call time', async () => {
const root = await mkdtemp(join(tmpdir(), 'cursor-cache-override-'))
const previousCacheDir = process.env['CODEBURN_CACHE_DIR']
const dbPath = join(root, 'state.vscdb')
const firstCacheDir = join(root, 'cache-a')
const secondCacheDir = join(root, 'cache-b')
const firstFloor = '2026-01-01T00:00:00.000Z'
const secondFloor = '2026-02-01T00:00:00.000Z'
await writeFile(dbPath, 'cursor-db-fixture')
try {
const { writeCachedResults } = await import('../../src/cursor-cache.js')
process.env['CODEBURN_CACHE_DIR'] = firstCacheDir
await writeCachedResults(dbPath, [], firstFloor)
process.env['CODEBURN_CACHE_DIR'] = secondCacheDir
await writeCachedResults(dbPath, [], secondFloor)
const { cursorCacheFileName } = await import('../../src/cursor-cache.js')
const firstPath = join(firstCacheDir, cursorCacheFileName())
const secondPath = join(secondCacheDir, cursorCacheFileName())
const first = JSON.parse(await readFile(firstPath, 'utf-8')) as { lookbackFloor: string }
const second = JSON.parse(await readFile(secondPath, 'utf-8')) as { lookbackFloor: string }
expect(first.lookbackFloor).toBe(firstFloor)
expect(second.lookbackFloor).toBe(secondFloor)
} finally {
if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir
await rm(root, { recursive: true, force: true })
}
})
})
// Regression: Cursor renamed the per-workspace composer list key from
// 'composer.composerData' to 'composer.composerHeaders'. loadWorkspaceMap must
// read both, otherwise every composer orphans into the 'cursor' catch-all and
// per-project attribution is lost.
describe('cursor workspace mapping (composer.composerHeaders regression)', () => {
const requireForTest = createRequire(import.meta.url)
type TestDb = {
exec(sql: string): void
prepare(sql: string): { run(...params: unknown[]): void }
close(): void
}
let root: string
function writeItemTableDb(dbPath: string, key: string, composerIds: string[]): void {
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (p: string) => TestDb }
const db = new DatabaseSync(dbPath)
db.exec('CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)')
if (key) {
const value = JSON.stringify({ allComposers: composerIds.map(composerId => ({ composerId })) })
db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run(key, value)
}
db.close()
}
async function makeWorkspace(hash: string, folderUri: string, key: string, composerIds: string[]): Promise<void> {
const wsDir = join(root, 'User', 'workspaceStorage', hash)
await mkdir(wsDir, { recursive: true })
await writeFile(join(wsDir, 'workspace.json'), JSON.stringify({ folder: folderUri }))
writeItemTableDb(join(wsDir, 'state.vscdb'), key, composerIds)
}
async function makeGlobalDb(): Promise<string> {
const gsDir = join(root, 'User', 'globalStorage')
await mkdir(gsDir, { recursive: true })
const dbPath = join(gsDir, 'state.vscdb')
// discoverSessions only needs the global DB to exist; the workspace map is
// built from the sibling workspaceStorage dir.
writeItemTableDb(dbPath, '', [])
return dbPath
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'cursor-ws-test-'))
clearCursorWorkspaceMapCache()
})
afterEach(async () => {
clearCursorWorkspaceMapCache()
await rm(root, { recursive: true, force: true })
})
it.skipIf(!isSqliteAvailable())(
'maps composers to their workspace via composer.composerHeaders (new Cursor key)',
async () => {
await makeWorkspace('ws-headers', 'file:///home/user/myapp', 'composer.composerHeaders', ['comp-1', 'comp-2'])
const dbPath = await makeGlobalDb()
const sources = await createCursorProvider(dbPath).discoverSessions()
const projects = sources.map(s => s.project)
// Before the fix these composers orphaned to the 'cursor' catch-all.
expect(projects).toContain('-home-user-myapp')
},
)
it.skipIf(!isSqliteAvailable())(
'still maps composers via the legacy composer.composerData key',
async () => {
await makeWorkspace('ws-legacy', 'file:///home/user/legacy', 'composer.composerData', ['old-1'])
const dbPath = await makeGlobalDb()
const sources = await createCursorProvider(dbPath).discoverSessions()
expect(sources.map(s => s.project)).toContain('-home-user-legacy')
},
)
})