import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { archiveScanDirs, derivedDataFor, leakNeedles, leakProblem, outsideHome, pathFreeSettings, prefixMaps, scanForPaths } from '../cli/lib/path-free'; import { archiveArgs, validateAppleBuild } from '../cli/lib/xcode'; const resolve = (p: string): string => (p.startsWith('/') ? p : `/proj/${p}`); const t = { framework: 'native' as const, project: 'App.xcodeproj', scheme: 'App', notes: [] }; describe('derived data', () => { test('flag beats config beats /DerivedData; without a chosen out Xcode keeps its default', () => { expect(derivedDataFor({ flag: '/dd', config: 'cfg', out: '/o', outChosen: true, resolve })).toBe('/dd'); expect(derivedDataFor({ config: 'cfg/dd', out: '/o', outChosen: true, resolve })).toBe('/proj/cfg/dd'); expect(derivedDataFor({ out: '/shared/ci/out', outChosen: true, resolve })).toBe('/shared/ci/out/DerivedData'); expect(derivedDataFor({ out: '/proj/build/ios', outChosen: false, resolve })).toBeNull(); }); test('archive gets -derivedDataPath only when set', () => { const base = { target: t, root: '/p', configuration: 'Release', archivePath: '/o/App.xcarchive', build: '1', auth: null }; const withDd = archiveArgs({ ...base, derivedData: '/o/DerivedData' }); expect(withDd.slice(withDd.indexOf('-archivePath'), withDd.indexOf('-archivePath') + 4)).toEqual(['-archivePath', '/o/App.xcarchive', '-derivedDataPath', '/o/DerivedData']); expect(archiveArgs(base)).not.toContain('-derivedDataPath'); expect(archiveArgs({ ...base, derivedData: null })).not.toContain('-derivedDataPath'); }); test('config keys are validated', () => { expect(validateAppleBuild({ derivedData: 'dd', pathFree: true })).toEqual([]); expect(validateAppleBuild({ derivedData: ' ' })).toHaveLength(1); expect(validateAppleBuild({ pathFree: 'yes' as unknown as boolean })).toHaveLength(1); }); test('outsideHome', () => { expect(outsideHome('/srv/shared/ci/out', '/Users/you')).toBe(true); expect(outsideHome('/Users/you/proj/out', '/Users/you')).toBe(false); expect(outsideHome('/Users/you-too/out', '/Users/you')).toBe(true); }); }); describe('prefix maps', () => { const i = { home: '/Users/you', root: '/Users/you/work/app', out: '/Users/you/work/app/build/ios', derivedData: '/Users/you/work/app/build/ios/DerivedData' }; test('longest first (swiftc takes the first match; clang gets the list reversed)', () => { expect(prefixMaps(i).map(m => m[0])).toEqual(['/Users/you/work/app/build/ios/DerivedData', '/Users/you/work/app/build/ios', '/Users/you/work/app', '/Users/you']); }); test('duplicates and a root slash are dropped', () => { expect(prefixMaps({ home: '/h/', root: '/h', out: '/h', derivedData: null }).map(m => m[0])).toEqual(['/h']); }); test('settings carry swift and clang flags and keep $(inherited)', () => { const s = pathFreeSettings(i); expect(s.map(x => x.split('=')[0])).toEqual(['SWIFT_SERIALIZE_DEBUGGING_OPTIONS', 'OTHER_SWIFT_FLAGS', 'OTHER_CFLAGS', 'OTHER_CPLUSPLUSFLAGS']); expect(s[0]!).toBe('SWIFT_SERIALIZE_DEBUGGING_OPTIONS=NO'); expect(s[1]!).toContain('$(inherited) -debug-prefix-map /Users/you/work/app/build/ios/DerivedData=/derived -file-prefix-map /Users/you/work/app/build/ios/DerivedData=/derived'); expect(s[1]!.endsWith('-file-prefix-map /Users/you=/home')).toBe(true); expect(s[1]!.indexOf('app=.')).toBeLessThan(s[1]!.indexOf('/Users/you=/home')); // clang: the last match wins, so the home folder comes first expect(s[2]!.indexOf('-ffile-prefix-map=/Users/you=/home')).toBeLessThan(s[2]!.indexOf('-ffile-prefix-map=/Users/you/work/app/build/ios/DerivedData=/derived')); expect(s.join()).not.toContain('DEBUG_INFORMATION_FORMAT'); }); test('a path with spaces is quoted', () => { const s = pathFreeSettings({ home: '/Users/you', root: '/Users/you/Visual Studio Workspaces/app', out: '/Users/you/o' }); expect(s[1]!).toContain('"/Users/you/Visual Studio Workspaces/app=."'); expect(s[2]!).toContain('"-ffile-prefix-map=/Users/you/Visual Studio Workspaces/app=."'); }); }); describe('leak scan', () => { const dir = mkdtempSync(join(tmpdir(), 'pf-')); const arch = join(dir, 'App.xcarchive'); mkdirSync(join(arch, 'Products/Applications/App.app'), { recursive: true }); mkdirSync(join(arch, 'dSYMs/App.app.dSYM'), { recursive: true }); mkdirSync(join(arch, 'Symbols'), { recursive: true }); writeFileSync(join(arch, 'Products/Applications/App.app/App'), Buffer.concat([Buffer.from([0, 1, 2]), Buffer.from('/Users/you/work/app/Sources/A.swift'), Buffer.from([0]), Buffer.from('/Users/you/Library/Developer/Xcode/DerivedData/x')])); writeFileSync(join(arch, 'dSYMs/App.app.dSYM/clean'), 'DW_AT_comp_dir ./Sources /Applications/Xcode.app/Contents'); writeFileSync(join(arch, 'Symbols/0.symbols'), 'path /Users/you/work/app/B.swift'); const needles = leakNeedles({ home: '/Users/you', user: 'you', root: '/Users/you/work/app' }); test('needles are named by kind; /Users// is dropped when it equals the home folder', () => { expect(needles.map(n => n.kind)).toEqual(['home folder', 'checkout']); }); test('a shared build folder outside the home folder is not a needle (dsymutil / module cache paths there are fine)', () => { expect(leakNeedles({ home: '/Users/you', user: 'you', root: '/Users/Shared/ship/app/abc' }).map(n => n.kind)).toEqual(['home folder']); expect(leakNeedles({ home: '/Users/you', user: 'you', root: '/opt/builds/you/app' }).map(n => n.kind)).toEqual(['home folder', 'checkout']); }); test('counts hits, keeps first offsets per file, scans app, dSYMs and Symbols', () => { const r = scanForPaths(archiveScanDirs(arch), needles); expect(r.files).toBe(2); expect(r.total).toBeGreaterThanOrEqual(5); const app = r.hits.filter(h => h.file === 'Applications/App.app/App'); expect(app.length).toBe(3); expect(app[0]!.offset).toBe(3); expect(r.hits.some(h => h.file === '0.symbols')).toBe(true); expect(r.hits.some(h => h.file.includes('clean'))).toBe(false); }); test('the report has counts, kinds, files and offsets but never the paths', () => { const msg = leakProblem(scanForPaths(archiveScanDirs(arch), needles), 'build/App.xcarchive')!; expect(msg).toContain('local paths left in 2 files'); expect(msg).toContain('Applications/App.app/App @3: home folder'); expect(msg).not.toContain('/Users/you'); }); test('a clean archive has no problem', () => { const clean = join(dir, 'Clean.xcarchive'); mkdirSync(join(clean, 'Products'), { recursive: true }); writeFileSync(join(clean, 'Products/a'), 'x /Applications/Xcode.app/y'); expect(leakProblem(scanForPaths(archiveScanDirs(clean), needles), 'c')).toBeNull(); rmSync(dir, { recursive: true, force: true }); }); });