mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 03:05:24 +00:00
* 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.
266 lines
8.8 KiB
TypeScript
266 lines
8.8 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { spawnSync } from 'node:child_process'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { utcDaysAgo } from './fixtures/clock.js'
|
|
|
|
// node --import takes a module specifier, and an absolute Windows path parses as
|
|
// a URL with scheme 'c:', so the loader rejects it. A file:// URL works on both.
|
|
const DISCOVERY_MOCK_REGISTER = pathToFileURL(join(process.cwd(), 'tests', 'fixtures', 'mock-discovery-register.mjs')).href
|
|
|
|
const bonjourMock = vi.hoisted(() => ({
|
|
destroy: vi.fn((cb: () => void) => cb()),
|
|
find: vi.fn(),
|
|
mdnsOn: vi.fn(),
|
|
stop: vi.fn(),
|
|
serviceCallback: undefined as ((service: {
|
|
txt?: Record<string, string>
|
|
addresses?: string[]
|
|
host?: string
|
|
name?: string
|
|
port: number
|
|
}) => void) | undefined,
|
|
errorCallback: undefined as ((err?: unknown) => void) | undefined,
|
|
}))
|
|
|
|
vi.mock('bonjour-service', () => ({
|
|
Bonjour: class {
|
|
server = {
|
|
mdns: {
|
|
on: bonjourMock.mdnsOn.mockImplementation((_event: string, cb: (err?: unknown) => void) => {
|
|
bonjourMock.errorCallback = cb
|
|
}),
|
|
},
|
|
}
|
|
|
|
destroy = bonjourMock.destroy
|
|
|
|
find(_opts: unknown, cb: NonNullable<typeof bonjourMock.serviceCallback>) {
|
|
bonjourMock.serviceCallback = cb
|
|
bonjourMock.find(_opts, cb)
|
|
return { stop: bonjourMock.stop }
|
|
}
|
|
},
|
|
}))
|
|
|
|
function runCli(args: string[], home: string, opts: { mockDiscovery?: boolean } = {}) {
|
|
const nodeArgs = ['--import', 'tsx']
|
|
if (opts.mockDiscovery) nodeArgs.push('--import', DISCOVERY_MOCK_REGISTER)
|
|
return spawnSync(process.execPath, [...nodeArgs, 'src/cli.ts', ...args], {
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
CLAUDE_CONFIG_DIR: join(home, '.claude'),
|
|
CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'),
|
|
HOME: home, USERPROFILE: home,
|
|
TZ: 'UTC',
|
|
},
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
})
|
|
}
|
|
|
|
function userLine(sessionId: string, timestamp: string): string {
|
|
return JSON.stringify({
|
|
type: 'user',
|
|
sessionId,
|
|
timestamp,
|
|
message: { role: 'user', content: 'check usage' },
|
|
})
|
|
}
|
|
|
|
function assistantLine(sessionId: string, timestamp: string, messageId: string): string {
|
|
return JSON.stringify({
|
|
type: 'assistant',
|
|
sessionId,
|
|
timestamp,
|
|
message: {
|
|
id: messageId,
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: 'claude-sonnet-4-5',
|
|
content: [{ type: 'text', text: 'done' }],
|
|
usage: { input_tokens: 1000, output_tokens: 100 },
|
|
},
|
|
})
|
|
}
|
|
|
|
describe('sharing discovery', () => {
|
|
it('logs mDNS errors and returns devices found before the error', async () => {
|
|
vi.resetModules()
|
|
bonjourMock.destroy.mockClear()
|
|
bonjourMock.find.mockClear()
|
|
bonjourMock.mdnsOn.mockClear()
|
|
bonjourMock.stop.mockClear()
|
|
bonjourMock.serviceCallback = undefined
|
|
bonjourMock.errorCallback = undefined
|
|
const stderr = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
try {
|
|
const { browse } = await import('../src/sharing/discovery.js')
|
|
const pending = browse(10_000)
|
|
bonjourMock.serviceCallback?.({
|
|
txt: { fp: 'fixture-fingerprint', dn: 'Fixture Mac' },
|
|
addresses: ['192.168.1.25'],
|
|
port: 7777,
|
|
})
|
|
bonjourMock.errorCallback?.(new Error('bind EPERM'))
|
|
|
|
await expect(pending).resolves.toEqual([
|
|
{
|
|
name: 'Fixture Mac',
|
|
host: '192.168.1.25',
|
|
port: 7777,
|
|
fingerprint: 'fixture-fingerprint',
|
|
},
|
|
])
|
|
expect(stderr).toHaveBeenCalledWith('codeburn devices scan: mDNS discovery failed: bind EPERM')
|
|
} finally {
|
|
stderr.mockRestore()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('devices/share/identity JSON CLI output', () => {
|
|
it('devices --format json returns CombinedUsage with the local device', async () => {
|
|
const home = await mkdtemp(join(tmpdir(), 'codeburn-devices-json-'))
|
|
|
|
try {
|
|
const projectDir = join(home, '.claude', 'projects', 'app')
|
|
await mkdir(projectDir, { recursive: true })
|
|
await writeFile(
|
|
join(projectDir, 'session.jsonl'),
|
|
[
|
|
userLine('s1', `${utcDaysAgo(30)}T09:00:00Z`),
|
|
assistantLine('s1', `${utcDaysAgo(30)}T09:01:00Z`, 'msg-1'),
|
|
].join('\n'),
|
|
)
|
|
|
|
const result = runCli(['devices', '--format', 'json', '--period', 'all'], home)
|
|
|
|
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
|
const payload = JSON.parse(result.stdout) as {
|
|
perDevice: Array<{ id: string; name: string; local: boolean; cost: number; calls: number; sessions: number }>
|
|
combined: { cost: number; calls: number; sessions: number; deviceCount: number; reachableCount: number }
|
|
}
|
|
expect(payload.perDevice).toHaveLength(1)
|
|
expect(payload.perDevice[0]).toMatchObject({
|
|
id: 'local',
|
|
local: true,
|
|
calls: 1,
|
|
sessions: 1,
|
|
})
|
|
expect(payload.perDevice[0]?.name).toBeTruthy()
|
|
expect(payload.perDevice[0]?.cost).toBeGreaterThan(0)
|
|
expect(payload.combined).toMatchObject({
|
|
calls: 1,
|
|
sessions: 1,
|
|
deviceCount: 1,
|
|
reachableCount: 1,
|
|
})
|
|
expect(payload.combined.cost).toBeCloseTo(payload.perDevice[0]!.cost, 10)
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('identity --format json returns the public identity subset', async () => {
|
|
const home = await mkdtemp(join(tmpdir(), 'codeburn-identity-json-'))
|
|
|
|
try {
|
|
const result = runCli(['identity', '--format', 'json'], home)
|
|
|
|
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
|
const payload = JSON.parse(result.stdout) as Record<string, unknown>
|
|
expect(Object.keys(payload).sort()).toEqual(['fingerprint', 'name'])
|
|
expect(typeof payload['name']).toBe('string')
|
|
expect(typeof payload['fingerprint']).toBe('string')
|
|
expect(payload['fingerprint']).toMatch(/^[a-f0-9]{64}$/)
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('devices scan --format json returns the documented scan envelope', async () => {
|
|
const home = await mkdtemp(join(tmpdir(), 'codeburn-devices-scan-json-'))
|
|
|
|
try {
|
|
const result = runCli(['devices', 'scan', '--format', 'json'], home, { mockDiscovery: true })
|
|
|
|
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
|
const payload = JSON.parse(result.stdout) as {
|
|
found: Array<{ name: string; host: string; port: number; fingerprint: string; code: string; paired: boolean }>
|
|
}
|
|
expect(payload.found).toHaveLength(1)
|
|
expect(payload.found[0]).toMatchObject({
|
|
name: 'Fixture Mac',
|
|
host: 'fixture.local',
|
|
port: 7777,
|
|
fingerprint: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
paired: false,
|
|
})
|
|
expect(payload.found[0]?.code).toMatch(/^\d{3}$/)
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('devices add --format json is rejected as a mutation', async () => {
|
|
const home = await mkdtemp(join(tmpdir(), 'codeburn-devices-add-json-'))
|
|
|
|
try {
|
|
const result = runCli(['devices', 'add', '--format', 'json'], home)
|
|
|
|
expect(result.status).not.toBe(0)
|
|
expect(result.stdout).toBe('')
|
|
expect(result.stderr).toContain('--format json is only supported for read-only devices output and scan')
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('share --format json is rejected without the status action', async () => {
|
|
const home = await mkdtemp(join(tmpdir(), 'codeburn-share-json-guard-'))
|
|
|
|
try {
|
|
const result = runCli(['share', '--format', 'json'], home)
|
|
|
|
expect(result.status).not.toBe(0)
|
|
expect(result.stdout).toBe('')
|
|
expect(result.stderr).toContain('--format json is only supported for `share status`')
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('share status --format json returns ShareStatus without starting sharing', async () => {
|
|
const home = await mkdtemp(join(tmpdir(), 'codeburn-share-status-json-'))
|
|
|
|
try {
|
|
const result = runCli(['share', 'status', '--format', 'json'], home)
|
|
|
|
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
|
const payload = JSON.parse(result.stdout) as {
|
|
sharing: boolean
|
|
name: string
|
|
port: number
|
|
always: boolean
|
|
peers: number
|
|
pending: Array<{ id: string; name: string; code: string }>
|
|
}
|
|
expect(payload).toMatchObject({
|
|
sharing: false,
|
|
port: 7777,
|
|
always: false,
|
|
peers: 0,
|
|
pending: [],
|
|
})
|
|
expect(typeof payload.name).toBe('string')
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|