mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-27 17:43:52 +00:00
* 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.
168 lines
7.5 KiB
TypeScript
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
|
|
}
|
|
})
|
|
})
|