diff --git a/test/gbrain-detect-install.test.ts b/test/gbrain-detect-install.test.ts new file mode 100644 index 00000000..6eb7ce2d --- /dev/null +++ b/test/gbrain-detect-install.test.ts @@ -0,0 +1,298 @@ +/** + * gstack-gbrain-detect + gstack-gbrain-install — Slice 2 of /setup-gbrain. + * + * Detect: state-reporter JSON with presence, version, config, doctor health, + * and gstack-brain-sync mode. Pure introspection, no side effects. + * + * Install: D5 detect-first (reuse pre-existing clones) + D19 PATH-shadow + * validation. The install flow itself (git clone + bun install + bun link) + * is not exercised in CI because it touches the user's real ~/.bun/bin and + * network. Instead we use --validate-only to exercise the D19 check and + * --dry-run to exercise the D5 detect-first path end-to-end. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawnSync } from 'child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const DETECT = path.join(ROOT, 'bin', 'gstack-gbrain-detect'); +const INSTALL = path.join(ROOT, 'bin', 'gstack-gbrain-install'); + +// Minimal PATH with POSIX tools + homebrew (for jq/git/curl) but no user-bin +// dirs — this keeps `gbrain` out of PATH deterministically across dev machines +// while still finding jq, git, curl, sed, cat, etc. Each test can prepend a +// fake-gbrain dir when it wants to simulate presence. +const SAFE_PATH = '/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin'; + +let tmpHome: string; +let tmpHomeReal: string; + +type RunOpts = { env?: Record; cwd?: string }; +function run(bin: string, args: string[], opts: RunOpts = {}) { + const env = { + ...process.env, + GSTACK_HOME: tmpHome, + HOME: tmpHomeReal, + ...(opts.env || {}), + }; + const res = spawnSync(bin, args, { + env, + cwd: opts.cwd, + encoding: 'utf-8', + }); + return { + stdout: (res.stdout || '').trim(), + stderr: (res.stderr || '').trim(), + status: res.status ?? -1, + }; +} + +beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-detect-gstack-')); + tmpHomeReal = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-detect-home-')); +}); + +afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpHomeReal, { recursive: true, force: true }); +}); + +describe('gstack-gbrain-detect', () => { + test('emits valid JSON even when nothing is configured', () => { + // Override PATH to exclude any real gbrain so the test is deterministic. + const emptyBin = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-bin-')); + try { + const r = run(DETECT, [], { env: { PATH: `${emptyBin}:${SAFE_PATH}` } }); + expect(r.status).toBe(0); + const j = JSON.parse(r.stdout); + expect(j.gbrain_on_path).toBe(false); + expect(j.gbrain_version).toBeNull(); + expect(j.gbrain_config_exists).toBe(false); + expect(j.gbrain_engine).toBeNull(); + expect(j.gbrain_doctor_ok).toBe(false); + expect(j.gstack_brain_sync_mode).toBe('off'); + expect(j.gstack_brain_git).toBe(false); + } finally { + fs.rmSync(emptyBin, { recursive: true, force: true }); + } + }); + + test('reports gstack_brain_git: true when GSTACK_HOME has a .git dir', () => { + fs.mkdirSync(path.join(tmpHome, '.git')); + const emptyBin = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-bin-')); + try { + const r = run(DETECT, [], { env: { PATH: `${emptyBin}:${SAFE_PATH}` } }); + const j = JSON.parse(r.stdout); + expect(j.gstack_brain_git).toBe(true); + } finally { + fs.rmSync(emptyBin, { recursive: true, force: true }); + } + }); + + test('reports gbrain_config + engine when ~/.gbrain/config.json exists', () => { + // HOME is tmpHomeReal; detect reads $HOME/.gbrain/config.json. + fs.mkdirSync(path.join(tmpHomeReal, '.gbrain')); + fs.writeFileSync( + path.join(tmpHomeReal, '.gbrain', 'config.json'), + JSON.stringify({ engine: 'pglite', database_path: '/tmp/x.pglite' }) + ); + const emptyBin = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-bin-')); + try { + const r = run(DETECT, [], { env: { PATH: `${emptyBin}:${SAFE_PATH}` } }); + const j = JSON.parse(r.stdout); + expect(j.gbrain_config_exists).toBe(true); + expect(j.gbrain_engine).toBe('pglite'); + } finally { + fs.rmSync(emptyBin, { recursive: true, force: true }); + } + }); + + test('malformed config returns null engine, does not crash', () => { + fs.mkdirSync(path.join(tmpHomeReal, '.gbrain')); + fs.writeFileSync(path.join(tmpHomeReal, '.gbrain', 'config.json'), 'not valid json{'); + const emptyBin = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-bin-')); + try { + const r = run(DETECT, [], { env: { PATH: `${emptyBin}:${SAFE_PATH}` } }); + expect(r.status).toBe(0); + const j = JSON.parse(r.stdout); + expect(j.gbrain_config_exists).toBe(true); + expect(j.gbrain_engine).toBeNull(); + } finally { + fs.rmSync(emptyBin, { recursive: true, force: true }); + } + }); + + test('detects a mocked gbrain binary on PATH and reports its version', () => { + const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-bin-')); + fs.writeFileSync( + path.join(fakeBin, 'gbrain'), + '#!/bin/bash\necho "0.18.2"\nexit 0\n', + { mode: 0o755 } + ); + try { + const r = run(DETECT, [], { env: { PATH: `${fakeBin}:${SAFE_PATH}` } }); + expect(r.status).toBe(0); + const j = JSON.parse(r.stdout); + expect(j.gbrain_on_path).toBe(true); + expect(j.gbrain_version).toBe('0.18.2'); + } finally { + fs.rmSync(fakeBin, { recursive: true, force: true }); + } + }); +}); + +describe('gstack-gbrain-install D5 detect-first', () => { + test('--dry-run reuses a pre-existing ~/git/gbrain-shaped clone', () => { + // Stand up a fake ~/git/gbrain that looks valid (name + bin.gbrain). + const fakeGit = path.join(tmpHomeReal, 'git', 'gbrain'); + fs.mkdirSync(fakeGit, { recursive: true }); + fs.writeFileSync( + path.join(fakeGit, 'package.json'), + JSON.stringify({ + name: 'gbrain', + version: '0.18.2', + bin: { gbrain: './src/cli.ts' }, + }) + ); + const r = run(INSTALL, ['--dry-run']); + expect(r.status).toBe(0); + expect(r.stdout).toContain(`detected existing gbrain clone at ${fakeGit}`); + expect(r.stdout).toContain('would run bun install + bun link'); + }); + + test('--dry-run falls through to fresh clone when no valid clone detected', () => { + // No ~/git/gbrain, no ~/gbrain. + const r = run(INSTALL, ['--dry-run']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('DRY RUN: would clone'); + expect(r.stdout).toContain('https://github.com/garrytan/gbrain.git'); + }); + + test('rejects a pre-existing path that lacks a valid gbrain package.json', () => { + // Put garbage at ~/git/gbrain, but nothing at ~/gbrain. + const badGit = path.join(tmpHomeReal, 'git', 'gbrain'); + fs.mkdirSync(badGit, { recursive: true }); + fs.writeFileSync(path.join(badGit, 'package.json'), JSON.stringify({ name: 'not-gbrain' })); + const r = run(INSTALL, ['--dry-run']); + expect(r.status).toBe(0); + // Falls through to fresh clone + expect(r.stdout).toContain('DRY RUN: would clone'); + }); +}); + +describe('gstack-gbrain-install D19 PATH-shadow validation', () => { + function seedInstallDir(version: string): string { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-install-')); + fs.writeFileSync( + path.join(d, 'package.json'), + JSON.stringify({ name: 'gbrain', version, bin: { gbrain: './src/cli.ts' } }) + ); + return d; + } + + function seedFakeGbrainBinary(version: string): string { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-bin-')); + fs.writeFileSync( + path.join(binDir, 'gbrain'), + `#!/bin/bash\necho "${version}"\nexit 0\n`, + { mode: 0o755 } + ); + return binDir; + } + + test('passes when install-dir version matches `gbrain --version` on PATH', () => { + const installDir = seedInstallDir('0.18.2'); + const fakeBin = seedFakeGbrainBinary('0.18.2'); + try { + const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], { + env: { PATH: `${fakeBin}:${SAFE_PATH}` }, + }); + expect(r.status).toBe(0); + expect(r.stdout).toContain('installed gbrain 0.18.2'); + } finally { + fs.rmSync(installDir, { recursive: true, force: true }); + fs.rmSync(fakeBin, { recursive: true, force: true }); + } + }); + + test('tolerates a leading "v" in `gbrain --version` output', () => { + const installDir = seedInstallDir('0.18.2'); + const fakeBin = seedFakeGbrainBinary('v0.18.2'); + try { + const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], { + env: { PATH: `${fakeBin}:${SAFE_PATH}` }, + }); + expect(r.status).toBe(0); + } finally { + fs.rmSync(installDir, { recursive: true, force: true }); + fs.rmSync(fakeBin, { recursive: true, force: true }); + } + }); + + test('fails hard with exit 3 and PATH-shadow message on version mismatch', () => { + const installDir = seedInstallDir('0.18.2'); + const fakeBin = seedFakeGbrainBinary('0.18.1'); + try { + const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], { + env: { PATH: `${fakeBin}:${SAFE_PATH}` }, + }); + expect(r.status).toBe(3); + expect(r.stderr).toContain('PATH SHADOWING DETECTED'); + expect(r.stderr).toContain('0.18.2'); + expect(r.stderr).toContain('0.18.1'); + // Remediation menu present + expect(r.stderr).toContain('rm the shadowing binary'); + expect(r.stderr).toContain('prepend ~/.bun/bin to PATH'); + } finally { + fs.rmSync(installDir, { recursive: true, force: true }); + fs.rmSync(fakeBin, { recursive: true, force: true }); + } + }); + + test('fails hard when no gbrain on PATH after supposed install', () => { + const installDir = seedInstallDir('0.18.2'); + const emptyBin = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-bin-')); + try { + const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], { + env: { PATH: `${emptyBin}:${SAFE_PATH}` }, + }); + expect(r.status).toBe(3); + expect(r.stderr).toContain("'gbrain' is not on PATH"); + } finally { + fs.rmSync(installDir, { recursive: true, force: true }); + fs.rmSync(emptyBin, { recursive: true, force: true }); + } + }); + + test('fails hard when install-dir package.json lacks version', () => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-install-')); + fs.writeFileSync( + path.join(d, 'package.json'), + JSON.stringify({ name: 'gbrain', bin: { gbrain: './src/cli.ts' } }) + ); + try { + const r = run(INSTALL, ['--validate-only', '--install-dir', d]); + expect(r.status).toBe(3); + expect(r.stderr).toContain('cannot read version'); + } finally { + fs.rmSync(d, { recursive: true, force: true }); + } + }); +}); + +describe('gstack-gbrain-install argument handling', () => { + test('--help prints usage without exiting non-zero', () => { + const r = run(INSTALL, ['--help']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('gstack-gbrain-install'); + }); + + test('unknown flag exits 2 with an error message', () => { + const r = run(INSTALL, ['--not-a-flag']); + expect(r.status).toBe(2); + expect(r.stderr).toContain('unknown flag'); + }); +});