codeburn/tests/cursor-sync-background.test.ts
Resham Joshi 247957ff91
perf(cursor): stop JSON and menubar answers waiting on cursor.com (#1724)
* perf(cursor): stop JSON and menubar answers waiting on cursor.com

report/today/month --format json, overview and status --format menubar-json
awaited the hourly Cursor usage download (15 s cap, 45 s on the first
backfill) before parsing. The download now runs beside the command and its
import applies after the command (serve: between two requests, then its
memos are dropped), so the next run shows it. Dashboards still wait.

* test(cursor-sync): keep the in-process sync enabled when the shell disables it

* test(cursor-sync): wait for the serve memo instead of a fixed answer count

The serve test expected the second answer to be memoized, but serve only
memoizes once its root watchers are armed, which on a slow CI runner lands
after that answer. Keep asking until two answers share a generation. The
spawn now imports the fake fetch by file URL so Windows accepts the path,
and the in-process test points USERPROFILE at the temp home too.
2026-10-10 07:24:37 +02:00

168 lines
7.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawn, type ChildProcess } from 'child_process'
import { existsSync, mkdirSync } from 'fs'
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { createRequire } from 'module'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
import { pathToFileURL } from 'url'
import { CURSOR_CSV_HEADER, cursorImportPath } from '../src/cursor-import.js'
import { maybeSyncCursor, setCursorImportRunner } from '../src/cursor-sync.js'
import { cursorDatabasePath } from '../src/quota/cursor.js'
import { isSqliteAvailable } from '../src/sqlite.js'
// The CLI runs that hold a fake cursor.com download open until the test lets go.
vi.setConfig({ testTimeout: 60_000 })
const requireForTest = createRequire(import.meta.url)
const CLI = join(__dirname, '..', 'src', 'cli.ts')
function jwt(): string {
const encode = (value: unknown) => Buffer.from(JSON.stringify(value), 'utf8').toString('base64url')
return `${encode({ alg: 'HS256' })}.${encode({ sub: 'auth0|background_sync', exp: Math.floor(Date.now() / 1000) + 86_400 })}.signature`
}
function writeToken(home: string): void {
const path = cursorDatabasePath(process.platform, home)
mkdirSync(dirname(path), { recursive: true })
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (file: string) => any }
const db = new DatabaseSync(path)
db.exec('CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)')
db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run('cursorAuth/accessToken', jwt())
db.close()
}
function csv(): string {
const row = [new Date(Date.now() - 2 * 3_600_000).toISOString(), '', '', 'Included', 'auto', 'No', 0, 1234, 0, 0, 1234, 'Included']
return [CURSOR_CSV_HEADER.join(','), row.map(v => `"${v}"`).join(',')].join('\n') + '\n'
}
// A cursor.com that answers only once `release` exists, so a run that waits
// on it cannot print anything before the test lets go.
const FAKE_FETCH = `
import { existsSync, readFileSync } from 'node:fs'
const realFetch = globalThis.fetch
globalThis.fetch = async (url, init) => {
if (!String(url).startsWith('https://cursor.com/')) return realFetch(url, init)
while (!existsSync(process.env.FAKE_CURSOR_RELEASE)) await new Promise(r => setTimeout(r, 25))
return new Response(readFileSync(process.env.FAKE_CURSOR_CSV, 'utf8'), { status: 200 })
}
`
const until = async (check: () => boolean, label: string): Promise<void> => {
const deadline = Date.now() + 30_000
while (!check()) {
if (Date.now() > deadline) throw new Error(`timed out waiting for ${label}`)
await new Promise(r => setTimeout(r, 25))
}
}
let root: string
let release: string
let env: NodeJS.ProcessEnv
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'cursor-sync-bg-'))
const home = join(root, 'home')
writeToken(home)
// One provider root for serve to watch; without an armed root it memoizes nothing.
mkdirSync(join(home, '.claude', 'projects'), { recursive: true })
release = join(root, 'release')
await writeFile(join(root, 'fake-fetch.mjs'), FAKE_FETCH)
await writeFile(join(root, 'usage.csv'), csv())
env = {
...process.env,
HOME: home,
USERPROFILE: home,
CODEBURN_CACHE_DIR: join(root, 'cache'),
FAKE_CURSOR_RELEASE: release,
FAKE_CURSOR_CSV: join(root, 'usage.csv'),
}
delete env['CODEBURN_CURSOR_SYNC']
// The in-process sync reads this too; a shell that disables sync must not hang the test.
vi.stubEnv('CODEBURN_CURSOR_SYNC', '')
process.env['CODEBURN_CACHE_DIR'] = join(root, 'cache')
})
afterEach(async () => {
vi.unstubAllEnvs()
setCursorImportRunner(null)
await rm(root, { recursive: true, force: true })
})
function start(args: string[]): { child: ChildProcess; stdout: () => string; exited: Promise<number | null> } {
const child = spawn(process.execPath, ['--import', 'tsx', '--import', pathToFileURL(join(root, 'fake-fetch.mjs')).href, CLI, ...args], { env, stdio: ['pipe', 'pipe', 'ignore'] })
let out = ''
child.stdout!.on('data', (chunk: Buffer) => { out += chunk.toString() })
return { child, stdout: () => out, exited: new Promise(resolve => child.on('exit', resolve)) }
}
const cursorCalls = (report: string): number => {
const parsed = JSON.parse(report) as { overview: { calls: number } }
return parsed.overview.calls
}
describe.skipIf(!isSqliteAvailable())('background Cursor sync', () => {
it('a deferred import waits for the runner, then lands', async () => {
let applyLater: (() => void) | undefined
setCursorImportRunner(apply => new Promise((resolve, reject) => { applyLater = () => { apply().then(resolve, reject) } }))
vi.stubEnv('HOME', env['HOME']!)
vi.stubEnv('USERPROFILE', env['HOME']!)
const fetchImpl = async () => new Response(csv(), { status: 200 })
const sync = maybeSyncCursor({ fetchImpl, deferImport: true })
await until(() => applyLater !== undefined, 'the runner')
expect(existsSync(cursorImportPath())).toBe(false)
applyLater!()
expect((await sync)?.changed).toBe(true)
expect(existsSync(cursorImportPath())).toBe(true)
})
it('a one-shot JSON report answers before cursor.com does, and the next run shows the import', async () => {
const args = ['report', '--format', 'json', '--provider', 'cursor', '--period', 'today']
const first = start(args)
await until(() => first.stdout().trim().endsWith('}'), 'the first answer')
expect(cursorCalls(first.stdout())).toBe(0)
expect(existsSync(cursorImportPath())).toBe(false)
await writeFile(release, '')
expect(await first.exited).toBe(0)
expect(existsSync(cursorImportPath())).toBe(true)
const second = start(args)
expect(await second.exited).toBe(0)
expect(cursorCalls(second.stdout())).toBe(1)
})
it('serve applies the import between requests and stops serving the answer it memoized', async () => {
const serve = start(['serve', '--stdio'])
let lines: string[] = []
serve.child.stdout!.on('data', () => { lines = serve.stdout().split('\n').filter(Boolean) })
const answer = async (id: number, args: string[]): Promise<{ output: string; generation: { n: number } }> => {
serve.child.stdin!.write(JSON.stringify({ id, args }) + '\n')
let found: { id?: number; ok?: boolean; output?: string; generation?: { n: number } } | undefined
await until(() => (found = lines.map(l => JSON.parse(l)).find(m => m.id === id && m.ok !== undefined)) !== undefined, `answer ${id}`)
expect(found!.ok).toBe(true)
return found as { output: string; generation: { n: number } }
}
const args = ['report', '--format', 'json', '--provider', 'cursor', '--period', 'today']
try {
await until(() => lines.some(l => l.includes('"ready"')), 'serve ready')
expect(cursorCalls((await answer(1, args)).output)).toBe(0)
// Asked again before the download lands: once the watchers are armed, which
// can be after the first few answers on a slow machine, the memo answers.
let id = 2
let memoized = await answer(id++, args)
for (let next = await answer(id++, args); next.generation.n !== memoized.generation.n; next = await answer(id++, args)) memoized = next
await writeFile(release, '')
await until(() => existsSync(cursorImportPath()), 'the import')
const after = await answer(id, args)
expect(after.generation.n).toBeGreaterThan(memoized.generation.n)
expect(cursorCalls(after.output)).toBe(1)
} finally {
serve.child.stdin!.end()
await serve.exited
}
})
})