/** * The fingerprint has one job: change when the baked CLI would change, and not * otherwise. Both halves are failure modes. A fingerprint that misses an edit * lets a stale image pass as fresh, which is the bug this exists to catch; one * that changes on every unrelated edit produces a warning nobody reads, which * is the same outcome by a different route. * * So each test here edits a real throwaway checkout and asserts which way the * answer moved. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { execFileSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { applyWorkingTree, bakedSourcePaths, computeSourceFingerprint, fingerprintFrom, readWorkingTreeEntries, } from './source-fingerprint'; let repo: string; function git(...args: string[]): void { execFileSync('git', ['-C', repo, ...args], { stdio: 'ignore' }); } function write(relative: string, content: string): void { const full = join(repo, relative); mkdirSync(join(full, '..'), { recursive: true }); writeFileSync(full, content); } beforeEach(() => { repo = mkdtempSync(join(tmpdir(), 'fingerprint-repo-')); write('apps/celilo/package.json', '{"name":"@celilo/cli","version":"1.0.0"}\n'); write('apps/celilo/src/index.ts', 'export const version = 1;\n'); write('packages/capabilities/src/index.ts', 'export const cap = 1;\n'); write('packages/capabilities/package.json', '{"name":"@celilo/capabilities"}\n'); write('packages/e2e/src/runner.ts', 'export const harness = 1;\n'); write('packages/e2e/package.json', '{"name":"@celilo/e2e"}\n'); git('init', '-q'); git('config', 'user.email', 'test@celilo.invalid'); git('config', 'user.name', 'test'); git('add', '-A'); git('commit', '-qm', 'initial'); }); afterEach(() => rmSync(repo, { recursive: true, force: true })); describe('bakedSourcePaths', () => { test('covers the app and every package, derived from the tree', () => { const paths = bakedSourcePaths(repo); expect(paths).toContain('apps/celilo/src'); expect(paths).toContain('apps/celilo/drizzle'); expect(paths).toContain('packages/capabilities/src'); expect(paths).toContain('packages/capabilities/package.json'); }); test('excludes packages/e2e, which runs from the worktree and is never stale', () => { // Including it would fire the warning continuously for anyone working on // the rig, which is the fastest way to teach people to ignore it. expect(bakedSourcePaths(repo).some((p) => p.startsWith('packages/e2e'))).toBe(false); }); test('a new package is covered the day it is added, without editing a list', () => { write('packages/brand-new/src/index.ts', 'export const x = 1;\n'); expect(bakedSourcePaths(repo)).toContain('packages/brand-new/src'); }); }); describe('fingerprintFrom', () => { test('is stable for the same inputs', () => { const files = [{ path: 'a.ts', hash: 'abc' }]; expect(fingerprintFrom(files)).toBe(fingerprintFrom(files)); }); test('does not depend on the order git happened to list files in', () => { const a = { path: 'a.ts', hash: 'aa' }; const b = { path: 'b.ts', hash: 'bb' }; expect(fingerprintFrom([a, b])).toBe(fingerprintFrom([b, a])); }); test('a changed file content is a different fingerprint', () => { expect(fingerprintFrom([{ path: 'a.ts', hash: 'aa' }])).not.toBe( fingerprintFrom([{ path: 'a.ts', hash: 'zz' }]), ); }); test('a removed file is a different fingerprint', () => { expect(fingerprintFrom([{ path: 'a.ts', hash: 'aa' }])).not.toBe(fingerprintFrom([])); }); }); describe('applyWorkingTree', () => { const tracked = [ { path: 'a.ts', hash: 'indexed-a' }, { path: 'b.ts', hash: 'indexed-b' }, ]; test('an edit on disk overrides what the index holds', () => { const out = applyWorkingTree(tracked, [ { status: ' M', path: 'a.ts', contentHash: 'ondisk-a' }, ]); expect(out.find((f) => f.path === 'a.ts')?.hash).toBe('ondisk-a'); expect(out.find((f) => f.path === 'b.ts')?.hash).toBe('indexed-b'); }); test('an untracked file is added', () => { const out = applyWorkingTree(tracked, [ { status: '??', path: 'c.ts', contentHash: 'ondisk-c' }, ]); expect(out).toHaveLength(3); }); test('a deleted file is removed, not recorded as empty', () => { const out = applyWorkingTree(tracked, [{ status: ' D', path: 'a.ts', contentHash: '' }]); expect(out.map((f) => f.path)).toEqual(['b.ts']); }); }); describe('computeSourceFingerprint', () => { test('a clean checkout fingerprints, and repeats', () => { const first = computeSourceFingerprint(repo); expect(first).not.toBeNull(); expect(computeSourceFingerprint(repo)).toBe(first as string); }); test('an UNCOMMITTED edit to baked source moves it', () => { // The case that matters most in practice: an agent edits the CLI, does not // commit, runs a suite, and the image is a bake behind. const before = computeSourceFingerprint(repo); write('apps/celilo/src/index.ts', 'export const version = 2;\n'); expect(computeSourceFingerprint(repo)).not.toBe(before as string); }); test('a DIFFERENT BRANCH with identical baked source fingerprints the same', () => { // The reason this hashes content rather than HEAD. This machine runs seven // agent worktrees on seven branches against one Docker daemon, and so one // baked image. Most of those branches touch no CLI source at all; keying on // the commit would make every one of them disagree with the image over an // unrelated change, and a warning that is usually wrong is one nobody reads. const onMain = computeSourceFingerprint(repo); git('checkout', '-qb', 'some-other-branch'); write('README.md', '# a change that touches no baked source\n'); git('add', '-A'); git('commit', '-qm', 'unrelated work'); expect(computeSourceFingerprint(repo)).toBe(onMain as string); }); test('a committed edit moves it too', () => { const before = computeSourceFingerprint(repo); write('apps/celilo/src/index.ts', 'export const version = 3;\n'); git('add', '-A'); git('commit', '-qm', 'change'); expect(computeSourceFingerprint(repo)).not.toBe(before as string); }); test('a NEW untracked file under baked source moves it', () => { const before = computeSourceFingerprint(repo); write('apps/celilo/src/extra.ts', 'export const extra = 1;\n'); expect(computeSourceFingerprint(repo)).not.toBe(before as string); }); test('an edit to packages/e2e does NOT move it', () => { const before = computeSourceFingerprint(repo); write('packages/e2e/src/runner.ts', 'export const harness = 99;\n'); expect(computeSourceFingerprint(repo)).toBe(before as string); }); test('an edit outside the baked paths does NOT move it', () => { const before = computeSourceFingerprint(repo); write('README.md', '# unrelated\n'); expect(computeSourceFingerprint(repo)).toBe(before as string); }); test('a directory that is not a celilo checkout has no fingerprint', () => { expect(computeSourceFingerprint(mkdtempSync(join(tmpdir(), 'empty-')))).toBeNull(); }); test('no path at all has no fingerprint — an npm consumer has no source', () => { expect(computeSourceFingerprint(undefined)).toBeNull(); }); }); describe('readWorkingTreeEntries', () => { test('reports a modified file with its current content hash', () => { write('apps/celilo/src/index.ts', 'export const version = 7;\n'); const entries = readWorkingTreeEntries(repo, ['apps/celilo/src']); expect(entries).toHaveLength(1); expect(entries[0].path).toBe('apps/celilo/src/index.ts'); expect(entries[0].contentHash).not.toBe(''); }); test('lists an untracked file individually, not collapsed to its directory', () => { // Without -uall git reports `apps/celilo/src/` for a whole new subtree, and // two different new files under it would fingerprint identically. mkdirSync(join(repo, 'apps/celilo/src/nested'), { recursive: true }); write('apps/celilo/src/nested/one.ts', 'export const one = 1;\n'); const entries = readWorkingTreeEntries(repo, ['apps/celilo/src']); expect(entries.map((e) => e.path)).toEqual(['apps/celilo/src/nested/one.ts']); }); test('a deleted file is still a deviation, with no content to hash', () => { rmSync(join(repo, 'apps/celilo/src/index.ts')); const entries = readWorkingTreeEntries(repo, ['apps/celilo/src']); expect(entries).toHaveLength(1); expect(entries[0].contentHash).toBe(''); }); });