codeburn/tests/devices-json.test.ts
iamtoruk b95b62d8fa fix(cli): surface mDNS scan errors, robust spend-flow rollup + date label, harden tests
Addresses T1 review: discovery.ts logs mDNS bind errors to stderr and returns
already-found devices instead of masking failures as empty; spend-flow uses a
collision-proof "other" sentinel and a local-date period label; the spend/date
flags get friendly error handling; devices-json tests are deterministic (mocked
discovery) and assert the read-only mutation guards. 13 tests green, tsc clean.

Implemented by Codex gpt-5.5 (high); committed by Fable (git blocked in Codex sandbox).
2026-07-10 16:20:41 -07:00

262 lines
8.5 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 { describe, expect, it, vi } from 'vitest'
const DISCOVERY_MOCK_REGISTER = join(process.cwd(), 'tests', 'fixtures', 'mock-discovery-register.mjs')
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,
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', '2026-04-10T09:00:00Z'),
assistantLine('s1', '2026-04-10T09: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 })
}
})
})