codeburn/tests/devices-json.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

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 })
}
})
})