mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 14:34:32 +00:00
611 lines
24 KiB
TypeScript
611 lines
24 KiB
TypeScript
/**
|
|
* Unit tests for sync ledger and OTLP payload builder.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import { mkdtemp, rm } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { tmpdir } from 'os'
|
|
|
|
import {
|
|
deriveSpanId,
|
|
deriveTraceId,
|
|
buildOtlpPayload,
|
|
batchCalls,
|
|
getDeviceId,
|
|
deriveDeviceId,
|
|
type CallWithSession,
|
|
} from '../src/sync/otlp.js'
|
|
|
|
import type { ParsedApiCall, TokenUsage } from '../src/types.js'
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
function makeCall(overrides: Partial<ParsedApiCall> & { deduplicationKey: string }): ParsedApiCall {
|
|
const usage: TokenUsage = {
|
|
inputTokens: 1000,
|
|
outputTokens: 500,
|
|
cacheCreationInputTokens: 0,
|
|
cacheReadInputTokens: 0,
|
|
cachedInputTokens: 0,
|
|
reasoningTokens: 0,
|
|
webSearchRequests: 0,
|
|
}
|
|
return {
|
|
provider: 'kiro',
|
|
model: 'claude-sonnet-4-6',
|
|
usage,
|
|
costUSD: 0.05,
|
|
tools: ['Edit', 'Bash'],
|
|
mcpTools: [],
|
|
skills: [],
|
|
subagentTypes: [],
|
|
hasAgentSpawn: false,
|
|
hasPlanMode: false,
|
|
speed: 'standard',
|
|
timestamp: '2026-07-10T10:00:00.000Z',
|
|
bashCommands: [],
|
|
deduplicationKey: 'test:key:1',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeCallWithSession(overrides?: Partial<ParsedApiCall> & { deduplicationKey?: string }): CallWithSession {
|
|
return {
|
|
call: makeCall({ deduplicationKey: overrides?.deduplicationKey ?? 'test:key:1', ...overrides }),
|
|
sessionId: 'session-abc',
|
|
project: 'my-project',
|
|
}
|
|
}
|
|
|
|
// ── OTLP Span/Trace ID Derivation ────────────────────────────────────
|
|
|
|
describe('deriveSpanId', () => {
|
|
it('returns 16 hex chars', () => {
|
|
const id = deriveSpanId('cursor:bubble:abc123')
|
|
expect(id).toMatch(/^[0-9a-f]{16}$/)
|
|
})
|
|
|
|
it('is deterministic (same input = same output)', () => {
|
|
const a = deriveSpanId('my:dedup:key')
|
|
const b = deriveSpanId('my:dedup:key')
|
|
expect(a).toBe(b)
|
|
})
|
|
|
|
it('different inputs produce different IDs', () => {
|
|
const a = deriveSpanId('key-1')
|
|
const b = deriveSpanId('key-2')
|
|
expect(a).not.toBe(b)
|
|
})
|
|
|
|
// GOLDEN PIN — do not update this value. The idempotency contract depends
|
|
// on span IDs being stable across releases: if the hash input or encoding
|
|
// ever changes, every re-sent span gets a new identity and backends that
|
|
// key on span ID double-count history. If this test fails, revert the
|
|
// encoding change (or design an explicit migration).
|
|
it('golden: SHA-256(deduplicationKey) first 8 bytes as hex', () => {
|
|
expect(deriveSpanId('golden-dedup-key')).toBe('ec3ca28cceacf381')
|
|
})
|
|
})
|
|
|
|
describe('deriveTraceId', () => {
|
|
it('returns 32 hex chars', () => {
|
|
const id = deriveTraceId('session-xyz')
|
|
expect(id).toMatch(/^[0-9a-f]{32}$/)
|
|
})
|
|
|
|
it('is deterministic', () => {
|
|
const a = deriveTraceId('session-1')
|
|
const b = deriveTraceId('session-1')
|
|
expect(a).toBe(b)
|
|
})
|
|
|
|
// GOLDEN PIN — see deriveSpanId golden test for why this must not change.
|
|
it('golden: SHA-256(sessionId) first 16 bytes as hex', () => {
|
|
expect(deriveTraceId('golden-session-id')).toBe('ff1b1358ef64c52f80e50e7ae47ca176')
|
|
})
|
|
})
|
|
|
|
describe('getDeviceId', () => {
|
|
it('returns 16 hex chars', () => {
|
|
const id = getDeviceId()
|
|
expect(id).toMatch(/^[0-9a-f]{16}$/)
|
|
})
|
|
|
|
it('is stable across calls', () => {
|
|
expect(getDeviceId()).toBe(getDeviceId())
|
|
})
|
|
|
|
// GOLDEN PIN — device ID must be stable across releases so a developer's
|
|
// machine keeps one identity in the backend.
|
|
it('golden: SHA-256(hostname:username) first 8 bytes as hex', () => {
|
|
expect(deriveDeviceId('host.example', 'alice')).toBe('004d1a2fc048f575')
|
|
})
|
|
})
|
|
|
|
// ── OTLP Payload Builder ──────────────────────────────────────────────
|
|
|
|
describe('buildOtlpPayload', () => {
|
|
it('builds valid OTLP structure with one span', () => {
|
|
const payload = buildOtlpPayload([makeCallWithSession()])
|
|
|
|
expect(payload.resourceSpans).toHaveLength(1)
|
|
expect(payload.resourceSpans[0]!.resource.attributes).toEqual([
|
|
{ key: 'codeburn.device_id', value: { stringValue: expect.stringMatching(/^[0-9a-f]{16}$/) } },
|
|
])
|
|
|
|
const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans
|
|
expect(spans).toHaveLength(1)
|
|
|
|
const span = spans[0]!
|
|
expect(span.traceId).toMatch(/^[0-9a-f]{32}$/)
|
|
expect(span.spanId).toMatch(/^[0-9a-f]{16}$/)
|
|
expect(span.name).toBe('kiro/claude-sonnet-4-6')
|
|
expect(span.startTimeUnixNano).toBe('1783677600000000000')
|
|
})
|
|
|
|
it('includes correct span attributes', () => {
|
|
const payload = buildOtlpPayload([makeCallWithSession()])
|
|
const attrs = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.attributes
|
|
const attrMap = Object.fromEntries(attrs.map(a => [a.key, a.value]))
|
|
|
|
expect(attrMap['ai.provider']).toEqual({ stringValue: 'kiro' })
|
|
expect(attrMap['ai.model']).toEqual({ stringValue: 'claude-sonnet-4-6' })
|
|
expect(attrMap['ai.input_tokens']).toEqual({ intValue: '1000' })
|
|
expect(attrMap['ai.output_tokens']).toEqual({ intValue: '500' })
|
|
expect(attrMap['ai.cost_usd']).toEqual({ doubleValue: 0.05 })
|
|
expect(attrMap['ai.project']).toEqual({ stringValue: 'my-project' })
|
|
expect(attrMap['ai.speed']).toEqual({ stringValue: 'standard' })
|
|
})
|
|
|
|
it('includes tools as array attribute', () => {
|
|
const payload = buildOtlpPayload([makeCallWithSession()])
|
|
const attrs = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.attributes
|
|
const toolsAttr = attrs.find(a => a.key === 'ai.tools')
|
|
|
|
expect(toolsAttr).toBeDefined()
|
|
expect(toolsAttr!.value).toEqual({
|
|
arrayValue: { values: [{ stringValue: 'Edit' }, { stringValue: 'Bash' }] },
|
|
})
|
|
})
|
|
|
|
it('omits tools attribute when empty', () => {
|
|
const call = makeCallWithSession({ tools: [] as string[] } as any)
|
|
const payload = buildOtlpPayload([call])
|
|
const attrs = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.attributes
|
|
const toolsAttr = attrs.find(a => a.key === 'ai.tools')
|
|
expect(toolsAttr).toBeUndefined()
|
|
})
|
|
|
|
it('multiple calls produce multiple spans', () => {
|
|
const calls = [
|
|
makeCallWithSession({ deduplicationKey: 'k1' }),
|
|
makeCallWithSession({ deduplicationKey: 'k2' }),
|
|
makeCallWithSession({ deduplicationKey: 'k3' }),
|
|
]
|
|
const payload = buildOtlpPayload(calls)
|
|
const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans
|
|
expect(spans).toHaveLength(3)
|
|
// Each span has a unique spanId
|
|
const ids = new Set(spans.map(s => s.spanId))
|
|
expect(ids.size).toBe(3)
|
|
})
|
|
|
|
it('same deduplicationKey produces same spanId (idempotent re-send)', () => {
|
|
const call = makeCallWithSession({ deduplicationKey: 'stable-key' })
|
|
const p1 = buildOtlpPayload([call])
|
|
const p2 = buildOtlpPayload([call])
|
|
expect(p1.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.spanId)
|
|
.toBe(p2.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.spanId)
|
|
})
|
|
})
|
|
|
|
// ── Batching ──────────────────────────────────────────────────────────
|
|
|
|
describe('batchCalls', () => {
|
|
it('returns single batch when under limit', () => {
|
|
const calls = Array.from({ length: 5 }, (_, i) =>
|
|
makeCallWithSession({ deduplicationKey: `k${i}` })
|
|
)
|
|
const batches = batchCalls(calls, 1000)
|
|
expect(batches).toHaveLength(1)
|
|
expect(batches[0]).toHaveLength(5)
|
|
})
|
|
|
|
it('splits into multiple batches at the limit', () => {
|
|
const calls = Array.from({ length: 2500 }, (_, i) =>
|
|
makeCallWithSession({ deduplicationKey: `k${i}` })
|
|
)
|
|
const batches = batchCalls(calls, 1000)
|
|
expect(batches).toHaveLength(3)
|
|
expect(batches[0]).toHaveLength(1000)
|
|
expect(batches[1]).toHaveLength(1000)
|
|
expect(batches[2]).toHaveLength(500)
|
|
})
|
|
|
|
it('empty input returns empty array', () => {
|
|
expect(batchCalls([], 1000)).toEqual([])
|
|
})
|
|
})
|
|
|
|
// ── Ledger ────────────────────────────────────────────────────────────
|
|
|
|
describe('ledger', () => {
|
|
let tmpDir: string
|
|
const originalHome = process.env.HOME
|
|
const originalCacheDir = process.env.CODEBURN_CACHE_DIR
|
|
const originalXdgCacheDir = process.env.XDG_CACHE_HOME
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-ledger-'))
|
|
process.env.HOME = tmpDir
|
|
process.env.CODEBURN_CACHE_DIR = join(tmpDir, '.cache', 'codeburn')
|
|
})
|
|
|
|
afterEach(async () => {
|
|
process.env.HOME = originalHome
|
|
if (originalCacheDir === undefined) delete process.env.CODEBURN_CACHE_DIR
|
|
else process.env.CODEBURN_CACHE_DIR = originalCacheDir
|
|
if (originalXdgCacheDir === undefined) delete process.env.XDG_CACHE_HOME
|
|
else process.env.XDG_CACHE_HOME = originalXdgCacheDir
|
|
await rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
it('readLedger returns empty array when no file', async () => {
|
|
const { readLedger } = await import('../src/sync/ledger.js')
|
|
expect(readLedger()).toEqual([])
|
|
})
|
|
|
|
it('writeLedger + readLedger round-trips', async () => {
|
|
const { writeLedger, readLedger } = await import('../src/sync/ledger.js')
|
|
const entries = [
|
|
{ key: 'k1', ts: '2026-07-10T00:00:00Z' },
|
|
{ key: 'k2', ts: '2026-07-11T00:00:00Z' },
|
|
]
|
|
writeLedger(entries)
|
|
expect(readLedger()).toEqual(entries)
|
|
})
|
|
|
|
it('appendToLedger adds new entries and deduplicates', async () => {
|
|
const { writeLedger, appendToLedger, readLedger } = await import('../src/sync/ledger.js')
|
|
writeLedger([{ key: 'existing', ts: '2026-07-01T00:00:00Z' }])
|
|
appendToLedger([
|
|
{ key: 'existing', ts: '2026-07-01T00:00:00Z' }, // duplicate
|
|
{ key: 'new-one', ts: '2026-07-10T00:00:00Z' },
|
|
])
|
|
const result = readLedger()
|
|
expect(result).toHaveLength(2)
|
|
expect(result.map(e => e.key).sort()).toEqual(['existing', 'new-one'])
|
|
})
|
|
|
|
it('appendToLedger prunes entries older than 6 months', async () => {
|
|
const { writeLedger, appendToLedger, readLedger } = await import('../src/sync/ledger.js')
|
|
const old = new Date(Date.now() - 200 * 24 * 60 * 60 * 1000).toISOString() // 200 days ago
|
|
writeLedger([{ key: 'old-entry', ts: old }])
|
|
appendToLedger([{ key: 'fresh', ts: new Date().toISOString() }])
|
|
const result = readLedger()
|
|
expect(result.map(e => e.key)).toEqual(['fresh'])
|
|
})
|
|
|
|
it('ledgerKeySet returns set of keys', async () => {
|
|
const { writeLedger, ledgerKeySet } = await import('../src/sync/ledger.js')
|
|
writeLedger([
|
|
{ key: 'a', ts: '2026-07-01T00:00:00Z' },
|
|
{ key: 'b', ts: '2026-07-02T00:00:00Z' },
|
|
])
|
|
const keys = ledgerKeySet()
|
|
expect(keys.has('a')).toBe(true)
|
|
expect(keys.has('b')).toBe(true)
|
|
expect(keys.has('c')).toBe(false)
|
|
})
|
|
|
|
it('clearLedger removes the file and returns count', async () => {
|
|
const { writeLedger, clearLedger, readLedger } = await import('../src/sync/ledger.js')
|
|
writeLedger([{ key: 'x', ts: '2026-07-01T00:00:00Z' }])
|
|
const count = clearLedger()
|
|
expect(count).toBe(1)
|
|
expect(readLedger()).toEqual([])
|
|
})
|
|
|
|
it('clearLedger returns 0 when no file', async () => {
|
|
const { clearLedger } = await import('../src/sync/ledger.js')
|
|
expect(clearLedger()).toBe(0)
|
|
})
|
|
|
|
it('clearLedger removes coexisting canonical and eligible legacy ledgers without adopting either', async () => {
|
|
const { clearLedger } = await import('../src/sync/ledger.js')
|
|
const { existsSync, mkdirSync, writeFileSync } = await import('fs')
|
|
const canonicalDir = join(tmpDir, '.cache', 'codeburn')
|
|
const xdgDir = join(tmpDir, 'xdg-clear-coexisting')
|
|
const legacyDir = join(xdgDir, 'codeburn')
|
|
|
|
delete process.env.CODEBURN_CACHE_DIR
|
|
process.env.XDG_CACHE_HOME = xdgDir
|
|
mkdirSync(canonicalDir, { recursive: true })
|
|
mkdirSync(legacyDir, { recursive: true })
|
|
writeFileSync(join(canonicalDir, 'sync-ledger.json'), JSON.stringify([
|
|
{ key: 'canonical', ts: '2026-07-02T00:00:00Z' },
|
|
{ key: 'duplicate', ts: '2026-07-03T00:00:00Z' },
|
|
]))
|
|
writeFileSync(join(legacyDir, 'sync-ledger.json'), JSON.stringify([
|
|
{ key: 'legacy', ts: '2026-07-01T00:00:00Z' },
|
|
{ key: 'duplicate', ts: '2025-01-01T00:00:00Z' },
|
|
]))
|
|
|
|
expect(clearLedger()).toBe(3)
|
|
expect(existsSync(join(canonicalDir, 'sync-ledger.json'))).toBe(false)
|
|
expect(existsSync(join(legacyDir, 'sync-ledger.json'))).toBe(false)
|
|
})
|
|
|
|
it('clearLedger attempts both targets, reports a real unlink failure, and can retry the remainder', async () => {
|
|
const fs = await import('fs')
|
|
const canonicalDir = join(tmpDir, '.cache', 'codeburn')
|
|
const canonicalPath = join(canonicalDir, 'sync-ledger.json')
|
|
const xdgDir = join(tmpDir, 'xdg-clear-retry')
|
|
const legacyDir = join(xdgDir, 'codeburn')
|
|
const legacyPath = join(legacyDir, 'sync-ledger.json')
|
|
|
|
delete process.env.CODEBURN_CACHE_DIR
|
|
process.env.XDG_CACHE_HOME = xdgDir
|
|
fs.mkdirSync(canonicalDir, { recursive: true })
|
|
fs.mkdirSync(legacyDir, { recursive: true })
|
|
fs.writeFileSync(canonicalPath, JSON.stringify([
|
|
{ key: 'canonical', ts: '2026-07-02T00:00:00Z' },
|
|
]))
|
|
fs.writeFileSync(legacyPath, JSON.stringify([
|
|
{ key: 'legacy', ts: '2026-07-01T00:00:00Z' },
|
|
]))
|
|
|
|
const attempts: string[] = []
|
|
let failCanonicalOnce = true
|
|
vi.doMock('fs', async () => {
|
|
const actual = await vi.importActual<typeof import('fs')>('fs')
|
|
return {
|
|
...actual,
|
|
unlinkSync: (path: fs.PathLike) => {
|
|
const value = String(path)
|
|
attempts.push(value)
|
|
if (value === canonicalPath && failCanonicalOnce) {
|
|
failCanonicalOnce = false
|
|
throw Object.assign(new Error('injected canonical unlink failure'), { code: 'EACCES' })
|
|
}
|
|
return actual.unlinkSync(path)
|
|
},
|
|
}
|
|
})
|
|
vi.resetModules()
|
|
|
|
try {
|
|
const { clearLedger } = await import('../src/sync/ledger.js')
|
|
expect(() => clearLedger()).toThrow('injected canonical unlink failure')
|
|
expect(attempts).toContain(canonicalPath)
|
|
expect(attempts).toContain(legacyPath)
|
|
expect(fs.existsSync(canonicalPath)).toBe(true)
|
|
expect(fs.existsSync(legacyPath)).toBe(false)
|
|
expect(JSON.parse(fs.readFileSync(canonicalPath, 'utf8'))).toEqual([
|
|
{ key: 'canonical', ts: '2026-07-02T00:00:00Z' },
|
|
])
|
|
|
|
// The successful legacy deletion is not replayed or migrated. Retrying
|
|
// removes only the canonical remainder; its missing peer is ENOENT-safe.
|
|
expect(clearLedger()).toBe(1)
|
|
expect(fs.existsSync(canonicalPath)).toBe(false)
|
|
expect(fs.existsSync(legacyPath)).toBe(false)
|
|
} finally {
|
|
vi.doUnmock('fs')
|
|
vi.resetModules()
|
|
}
|
|
})
|
|
|
|
it('corrupt ledger file reads as empty (crash-safe recovery)', async () => {
|
|
const { readLedger } = await import('../src/sync/ledger.js')
|
|
const { mkdirSync, writeFileSync } = await import('fs')
|
|
const { join } = await import('path')
|
|
const dir = join(process.env.HOME!, '.cache', 'codeburn')
|
|
mkdirSync(dir, { recursive: true })
|
|
writeFileSync(join(dir, 'sync-ledger.json'), '{"truncated mid-wri')
|
|
expect(readLedger()).toEqual([])
|
|
})
|
|
|
|
it('writes are atomic — no .tmp file left behind', async () => {
|
|
const { writeLedger } = await import('../src/sync/ledger.js')
|
|
const { existsSync } = await import('fs')
|
|
const { join } = await import('path')
|
|
writeLedger([{ key: 'a', ts: '2026-07-01T00:00:00Z' }])
|
|
const dir = join(process.env.HOME!, '.cache', 'codeburn')
|
|
expect(existsSync(join(dir, 'sync-ledger.json'))).toBe(true)
|
|
expect(existsSync(join(dir, 'sync-ledger.json.tmp'))).toBe(false)
|
|
})
|
|
|
|
it('honors CODEBURN_CACHE_DIR at call time', async () => {
|
|
const { writeLedger, readLedger } = await import('../src/sync/ledger.js')
|
|
const { existsSync } = await import('fs')
|
|
const { join } = await import('path')
|
|
const firstDir = join(tmpDir, 'cache-a')
|
|
const secondDir = join(tmpDir, 'cache-b')
|
|
|
|
process.env.CODEBURN_CACHE_DIR = firstDir
|
|
writeLedger([{ key: 'first', ts: '2026-07-01T00:00:00Z' }])
|
|
process.env.CODEBURN_CACHE_DIR = secondDir
|
|
writeLedger([{ key: 'second', ts: '2026-07-02T00:00:00Z' }])
|
|
|
|
expect(existsSync(join(firstDir, 'sync-ledger.json'))).toBe(true)
|
|
expect(existsSync(join(secondDir, 'sync-ledger.json'))).toBe(true)
|
|
expect(readLedger().map(e => e.key)).toEqual(['second'])
|
|
|
|
process.env.CODEBURN_CACHE_DIR = firstDir
|
|
expect(readLedger().map(e => e.key)).toEqual(['first'])
|
|
})
|
|
|
|
it('uses the shared default when both overrides are explicitly absent', async () => {
|
|
const { writeLedger } = await import('../src/sync/ledger.js')
|
|
const { existsSync } = await import('fs')
|
|
const { join } = await import('path')
|
|
|
|
delete process.env.CODEBURN_CACHE_DIR
|
|
delete process.env.XDG_CACHE_HOME
|
|
writeLedger([{ key: 'default', ts: '2026-07-01T00:00:00Z' }])
|
|
|
|
expect(existsSync(join(tmpDir, '.cache', 'codeburn', 'sync-ledger.json'))).toBe(true)
|
|
})
|
|
|
|
it('adopts an XDG-only legacy ledger into the canonical default and writes there thereafter', async () => {
|
|
const { appendToLedger, readLedger } = await import('../src/sync/ledger.js')
|
|
const { existsSync, mkdirSync, readFileSync, writeFileSync } = await import('fs')
|
|
const { join } = await import('path')
|
|
const xdgDir = join(tmpDir, 'xdg-cache')
|
|
const legacyDir = join(xdgDir, 'codeburn')
|
|
const canonicalDir = join(tmpDir, '.cache', 'codeburn')
|
|
|
|
delete process.env.CODEBURN_CACHE_DIR
|
|
process.env.XDG_CACHE_HOME = xdgDir
|
|
mkdirSync(legacyDir, { recursive: true })
|
|
writeFileSync(join(legacyDir, 'sync-ledger.json'), JSON.stringify([
|
|
{ key: 'legacy', ts: '2026-07-01T00:00:00Z' },
|
|
]))
|
|
|
|
expect(readLedger().map(entry => entry.key)).toEqual(['legacy'])
|
|
expect(existsSync(join(canonicalDir, 'sync-ledger.json'))).toBe(true)
|
|
expect(existsSync(join(legacyDir, 'sync-ledger.json'))).toBe(false)
|
|
|
|
appendToLedger([{ key: 'canonical', ts: '2026-07-02T00:00:00Z' }])
|
|
expect(JSON.parse(readFileSync(join(canonicalDir, 'sync-ledger.json'), 'utf8')).map((entry: { key: string }) => entry.key)).toEqual([
|
|
'legacy',
|
|
'canonical',
|
|
])
|
|
expect(existsSync(join(legacyDir, 'sync-ledger.json'))).toBe(false)
|
|
})
|
|
|
|
it('does not adopt a legacy XDG ledger when CODEBURN_CACHE_DIR is explicitly set', async () => {
|
|
const { readLedger, writeLedger } = await import('../src/sync/ledger.js')
|
|
const { existsSync, mkdirSync, writeFileSync } = await import('fs')
|
|
const explicitDir = join(tmpDir, 'explicit-cache-precedence')
|
|
const xdgDir = join(tmpDir, 'xdg-cache-precedence')
|
|
const legacyDir = join(xdgDir, 'codeburn')
|
|
|
|
process.env.CODEBURN_CACHE_DIR = explicitDir
|
|
process.env.XDG_CACHE_HOME = xdgDir
|
|
mkdirSync(legacyDir, { recursive: true })
|
|
writeFileSync(join(legacyDir, 'sync-ledger.json'), JSON.stringify([
|
|
{ key: 'legacy', ts: '2026-07-01T00:00:00Z' },
|
|
]))
|
|
|
|
expect(readLedger()).toEqual([])
|
|
writeLedger([{ key: 'explicit', ts: '2026-07-02T00:00:00Z' }])
|
|
|
|
expect(readLedger().map(entry => entry.key)).toEqual(['explicit'])
|
|
expect(existsSync(join(explicitDir, 'sync-ledger.json'))).toBe(true)
|
|
expect(existsSync(join(legacyDir, 'sync-ledger.json'))).toBe(true)
|
|
})
|
|
|
|
it('merges an XDG legacy ledger into an existing canonical ledger once', async () => {
|
|
const { readLedger } = await import('../src/sync/ledger.js')
|
|
const { existsSync, mkdirSync, writeFileSync } = await import('fs')
|
|
const canonicalDir = join(tmpDir, '.cache', 'codeburn')
|
|
const xdgDir = join(tmpDir, 'xdg-cache-merge')
|
|
const legacyDir = join(xdgDir, 'codeburn')
|
|
|
|
delete process.env.CODEBURN_CACHE_DIR
|
|
process.env.XDG_CACHE_HOME = xdgDir
|
|
mkdirSync(canonicalDir, { recursive: true })
|
|
mkdirSync(legacyDir, { recursive: true })
|
|
writeFileSync(join(canonicalDir, 'sync-ledger.json'), JSON.stringify([
|
|
{ key: 'canonical', ts: '2026-07-02T00:00:00Z' },
|
|
{ key: 'duplicate', ts: '2026-07-03T00:00:00Z' },
|
|
]))
|
|
writeFileSync(join(legacyDir, 'sync-ledger.json'), JSON.stringify([
|
|
{ key: 'legacy', ts: '2026-07-01T00:00:00Z' },
|
|
{ key: 'duplicate', ts: '2025-01-01T00:00:00Z' },
|
|
]))
|
|
|
|
expect(readLedger()).toEqual([
|
|
{ key: 'canonical', ts: '2026-07-02T00:00:00Z' },
|
|
{ key: 'duplicate', ts: '2026-07-03T00:00:00Z' },
|
|
{ key: 'legacy', ts: '2026-07-01T00:00:00Z' },
|
|
])
|
|
expect(existsSync(join(legacyDir, 'sync-ledger.json'))).toBe(false)
|
|
// A later read is canonical-only and stable; XDG is no longer active.
|
|
expect(readLedger().map(entry => entry.key)).toEqual(['canonical', 'duplicate', 'legacy'])
|
|
})
|
|
|
|
it('prefers non-empty CODEBURN_CACHE_DIR over XDG_CACHE_HOME', async () => {
|
|
const { writeLedger } = await import('../src/sync/ledger.js')
|
|
const { existsSync } = await import('fs')
|
|
const { join } = await import('path')
|
|
const explicitDir = join(tmpDir, 'explicit-cache')
|
|
const xdgDir = join(tmpDir, 'xdg-cache')
|
|
|
|
process.env.CODEBURN_CACHE_DIR = explicitDir
|
|
process.env.XDG_CACHE_HOME = xdgDir
|
|
writeLedger([{ key: 'explicit', ts: '2026-07-01T00:00:00Z' }])
|
|
|
|
expect(existsSync(join(explicitDir, 'sync-ledger.json'))).toBe(true)
|
|
expect(existsSync(join(xdgDir, 'codeburn', 'sync-ledger.json'))).toBe(false)
|
|
})
|
|
|
|
it.each(['', ' '])('ignores empty CODEBURN_CACHE_DIR %j and writes to the canonical default', async explicit => {
|
|
const { writeLedger } = await import('../src/sync/ledger.js')
|
|
const { existsSync } = await import('fs')
|
|
const { join } = await import('path')
|
|
const xdgDir = join(tmpDir, `xdg-cache-${explicit.length}`)
|
|
|
|
process.env.CODEBURN_CACHE_DIR = explicit
|
|
process.env.XDG_CACHE_HOME = xdgDir
|
|
writeLedger([{ key: 'xdg-fallback', ts: '2026-07-01T00:00:00Z' }])
|
|
|
|
expect(existsSync(join(tmpDir, '.cache', 'codeburn', 'sync-ledger.json'))).toBe(true)
|
|
expect(existsSync(join(xdgDir, 'codeburn', 'sync-ledger.json'))).toBe(false)
|
|
})
|
|
|
|
it.each(['', ' '])('ignores empty XDG_CACHE_HOME %j and uses the shared default', async xdg => {
|
|
const { writeLedger } = await import('../src/sync/ledger.js')
|
|
const { existsSync } = await import('fs')
|
|
const { join } = await import('path')
|
|
|
|
delete process.env.CODEBURN_CACHE_DIR
|
|
process.env.XDG_CACHE_HOME = xdg
|
|
writeLedger([{ key: 'default-fallback', ts: '2026-07-01T00:00:00Z' }])
|
|
|
|
expect(existsSync(join(tmpDir, '.cache', 'codeburn', 'sync-ledger.json'))).toBe(true)
|
|
})
|
|
|
|
it.each(['', ' '])('uses the shared default when both overrides are empty and CODEBURN is %j', async explicit => {
|
|
const { writeLedger } = await import('../src/sync/ledger.js')
|
|
const { existsSync } = await import('fs')
|
|
const { join } = await import('path')
|
|
|
|
process.env.CODEBURN_CACHE_DIR = explicit
|
|
process.env.XDG_CACHE_HOME = ' '
|
|
writeLedger([{ key: 'default-fallback', ts: '2026-07-01T00:00:00Z' }])
|
|
|
|
expect(existsSync(join(tmpDir, '.cache', 'codeburn', 'sync-ledger.json'))).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ── assertHttps (RFC 8252 §8.3) ───────────────────────────────────────
|
|
|
|
describe('assertHttps', () => {
|
|
it('accepts https URLs', async () => {
|
|
const { assertHttps } = await import('../src/sync/discovery.js')
|
|
expect(() => assertHttps('https://telemetry.example.com', 'Base URL')).not.toThrow()
|
|
})
|
|
|
|
it('accepts http on loopback (offline tests, local dev)', async () => {
|
|
const { assertHttps } = await import('../src/sync/discovery.js')
|
|
expect(() => assertHttps('http://127.0.0.1:8080/x', 'Base URL')).not.toThrow()
|
|
expect(() => assertHttps('http://localhost:3000', 'Base URL')).not.toThrow()
|
|
expect(() => assertHttps('http://[::1]:9999', 'Base URL')).not.toThrow()
|
|
})
|
|
|
|
it('rejects plain http on non-loopback hosts', async () => {
|
|
const { assertHttps } = await import('../src/sync/discovery.js')
|
|
expect(() => assertHttps('http://telemetry.example.com', 'Base URL')).toThrow(/must use https/)
|
|
expect(() => assertHttps('http://192.168.1.10', 'Issuer')).toThrow(/must use https/)
|
|
})
|
|
|
|
it('rejects non-http(s) schemes and garbage', async () => {
|
|
const { assertHttps } = await import('../src/sync/discovery.js')
|
|
expect(() => assertHttps('ftp://example.com', 'Base URL')).toThrow(/must use https/)
|
|
expect(() => assertHttps('not a url', 'Base URL')).toThrow(/not a valid URL/)
|
|
})
|
|
})
|