/** * Tests for the prerequisite-detection module. * * Coverage: * - Version regex parses real-world output for each tool in the table * - compareVersions handles MAJOR.MINOR.PATCH (and MAJOR.MINOR) edge cases * - getInstallHint returns the right string per tool/pm combination, * including fallback paths for tools without a native package * - checkPrerequisite returns the documented shape for present + missing * binaries, and exercises the "binary present but version unparseable" * edge case * - PREREQUISITES table is internally consistent (no duplicate names, * every entry has the fields the type requires) */ import { describe, expect, test } from 'bun:test'; import { unlinkSync, writeFileSync } from 'node:fs'; import { BROWSER_EXECUTABLE_PATH } from '@celilo/capabilities'; import { PREREQUISITES, checkPrerequisite, compareVersions, detectPackageManager, failingPrerequisites, getInstallHint, } from './prereqs'; // ── PREREQUISITES table consistency ─────────────────────────────────── describe('PREREQUISITES table', () => { test('has no duplicate tool names', () => { const names = PREREQUISITES.map((p) => p.name); expect(new Set(names).size).toBe(names.length); }); test('every entry has the required fields', () => { for (const spec of PREREQUISITES) { expect(spec.name).toBeTruthy(); expect(spec.description).toBeTruthy(); expect(spec.versionFlag).toBeTruthy(); expect(spec.versionRegex).toBeInstanceOf(RegExp); // minVersion may be null; just check the property exists expect('minVersion' in spec).toBe(true); } }); test('contains the tools the design doc requires', () => { const names = PREREQUISITES.map((p) => p.name); expect(names).toContain('bun'); expect(names).toContain('ansible'); expect(names).toContain('ansible-galaxy'); expect(names).toContain('terraform'); expect(names).toContain('ssh'); expect(names).toContain('git'); expect(names).toContain('curl'); expect(names).toContain('unzip'); expect(names).toContain('browser'); expect(names).toContain('fonts'); }); }); // ── Version regex per tool ──────────────────────────────────────────── describe('version regex', () => { function probe(toolName: string, sampleOutput: string): string | null { const spec = PREREQUISITES.find((p) => p.name === toolName); if (!spec) throw new Error(`No spec for ${toolName}`); const match = sampleOutput.match(spec.versionRegex); return match?.[1] ?? null; } test('bun --version', () => { expect(probe('bun', '1.3.3')).toBe('1.3.3'); expect(probe('bun', '1.3.3\n')).toBe('1.3.3'); }); test('ansible --version (modern, "core 2.16.3")', () => { expect( probe( 'ansible', 'ansible [core 2.16.3]\n config file = None\n configured module search path = ...', ), ).toBe('2.16.3'); }); test('ansible --version (legacy, "ansible 2.9.27")', () => { expect(probe('ansible', 'ansible 2.9.27\n config file = None')).toBe('2.9.27'); }); test('ansible-galaxy --version', () => { expect(probe('ansible-galaxy', 'ansible-galaxy [core 2.16.3]\n python version = 3.11.4')).toBe( '2.16.3', ); }); test('terraform version', () => { expect( probe( 'terraform', 'Terraform v1.6.6\non darwin_arm64\n\nYour version of Terraform is out of date!', ), ).toBe('1.6.6'); }); test('ssh -V (writes to stderr)', () => { expect(probe('ssh', 'OpenSSH_9.6p1, LibreSSL 3.3.6')).toBe('9.6'); // Older OpenSSH formats with space rather than underscore expect(probe('ssh', 'OpenSSH 8.2p1 Ubuntu-4ubuntu0.11')).toBe('8.2'); }); test('git --version', () => { expect(probe('git', 'git version 2.45.2')).toBe('2.45.2'); }); test('curl --version (multi-line, version is on first line)', () => { expect( probe( 'curl', 'curl 8.6.0 (x86_64-pc-linux-gnu) libcurl/8.6.0 OpenSSL/3.1.4\nRelease-Date: 2024-01-31', ), ).toBe('8.6.0'); }); test('unzip -v', () => { expect( probe( 'unzip', 'UnZip 6.00 of 20 April 2009, by Debian. Original by Info-ZIP.\n\nLatest sources...', ), ).toBe('6.00'); }); test('returns null when output is unrecognized', () => { const spec = PREREQUISITES.find((p) => p.name === 'terraform'); expect(spec?.versionRegex.exec('completely unrelated output')?.[1]).toBeUndefined(); }); }); // ── compareVersions ─────────────────────────────────────────────────── describe('compareVersions', () => { test('equal triples → 0', () => { expect(compareVersions('2.16.3', '2.16.3')).toBe(0); }); test('strictly less → -1', () => { expect(compareVersions('2.15.0', '2.16.0')).toBe(-1); expect(compareVersions('2.16.2', '2.16.3')).toBe(-1); expect(compareVersions('1.6.5', '2.0.0')).toBe(-1); }); test('strictly greater → 1', () => { expect(compareVersions('2.17.0', '2.16.0')).toBe(1); expect(compareVersions('2.16.4', '2.16.3')).toBe(1); expect(compareVersions('3.0.0', '2.16.99')).toBe(1); }); test('handles missing components (treats as 0)', () => { expect(compareVersions('2.16', '2.16.0')).toBe(0); expect(compareVersions('2.16', '2.16.1')).toBe(-1); expect(compareVersions('2.16.1', '2.16')).toBe(1); }); test('handles non-numeric segments by treating as 0', () => { // We don't try to parse pre-release tags. "2.16.3-rc1" and "2.16.3" // compare equal — that's intentional. Ansible's pre-releases are // rare in operator-installed boxes. expect(compareVersions('2.16.3-rc1', '2.16.3')).toBe(0); }); test('realistic ansible version comparison (2.16.3 satisfies 2.15.0 minimum)', () => { expect(compareVersions('2.16.3', '2.15.0')).toBe(1); }); test('realistic terraform version comparison (1.5.7 below 1.6.0 minimum)', () => { expect(compareVersions('1.5.7', '1.6.0')).toBe(-1); }); }); // ── getInstallHint ──────────────────────────────────────────────────── describe('getInstallHint', () => { test('apt: standard tools resolve to "sudo apt-get install "', () => { expect(getInstallHint('ansible', 'apt')).toBe('sudo apt-get install ansible'); expect(getInstallHint('git', 'apt')).toBe('sudo apt-get install git'); expect(getInstallHint('curl', 'apt')).toBe('sudo apt-get install curl'); expect(getInstallHint('unzip', 'apt')).toBe('sudo apt-get install unzip'); }); test('apt: ssh maps to openssh-client (Debian package name differs)', () => { expect(getInstallHint('ssh', 'apt')).toBe('sudo apt-get install openssh-client'); }); test('apt: terraform falls through to HashiCorp docs link', () => { expect(getInstallHint('terraform', 'apt')).toBe( 'See https://developer.hashicorp.com/terraform/install', ); }); test('apt: bun has no native package — falls through to docs', () => { expect(getInstallHint('bun', 'apt')).toBe('See https://bun.sh/install'); }); test('apt: ansible-galaxy points the operator at ansible', () => { expect(getInstallHint('ansible-galaxy', 'apt')).toContain('install ansible'); }); test('brew: tools available via brew', () => { expect(getInstallHint('ansible', 'brew')).toBe('brew install ansible'); expect(getInstallHint('terraform', 'brew')).toBe('brew install terraform'); expect(getInstallHint('bun', 'brew')).toBe('brew install oven-sh/bun/bun'); }); test('brew: ssh/curl/unzip fall through (built into macOS)', () => { expect(getInstallHint('ssh', 'brew')).toBe("Install 'ssh' via your package manager"); expect(getInstallHint('curl', 'brew')).toBe("Install 'curl' via your package manager"); expect(getInstallHint('unzip', 'brew')).toBe("Install 'unzip' via your package manager"); }); test('pacman: terraform IS available via pacman (unlike apt)', () => { expect(getInstallHint('terraform', 'pacman')).toBe('sudo pacman -S terraform'); }); test('apk: ssh maps to openssh-client', () => { expect(getInstallHint('ssh', 'apk')).toBe('sudo apk add openssh-client'); }); test('none: unrecognized package manager falls back to generic guidance', () => { expect(getInstallHint('ansible', 'none')).toBe("Install 'ansible' via your package manager"); // bun and terraform have docs links that work regardless of pm expect(getInstallHint('bun', 'none')).toBe('See https://bun.sh/install'); expect(getInstallHint('terraform', 'none')).toBe( 'See https://developer.hashicorp.com/terraform/install', ); }); test('unknown tool name: generic fallback', () => { expect(getInstallHint('totally-fictional-tool', 'apt')).toBe( "Install 'totally-fictional-tool' via your package manager", ); }); }); // ── checkPrerequisite ───────────────────────────────────────────────── describe('checkPrerequisite', () => { test('present binary returns present:true with a path', () => { // bun is the celilo runtime; if these tests are running, bun is there. const spec = PREREQUISITES.find((p) => p.name === 'bun'); if (!spec) throw new Error('bun spec missing'); const result = checkPrerequisite(spec); expect(result.present).toBe(true); expect(result.binaryPath).toBeTruthy(); expect(result.version).toMatch(/^\d+\.\d+\.\d+$/); expect(result.meetsMinimum).toBe(true); }); test('missing binary returns present:false with installHint populated', () => { const spec = { name: 'definitely-not-installed-aaaaaa', description: 'fictional', versionFlag: '--version', versionRegex: /(\d+\.\d+\.\d+)/, minVersion: null, }; const result = checkPrerequisite(spec); expect(result.present).toBe(false); expect(result.binaryPath).toBeNull(); expect(result.version).toBeNull(); expect(result.meetsMinimum).toBe(false); expect(result.installHint).toBeTruthy(); // generic fallback at minimum }); test('present binary with minVersion → meetsMinimum reflects comparison', () => { // Synthesize a spec that demands an impossibly-high bun version const spec = { name: 'bun', description: 'celilo runtime', versionFlag: '--version', versionRegex: /(\d+\.\d+\.\d+)/, minVersion: '999.0.0', }; const result = checkPrerequisite(spec); expect(result.present).toBe(true); expect(result.version).toMatch(/^\d+\.\d+\.\d+$/); expect(result.meetsMinimum).toBe(false); }); test('present binary with parse-fail leaves version null', () => { // Force a regex that won't match bun's version output const spec = { name: 'bun', description: 'celilo runtime', versionFlag: '--version', versionRegex: /THIS_WILL_NEVER_MATCH_(\w+)/, minVersion: null, }; const result = checkPrerequisite(spec); expect(result.present).toBe(true); expect(result.version).toBeNull(); // No minimum, parse-fail still passes expect(result.meetsMinimum).toBe(true); }); test('present binary with parse-fail AND minimum → meetsMinimum:false', () => { const spec = { name: 'bun', description: 'celilo runtime', versionFlag: '--version', versionRegex: /THIS_WILL_NEVER_MATCH_(\w+)/, minVersion: '1.0.0', }; const result = checkPrerequisite(spec); expect(result.present).toBe(true); expect(result.version).toBeNull(); expect(result.meetsMinimum).toBe(false); }); }); // ── Absolute-path prerequisites (the browser) ───────────────────────── describe('absolute-path prerequisites', () => { test('a real executable at an absolute path is present, with its version', () => { // `bun --version` prints a bare semver, and Bun.which finds a real path // for it — so this exercises the absolute-path branch end to end. const bunPath = Bun.which('bun'); if (!bunPath) throw new Error('bun is not on PATH'); const result = checkPrerequisite({ name: 'browser', description: 'stand-in for the provisioned browser', command: bunPath, versionFlag: '--version', versionRegex: /(\d+\.\d+\.\d+)/, minVersion: null, }); expect(result.present).toBe(true); expect(result.binaryPath).toBe(bunPath); expect(result.version).toMatch(/^\d+\.\d+\.\d+$/); }); test('a DIRECTORY at the declared path is not present', () => { // The case that motivates the whole check: a build directory with no // binary in it satisfies a path test and then fails at launch. const result = checkPrerequisite({ name: 'browser', description: 'stand-in for the provisioned browser', command: '/tmp', versionFlag: '--version', versionRegex: /(\d+\.\d+\.\d+)/, minVersion: null, }); expect(result.present).toBe(false); expect(result.binaryPath).toBeNull(); }); test('a non-executable file at the declared path is not present', () => { const path = `/tmp/celilo-prereq-not-executable-${process.pid}`; writeFileSync(path, 'not a browser', { mode: 0o644 }); try { const result = checkPrerequisite({ name: 'browser', description: 'stand-in for the provisioned browser', command: path, versionFlag: '--version', versionRegex: /(\d+\.\d+\.\d+)/, minVersion: null, }); expect(result.present).toBe(false); } finally { unlinkSync(path); } }); test('the browser row names install_browser as its remedy', () => { const spec = PREREQUISITES.find((p) => p.name === 'browser'); if (!spec) throw new Error('browser spec missing'); expect(spec.command).toBe(BROWSER_EXECUTABLE_PATH); expect(getInstallHint('browser', 'apt')).toContain('install_browser'); }); test('fc-match output yields the resolved font family', () => { const spec = PREREQUISITES.find((p) => p.name === 'fonts'); if (!spec) throw new Error('fonts spec missing'); const sample = 'DejaVuSans.ttf: "DejaVu Sans" "Book"'; expect(sample.match(spec.versionRegex)?.[1]).toBe('DejaVu Sans'); }); }); // ── failingPrerequisites ────────────────────────────────────────────── describe('failingPrerequisites', () => { test('returns only entries that are missing or below-minimum', () => { const checks = [ { name: 'a', description: '', present: true, binaryPath: '/bin/a', version: '1.0.0', meetsMinimum: true, installHint: '', }, { name: 'b', description: '', present: false, binaryPath: null, version: null, meetsMinimum: false, installHint: '', }, { name: 'c', description: '', present: true, binaryPath: '/bin/c', version: '0.5.0', meetsMinimum: false, installHint: '', }, { name: 'd', description: '', present: true, binaryPath: '/bin/d', version: null, meetsMinimum: true, installHint: '', }, ]; const failing = failingPrerequisites(checks); expect(failing.map((c) => c.name)).toEqual(['b', 'c']); }); test('empty input → empty result', () => { expect(failingPrerequisites([])).toEqual([]); }); }); // ── detectPackageManager (smoke test only) ─────────────────────────── describe('detectPackageManager', () => { test('returns one of the known PackageManager values', () => { const pm = detectPackageManager(); expect(['apt', 'dnf', 'yum', 'pacman', 'apk', 'brew', 'none']).toContain(pm); }); });