codeburn/tests/sync-attribution-cli.test.ts
iamtoruk 62cb4f8630 test: isolate the real home on Windows and make the CLI suite pass there
os.homedir() reads USERPROFILE on Windows and never HOME, so the
isolation setup and every test that moved home were still resolving the
developer's real home directory. Fixtures came back empty, real sessions
leaked into counts, and the code under test wrote plans, receipts and a
sync ledger into the real ~/.config/codeburn during a run.

USERPROFILE now joins the redirected variables and a shared setHome()
helper moves HOME and USERPROFILE together; every spawn env carries both.
The remaining Windows-only failures were POSIX-shaped fixtures: chmod
tricks that only toggle a read-only attribute on NTFS, pid 1 as an
always-alive lock holder, splitting paths on '/', bare tar, mode-bit
expectations, and per-file timeouts too short for the cost of a tsx spawn.
Each gets a platform-aware equivalent so nothing is skipped on Windows and
the POSIX branches stay byte-for-byte what they were.

Full run on Windows: 267 files passed, 2 skipped, 3592 tests passed.
2026-09-02 13:43:57 -07:00

182 lines
8.2 KiB
TypeScript

/**
* CLI-level tests for `codeburn sync push --attribution`.
*
* Drives the real commander action against a mock IdP + collector:
* - --dry-run --attribution: NO telemetry reaches the traces endpoint
* - push WITHOUT the flag: no attribution span names on the wire
* - push WITH the flag: attribution spans arrive alongside usage spans
*/
import { execFileSync } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'
import { Command } from 'commander'
import { startMockIdp, type MockIdp } from './fixtures/mock-idp.js'
import type { ProjectSummary, SessionSummary, ParsedApiCall, TokenUsage } from '../src/types.js'
import { setHome } from './setup/home.js'
const { parseAllSessionsMock } = vi.hoisted(() => ({ parseAllSessionsMock: vi.fn() }))
vi.mock('../src/parser.js', () => ({ parseAllSessions: parseAllSessionsMock }))
function git(cwd: string, args: string[], env: Record<string, string> = {}): string {
return execFileSync('git', args, { cwd, encoding: 'utf-8', env: { ...process.env, ...env } }).trim()
}
function makeUsage(): TokenUsage {
return { inputTokens: 10, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 }
}
function makeCall(key: string, ts: string): ParsedApiCall {
return {
provider: 'test', model: 'test-model', usage: makeUsage(), costUSD: 0.01,
tools: [], mcpTools: [], skills: [], subagentTypes: [], hasAgentSpawn: false,
hasPlanMode: false, speed: 'standard', timestamp: ts, bashCommands: [],
deduplicationKey: key,
}
}
function makeSession(id: string, first: string, last: string, calls: ParsedApiCall[]): SessionSummary {
return {
sessionId: id, project: 'app', firstTimestamp: first, lastTimestamp: last,
totalCostUSD: 0.01, totalSavingsUSD: 0, totalInputTokens: 10, totalOutputTokens: 5,
totalReasoningTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
apiCalls: calls.length,
turns: [{ userMessage: 'x', assistantCalls: calls, timestamp: first, sessionId: id, category: 'coding', retries: 0, hasEdits: false }],
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'], skillBreakdown: {}, subagentBreakdown: {},
}
}
let idp: MockIdp
let tmpHome: string
let repoDir: string
const originalHome = process.env.HOME
const originalXdg = process.env.XDG_CACHE_HOME
const originalStore = process.env.CODEBURN_SYNC_TOKEN_STORE
async function runPush(args: string[]): Promise<void> {
const { registerSyncCommands } = await import('../src/sync/cli.js')
const program = new Command()
program.exitOverride() // throw instead of process.exit on commander errors
registerSyncCommands(program)
await program.parseAsync(['node', 'codeburn', 'sync', 'push', ...args])
}
beforeAll(async () => {
idp = await startMockIdp({ rotateTokens: false })
process.env.CODEBURN_SYNC_TOKEN_STORE = 'file'
// Real git repo with a remote — a recent commit inside the session window
repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-cli-repo-'))
git(repoDir, ['init', '-b', 'main'])
git(repoDir, ['config', 'user.email', 't@e.com'])
git(repoDir, ['config', 'user.name', 'T'])
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/cli-widget.git'])
await writeFile(join(repoDir, 'f.txt'), 'x\n')
const commitIso = new Date(Date.now() - 60 * 60 * 1000).toISOString()
git(repoDir, ['add', '.'])
git(repoDir, ['commit', '-m', 'feat: recent'], { GIT_AUTHOR_DATE: commitIso, GIT_COMMITTER_DATE: commitIso })
})
afterAll(async () => {
await idp.close()
await rm(repoDir, { recursive: true, force: true })
if (originalStore === undefined) delete process.env.CODEBURN_SYNC_TOKEN_STORE
else process.env.CODEBURN_SYNC_TOKEN_STORE = originalStore
})
beforeEach(async () => {
tmpHome = await mkdtemp(join(tmpdir(), 'codeburn-attr-cli-'))
setHome(tmpHome)
process.env.XDG_CACHE_HOME = join(tmpHome, '.cache')
idp.tracesRequests.length = 0
// Configure sync against the mock IdP + store the refresh token
const { writeSyncConfig } = await import('../src/sync/config.js')
const { createCredentialStore } = await import('../src/sync/credentials.js')
writeSyncConfig({ baseUrl: idp.baseUrl, clientId: 'mock-client-id', tracesPath: '/v1/traces', issuer: idp.baseUrl })
createCredentialStore().store('mock-refresh-token-v1')
// One session in the last hour, working in the real repo
const now = Date.now()
const first = new Date(now - 90 * 60 * 1000).toISOString()
const last = new Date(now - 30 * 60 * 1000).toISOString()
const session = makeSession('cli-sess-1', first, last, [makeCall(`call-${now}`, first)])
session.workingDirectory = repoDir
parseAllSessionsMock.mockResolvedValue([
{ project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
])
// The push action reads process.cwd() for the attribution cwd — run from
// a neutral non-repo dir so nothing can come from a cwd fallback.
vi.spyOn(process, 'cwd').mockReturnValue(tmpHome)
})
afterEach(async () => {
vi.restoreAllMocks()
process.exitCode = 0
setHome(originalHome)
if (originalXdg === undefined) delete process.env.XDG_CACHE_HOME
else process.env.XDG_CACHE_HOME = originalXdg
await rm(tmpHome, { recursive: true, force: true })
})
const spanNames = (): string[] =>
idp.tracesRequests.flatMap(r => {
const body = r.body as { resourceSpans?: Array<{ scopeSpans: Array<{ spans: Array<{ name: string }> }> }> }
return (body.resourceSpans ?? []).flatMap(rs => rs.scopeSpans.flatMap(ss => ss.spans.map(s => s.name)))
})
describe('sync push --attribution (CLI level)', () => {
it('--dry-run --attribution sends nothing to the traces endpoint', async () => {
await runPush(['--dry-run', '--attribution'])
expect(idp.tracesRequests).toHaveLength(0)
})
it('push WITHOUT --attribution never emits attribution span names', async () => {
await runPush([])
expect(idp.tracesRequests.length).toBeGreaterThan(0)
const names = spanNames()
expect(names.length).toBeGreaterThan(0)
expect(names).not.toContain('codeburn.session.attribution')
expect(names).not.toContain('codeburn.commit')
expect(JSON.stringify(idp.tracesRequests)).not.toContain('git.sha')
})
it('push WITH --attribution emits usage + attribution spans', async () => {
await runPush(['--attribution'])
const names = spanNames()
expect(names).toContain('test/test-model') // usage span
expect(names).toContain('codeburn.session.attribution') // session span
expect(names).toContain('codeburn.commit') // commit span
const wire = JSON.stringify(idp.tracesRequests)
expect(wire).toContain('github.com/acme/cli-widget')
})
it('never puts a filesystem path on the wire as ai.project', async () => {
const now = Date.now()
const first = new Date(now - 90 * 60 * 1000).toISOString()
const last = new Date(now - 30 * 60 * 1000).toISOString()
const session = makeSession('cli-sess-1', first, last, [makeCall(`call-${now}`, first)])
parseAllSessionsMock.mockResolvedValue([
{ project: `-${repoDir.replace(/^\//, '').replace(/\//g, '-')}`, projectPath: repoDir, sessions: [session] } as ProjectSummary,
])
await runPush(['--attribution'])
const wire = JSON.stringify(idp.tracesRequests)
expect(wire).not.toContain(repoDir) // no absolute path
expect(wire).not.toContain(repoDir.replace(/\//g, '-')) // no slugified path
const projects = idp.tracesRequests.flatMap(r => (r.body as any).resourceSpans
.flatMap((rs: any) => rs.scopeSpans.flatMap((ss: any) => ss.spans))
.flatMap((s: any) => s.attributes.filter((a: any) => a.key === 'ai.project')
.map((a: any) => a.value.stringValue)))
// Under fail-closed provenance (#1128) this fixture carries no trusted
// provider-recorded cwd, so ai.project is OMITTED entirely — stronger than
// the #1126 basename this test originally asserted. The positive case
// (trusted cwd -> basename on the wire) lives in sync-project-provenance.
expect(projects).toEqual([])
})
})