import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdtemp, rm, writeFile, readFile, mkdir } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { Command } from 'commander'
import {
computeAcceptanceFingerprint,
buildDisclosure,
CORE_SYNC_FIELD_MEANINGS,
detectFingerprintChanges,
type FingerprintInput,
type DisclosureInput,
} from '../src/sync/consent.js'
import { readSyncConfig, writeSyncConfig, readReceipts, appendReceipt } from '../src/sync/config.js'
import { CORE_SYNC_ATTRIBUTE_KEYS } from '../src/sync/otlp.js'
import { buildLaunchAgentPlist } from '../src/sync/schedule-installer.js'
import { setHome } from './setup/home.js'
describe('Fingerprint', () => {
it('is deterministic - same input produces same hash', () => {
const input: FingerprintInput = {
org: 'test-org',
destination: 'https://endpoint.example.com',
outboundFields: ['ai.cost_usd', 'ai.model'],
workMatching: true,
scopeSinceDays: 7,
cadence: 'daily',
}
const fp1 = computeAcceptanceFingerprint(input)
const fp2 = computeAcceptanceFingerprint(input)
expect(fp1).toBe(fp2)
expect(fp1).toMatch(/^[a-f0-9]{64}$/)
})
it('differs when org changes', () => {
const base: FingerprintInput = {
org: 'org1',
destination: 'https://endpoint.example.com',
outboundFields: ['ai.cost_usd'],
workMatching: true,
scopeSinceDays: null,
cadence: 'daily',
}
const changed = { ...base, org: 'org2' }
const fp1 = computeAcceptanceFingerprint(base)
const fp2 = computeAcceptanceFingerprint(changed)
expect(fp1).not.toBe(fp2)
})
it('differs when destination changes', () => {
const base: FingerprintInput = {
org: 'test-org',
destination: 'https://endpoint1.example.com',
outboundFields: ['ai.cost_usd'],
workMatching: true,
scopeSinceDays: null,
cadence: 'daily',
}
const changed = { ...base, destination: 'https://endpoint2.example.com' }
const fp1 = computeAcceptanceFingerprint(base)
const fp2 = computeAcceptanceFingerprint(changed)
expect(fp1).not.toBe(fp2)
})
it('differs when fields change', () => {
const base: FingerprintInput = {
org: 'test-org',
destination: 'https://endpoint.example.com',
outboundFields: ['ai.cost_usd', 'ai.model'],
workMatching: true,
scopeSinceDays: null,
cadence: 'daily',
}
const changed = { ...base, outboundFields: ['ai.cost_usd', 'ai.model', 'git.repo'] }
const fp1 = computeAcceptanceFingerprint(base)
const fp2 = computeAcceptanceFingerprint(changed)
expect(fp1).not.toBe(fp2)
})
it('differs when workMatching changes', () => {
const base: FingerprintInput = {
org: 'test-org',
destination: 'https://endpoint.example.com',
outboundFields: ['ai.cost_usd'],
workMatching: true,
scopeSinceDays: null,
cadence: 'daily',
}
const changed = { ...base, workMatching: false }
const fp1 = computeAcceptanceFingerprint(base)
const fp2 = computeAcceptanceFingerprint(changed)
expect(fp1).not.toBe(fp2)
})
it('differs when scopeSinceDays changes', () => {
const base: FingerprintInput = {
org: 'test-org',
destination: 'https://endpoint.example.com',
outboundFields: ['ai.cost_usd'],
workMatching: true,
scopeSinceDays: null,
cadence: 'daily',
}
const changed = { ...base, scopeSinceDays: 7 }
const fp1 = computeAcceptanceFingerprint(base)
const fp2 = computeAcceptanceFingerprint(changed)
expect(fp1).not.toBe(fp2)
})
it('differs when cadence changes', () => {
const base: FingerprintInput = {
org: 'test-org',
destination: 'https://endpoint.example.com',
outboundFields: ['ai.cost_usd'],
workMatching: true,
scopeSinceDays: null,
cadence: 'daily',
}
const changed = { ...base, cadence: 'hourly' }
const fp1 = computeAcceptanceFingerprint(base)
const fp2 = computeAcceptanceFingerprint(changed)
expect(fp1).not.toBe(fp2)
})
})
describe('Disclosure', () => {
it('builds readable disclosure text', () => {
const input: DisclosureInput = {
destination: 'test-org',
destinationUrl: 'https://endpoint.example.com',
cadence: 'daily',
outboundFields: [
{ key: 'ai.cost_usd', disclosure: 'Dollar cost of API calls' },
{ key: 'ai.model', disclosure: 'AI model name' },
],
workMatching: true,
scopeSinceDays: null,
}
const disclosure = buildDisclosure(input)
expect(disclosure).toContain('test-org')
expect(disclosure).toContain('https://endpoint.example.com')
expect(disclosure).toContain('once per day')
expect(disclosure).toContain('ai.cost_usd')
expect(disclosure).toContain('ai.model')
expect(disclosure).toContain('raw prompts')
expect(disclosure).toContain('file paths')
expect(disclosure).toContain('codeburn sync auto disable')
})
it('states hourly cadence correctly', () => {
const input: DisclosureInput = {
destination: 'test-org',
destinationUrl: 'https://endpoint.example.com',
cadence: 'hourly',
outboundFields: [],
workMatching: true,
scopeSinceDays: null,
}
const disclosure = buildDisclosure(input)
expect(disclosure).toContain('once per hour')
})
it('states scope correctly for null (full history)', () => {
const input: DisclosureInput = {
destination: 'test-org',
destinationUrl: 'https://endpoint.example.com',
cadence: 'daily',
outboundFields: [],
workMatching: true,
scopeSinceDays: null,
}
const disclosure = buildDisclosure(input)
expect(disclosure).toContain('full history')
})
it('states scope correctly for 0 (today only)', () => {
const input: DisclosureInput = {
destination: 'test-org',
destinationUrl: 'https://endpoint.example.com',
cadence: 'daily',
outboundFields: [],
workMatching: true,
scopeSinceDays: 0,
}
const disclosure = buildDisclosure(input)
expect(disclosure).toContain('today only')
})
it('states scope correctly for days', () => {
const input: DisclosureInput = {
destination: 'test-org',
destinationUrl: 'https://endpoint.example.com',
cadence: 'daily',
outboundFields: [],
workMatching: true,
scopeSinceDays: 7,
}
const disclosure = buildDisclosure(input)
expect(disclosure).toContain('last 7 days')
})
it('core field meanings map contains all core attribute keys', () => {
const meaningsKeys = new Set(CORE_SYNC_FIELD_MEANINGS.keys())
for (const key of CORE_SYNC_ATTRIBUTE_KEYS) {
expect(meaningsKeys.has(key)).toBe(true)
}
})
it('disclosure contains no empty field meanings', () => {
const input: DisclosureInput = {
destination: 'test-org',
destinationUrl: 'https://endpoint.example.com',
cadence: 'daily',
outboundFields: [
{ key: 'ai.cost_usd', disclosure: 'the cost of the call in US dollars' },
{ key: 'ai.model', disclosure: 'which AI model handled the call' },
{ key: 'custom.field', disclosure: 'custom plugin field' },
],
workMatching: false,
scopeSinceDays: 7,
}
const disclosure = buildDisclosure(input)
// Check that no field line (starting with spaces) ends with ":" and nothing after
const lines = disclosure.split('\n')
for (const line of lines) {
if (line.startsWith(' ') && line.includes(':')) {
// Field line: should have content after the colon
expect(line).not.toMatch(/:\s*$/)
}
}
})
it('includes work-matching line when enabled', () => {
const input: DisclosureInput = {
destination: 'test-org',
destinationUrl: 'https://endpoint.example.com',
cadence: 'daily',
outboundFields: [],
workMatching: true,
scopeSinceDays: 7,
}
const disclosure = buildDisclosure(input)
expect(disclosure).toContain('Work matching: on - session-to-commit links are sent')
})
it('includes work-matching line when disabled', () => {
const input: DisclosureInput = {
destination: 'test-org',
destinationUrl: 'https://endpoint.example.com',
cadence: 'daily',
outboundFields: [],
workMatching: false,
scopeSinceDays: 7,
}
const disclosure = buildDisclosure(input)
expect(disclosure).toContain('Work matching: off')
})
it('detects removed field set changes', () => {
const storedInput: FingerprintInput = {
org: 'test-org',
destination: 'https://endpoint.example.com',
outboundFields: ['ai.cost_usd', 'ai.model', 'plugin.custom'],
workMatching: false,
scopeSinceDays: 7,
cadence: 'daily',
}
const currentInput: FingerprintInput = {
org: 'test-org',
destination: 'https://endpoint.example.com',
outboundFields: ['ai.cost_usd', 'ai.model'],
workMatching: false,
scopeSinceDays: 7,
cadence: 'daily',
}
const changes = detectFingerprintChanges(storedInput, currentInput)
expect(changes[0]).toContain('field set')
expect(changes[0]).toContain('removed: plugin.custom')
})
})
describe('Config with auto block', () => {
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-test-'))
setHome(tmpDir)
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
it('round-trips auto block through config with attribution', async () => {
const configDir = join(tmpDir, '.config', 'codeburn')
await mkdir(configDir, { recursive: true })
const config = {
baseUrl: 'https://endpoint.example.com',
clientId: 'test-client',
tracesPath: '/v1/traces',
issuer: 'https://issuer.example.com',
auto: {
accepted: {
fingerprint: 'abc123',
acceptedAt: new Date().toISOString(),
cadence: 'daily' as const,
disclosure: 'Test disclosure',
attribution: true,
},
killed: false,
},
}
writeSyncConfig(config)
const loaded = readSyncConfig()
expect(loaded?.auto?.accepted?.fingerprint).toBe('abc123')
expect(loaded?.auto?.accepted?.cadence).toBe('daily')
expect(loaded?.auto?.accepted?.attribution).toBe(true)
expect(loaded?.auto?.killed).toBe(false)
})
it('status recompute matches original fingerprint when attribution choice stored', () => {
// Simulate what enable does: compute fingerprint with 7-day scope and stored attribution choice
const fields = ['ai.cost_usd', 'ai.model', 'git.repo']
const attribution = false // User did NOT request --attribution
const cadence = 'daily' as const
const enableInput: FingerprintInput = {
org: 'test-client',
destination: 'https://endpoint.example.com',
outboundFields: fields,
workMatching: attribution,
scopeSinceDays: 7,
cadence,
}
const storedFingerprint = computeAcceptanceFingerprint(enableInput)
// Now simulate what status does: recompute with same config
const statusInput: FingerprintInput = {
org: 'test-client',
destination: 'https://endpoint.example.com',
outboundFields: fields,
workMatching: attribution, // Same as what was stored
scopeSinceDays: 7, // Same as what was stored
cadence,
}
const recomputedFingerprint = computeAcceptanceFingerprint(statusInput)
expect(recomputedFingerprint).toBe(storedFingerprint)
})
it('handles missing auto block', () => {
const configDir = join(tmpDir, '.config', 'codeburn')
mkdir(configDir, { recursive: true })
const config = {
baseUrl: 'https://endpoint.example.com',
clientId: 'test-client',
tracesPath: '/v1/traces',
issuer: 'https://issuer.example.com',
}
writeSyncConfig(config)
const loaded = readSyncConfig()
expect(loaded?.auto).toBeUndefined()
})
})
describe('Receipts', () => {
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-test-'))
setHome(tmpDir)
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
it('appends receipts to JSONL file', () => {
const configDir = join(tmpDir, '.config', 'codeburn')
mkdir(configDir, { recursive: true })
const receipt1 = { at: '2024-01-01T00:00:00Z', result: 'pushed', spans: 10 }
const receipt2 = { at: '2024-01-02T00:00:00Z', result: 'killed' }
appendReceipt(receipt1)
appendReceipt(receipt2)
const receipts = readReceipts()
expect(receipts).toHaveLength(2)
expect(receipts[0]?.result).toBe('pushed')
expect(receipts[1]?.result).toBe('killed')
})
it('reads last N receipts', () => {
const configDir = join(tmpDir, '.config', 'codeburn')
mkdir(configDir, { recursive: true })
for (let i = 0; i < 10; i++) {
appendReceipt({ at: `2024-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`, result: 'pushed' })
}
const last5 = readReceipts(5)
expect(last5).toHaveLength(5)
expect(last5[0]?.at).toContain('01-06')
expect(last5[4]?.at).toContain('01-10')
})
it('returns empty array when receipts file does not exist', () => {
const receipts = readReceipts()
expect(receipts).toEqual([])
})
it('creates directory if missing when appending receipt', () => {
// Don't pre-create configDir - test that appendReceipt creates it
setHome(tmpDir)
const receipt = { at: '2024-01-01T00:00:00Z', result: 'pushed', spans: 5 }
appendReceipt(receipt)
const receipts = readReceipts()
expect(receipts).toHaveLength(1)
expect(receipts[0]?.result).toBe('pushed')
})
})
describe('LaunchAgent plist', () => {
it('emits runtimePath and scriptPath as separate ProgramArguments strings', () => {
const plist = buildLaunchAgentPlist('daily', '/usr/local/bin/node', '/Users/me/code/codeburn/dist/cli.js')
const argsBlock = plist.slice(
plist.indexOf('ProgramArguments'),
plist.indexOf('', plist.indexOf('ProgramArguments')) + ''.length,
)
expect(argsBlock).toContain('/usr/local/bin/node')
expect(argsBlock).toContain('/Users/me/code/codeburn/dist/cli.js')
expect(argsBlock).toContain('sync')
expect(argsBlock).toContain('auto')
expect(argsBlock).toContain('run')
})
it('includes the ELECTRON_RUN_AS_NODE environment block', () => {
const plist = buildLaunchAgentPlist('hourly', '/Applications/codeburn.app/Contents/MacOS/codeburn', '/Applications/codeburn.app/Contents/Resources/app/dist/cli.js')
expect(plist).toContain('EnvironmentVariables')
expect(plist).toContain('ELECTRON_RUN_AS_NODE')
expect(plist).toContain('1')
})
it('XML-escapes ampersand, less-than, and greater-than in both paths', () => {
const runtime = '/Users/jane & joe/Contents/MacOS/codeburn'
const script = '/Applications/codeburn /dist/cli.js'
const plist = buildLaunchAgentPlist('daily', runtime, script)
expect(plist).toContain('& joe')
expect(plist).toContain('<beta>')
expect(plist).not.toContain('& joe/Contents')
expect(plist).not.toContain('')
})
})
describe('Damaged acceptance record', () => {
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-test-'))
setHome(tmpDir)
})
afterEach(async () => {
vi.restoreAllMocks()
process.exitCode = 0
await rm(tmpDir, { recursive: true, force: true })
})
it('status prints the damaged-record message and never "undefined" when fingerprint is missing', async () => {
const configDir = join(tmpDir, '.config', 'codeburn')
await mkdir(configDir, { recursive: true })
writeSyncConfig({
baseUrl: 'https://endpoint.example.com',
clientId: 'test-client',
tracesPath: '/v1/traces',
issuer: 'https://issuer.example.com',
auto: {
// accepted present but fingerprint is missing
accepted: {
acceptedAt: new Date().toISOString(),
cadence: 'daily',
disclosure: 'd',
attribution: false,
} as any,
killed: false,
},
})
const stdoutChunks: string[] = []
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => {
stdoutChunks.push(String(chunk))
return true
})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('__exit__')
}) as never)
const { registerSyncCommands } = await import('../src/sync/cli.js')
const program = new Command()
program.exitOverride()
registerSyncCommands(program)
await expect(
program.parseAsync(['node', 'codeburn', 'sync', 'auto', 'status']),
).rejects.toThrow('__exit__')
expect(exitSpy).toHaveBeenCalledWith(1)
const stdout = stdoutChunks.join('')
expect(stdout).toContain('Automatic sync acceptance record is damaged.')
expect(stdout).toContain('codeburn sync auto enable --cadence --accept')
expect(stdout).not.toContain('undefined')
})
it('status prints the damaged-record message when acceptedAt is missing', async () => {
const configDir = join(tmpDir, '.config', 'codeburn')
await mkdir(configDir, { recursive: true })
writeSyncConfig({
baseUrl: 'https://endpoint.example.com',
clientId: 'test-client',
tracesPath: '/v1/traces',
issuer: 'https://issuer.example.com',
auto: {
accepted: {
fingerprint: 'abc123',
cadence: 'daily',
disclosure: 'd',
attribution: false,
} as any,
killed: false,
},
})
const stdoutChunks: string[] = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => {
stdoutChunks.push(String(chunk))
return true
})
vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('__exit__')
}) as never)
const { registerSyncCommands } = await import('../src/sync/cli.js')
const program = new Command()
program.exitOverride()
registerSyncCommands(program)
await expect(
program.parseAsync(['node', 'codeburn', 'sync', 'auto', 'status']),
).rejects.toThrow('__exit__')
const stdout = stdoutChunks.join('')
expect(stdout).toContain('Automatic sync acceptance record is damaged.')
expect(stdout).not.toContain('undefined')
})
})
describe('sync auto status --json', () => {
let testDir: string
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), 'sync-auto-'))
setHome(testDir)
})
afterEach(async () => {
await rm(testDir, { recursive: true, force: true })
})
it('outputs JSON when --json flag is set', async () => {
await mkdir(join(testDir, '.config', 'codeburn'), { recursive: true })
const config = {
issuer: 'https://issuer.example.com',
clientId: 'test-client',
baseUrl: 'https://receiver.example.com',
auto: {
accepted: {
fingerprint: 'abc123',
acceptedAt: '2024-01-01T00:00:00Z',
cadence: 'daily' as const,
disclosure: 'test disclosure',
attribution: false,
},
killed: false,
},
}
writeSyncConfig(config)
appendReceipt({ at: '2024-01-01T12:00:00Z', fingerprint: 'abc123', result: 'pushed', spans: 5 })
appendReceipt({ at: '2024-01-01T13:00:00Z', fingerprint: 'abc123', result: 'pushed', spans: 3 })
// Import and setup CLI
const { registerSyncCommands } = await import('../src/sync/cli.js')
const { Command } = await import('commander')
const program = new Command()
program.exitOverride()
registerSyncCommands(program)
const savedStdout = process.stdout.write
let stdout = ''
process.stdout.write = (chunk: string | Uint8Array | Buffer): boolean => {
stdout += chunk.toString()
return true
}
try {
await program.parseAsync(['node', 'codeburn', 'sync', 'auto', 'status', '--json'])
const result = JSON.parse(stdout)
expect(result.configured).toBe(true)
expect(result.accepted).toBeDefined()
expect(result.accepted.fingerprint).toBe('abc123')
expect(result.accepted.cadence).toBe('daily')
expect(result.accepted.attribution).toBe(false)
expect(result.killed).toBe(false)
expect(Array.isArray(result.receipts)).toBe(true)
expect(result.receipts.length).toBeGreaterThan(0)
} finally {
process.stdout.write = savedStdout
}
})
})