From 4a9d964b42881e4562495057d174c798e23e48b4 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 24 Apr 2026 01:07:30 -0700 Subject: [PATCH] test: mock-gh integration tests for gstack-brain-init auto-create path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 tests covering the gh-repo-create happy path that had zero coverage before. Existing brain-sync.test.ts always passes --remote to bypass gh entirely, so the interactive default ("press Enter, we'll run gh repo create for you") was shipping on trust. Test strategy: write a bash stub for gh that records every call into a file, then run gstack-brain-init with that stub on PATH. Assertions verify: gh auth status is checked, gh repo create fires with the computed gstack-brain- default name + --private + --source flags, fall-through to gh repo view when create reports already-exists, user-provided URL bypasses gh entirely, gh-not-on-path and gh-not-authed branches both prompt for URL, --remote flag short-circuits all gh calls, conflicting-remote re-runs exit 1 with a clear message. No real GitHub, no live auth. Gate tier — runs on every commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/gstack-brain-init-gh-mock.test.ts | 234 +++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 test/gstack-brain-init-gh-mock.test.ts diff --git a/test/gstack-brain-init-gh-mock.test.ts b/test/gstack-brain-init-gh-mock.test.ts new file mode 100644 index 00000000..7d3e85c8 --- /dev/null +++ b/test/gstack-brain-init-gh-mock.test.ts @@ -0,0 +1,234 @@ +/** + * gstack-brain-init — mocked-gh integration tests. + * + * The regular brain-sync tests pass `--remote ` to skip the + * gh-repo-creation path entirely. That left the happy path (user just + * presses Enter, gstack-brain-init calls `gh repo create --private`) + * with zero coverage — you'd only know it broke when a real user tried + * it with a real GitHub account. + * + * These tests put a fake `gh` binary on PATH that records every call + * into a file, then run gstack-brain-init in its non-flag interactive + * mode and assert the fake `gh` was invoked with the expected arguments. + * + * No real GitHub account, no live API, deterministic per-run. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN_DIR = path.join(ROOT, 'bin'); +const INIT_BIN = path.join(BIN_DIR, 'gstack-brain-init'); + +let tmpHome: string; +let bareRemote: string; +let fakeBinDir: string; +let ghCallLog: string; + +function makeFakeGh(opts: { + authStatus?: 'ok' | 'fail'; + repoCreate?: 'success' | 'already-exists' | 'fail'; + sshUrl?: string; +}) { + const authStatus = opts.authStatus ?? 'ok'; + const repoCreate = opts.repoCreate ?? 'success'; + const sshUrl = opts.sshUrl ?? bareRemote; + const script = `#!/bin/bash +echo "gh $@" >> "${ghCallLog}" +case "$1" in + auth) + ${authStatus === 'ok' ? 'exit 0' : 'exit 1'} + ;; + repo) + shift + case "$1" in + create) + ${ + repoCreate === 'success' + ? 'exit 0' + : repoCreate === 'already-exists' + ? 'echo "GraphQL: Name already exists on this account" >&2; exit 1' + : 'echo "network error" >&2; exit 1' + } + ;; + view) + # Emulate \`gh repo view --json sshUrl -q .sshUrl\` + echo "${sshUrl}" + exit 0 + ;; + esac + ;; +esac +exit 0 +`; + const ghPath = path.join(fakeBinDir, 'gh'); + fs.writeFileSync(ghPath, script, { mode: 0o755 }); + return ghPath; +} + +function run( + argv: string[], + opts: { env?: Record; input?: string } = {} +) { + const env = { + // Put the fake bin dir FIRST on PATH so our mock gh wins. + PATH: `${fakeBinDir}:/usr/bin:/bin:/opt/homebrew/bin`, + GSTACK_HOME: tmpHome, + USER: 'testuser', + HOME: tmpHome, + ...(opts.env || {}), + }; + const res = spawnSync(INIT_BIN, argv, { + env, + encoding: 'utf-8', + input: opts.input, + cwd: ROOT, + }); + return { + stdout: res.stdout || '', + stderr: res.stderr || '', + status: res.status ?? -1, + }; +} + +function readGhCalls(): string[] { + if (!fs.existsSync(ghCallLog)) return []; + return fs.readFileSync(ghCallLog, 'utf-8').trim().split('\n').filter(Boolean); +} + +beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-init-gh-mock-')); + bareRemote = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-init-bare-')); + fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-init-fake-bin-')); + ghCallLog = path.join(fakeBinDir, 'gh-calls.log'); + spawnSync('git', ['init', '--bare', '-q', '-b', 'main', bareRemote]); +}); + +afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(bareRemote, { recursive: true, force: true }); + fs.rmSync(fakeBinDir, { recursive: true, force: true }); + const remoteFile = path.join(os.homedir(), '.gstack-brain-remote.txt'); + if (fs.existsSync(remoteFile)) { + const contents = fs.readFileSync(remoteFile, 'utf-8'); + if (contents.includes(bareRemote)) fs.unlinkSync(remoteFile); + } +}); + +describe('gstack-brain-init uses gh CLI when present + authed', () => { + test('calls gh repo create --private with the computed default name', () => { + makeFakeGh({ authStatus: 'ok', repoCreate: 'success' }); + // Interactive mode; pressing Enter accepts the gh default. + const r = run([], { input: '\n' }); + expect(r.status).toBe(0); + const calls = readGhCalls(); + // First call: auth status check + expect(calls.some((c) => c.startsWith('gh auth'))).toBe(true); + // The create call + const createCall = calls.find((c) => c.startsWith('gh repo create')); + expect(createCall).toBeDefined(); + expect(createCall).toContain('gstack-brain-testuser'); + expect(createCall).toContain('--private'); + expect(createCall).toContain('--description'); + expect(createCall).toContain('--source'); + expect(createCall).toContain(tmpHome); + }); + + test('falls back to gh repo view when create reports already-exists', () => { + makeFakeGh({ authStatus: 'ok', repoCreate: 'already-exists' }); + const r = run([], { input: '\n' }); + expect(r.status).toBe(0); + const calls = readGhCalls(); + // create was attempted + expect(calls.some((c) => c.startsWith('gh repo create'))).toBe(true); + // then view was called to recover the URL + expect(calls.some((c) => c.startsWith('gh repo view') && c.includes('gstack-brain-testuser'))).toBe(true); + // The view output (bareRemote URL) should have been wired up as origin. + const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { + encoding: 'utf-8', + }); + expect(remote.stdout.trim()).toBe(bareRemote); + }); + + test('user-provided URL bypasses gh create entirely', () => { + makeFakeGh({ authStatus: 'ok', repoCreate: 'fail' }); + const r = run([], { input: `${bareRemote}\n` }); + expect(r.status).toBe(0); + const calls = readGhCalls(); + // gh auth was still checked + expect(calls.some((c) => c.startsWith('gh auth'))).toBe(true); + // but create was NOT called (user bypassed the default) + expect(calls.some((c) => c.startsWith('gh repo create'))).toBe(false); + }); +}); + +describe('gstack-brain-init without gh CLI', () => { + test('prompts for URL when gh is not on PATH', () => { + // Don't install fake gh — PATH will not have it. + // Use a bare-minimum PATH so nothing else shadows. + const stripped = `${fakeBinDir}:/usr/bin:/bin`; + const res = spawnSync(INIT_BIN, [], { + env: { + PATH: stripped, + GSTACK_HOME: tmpHome, + USER: 'testuser', + HOME: tmpHome, + }, + encoding: 'utf-8', + input: `${bareRemote}\n`, + cwd: ROOT, + }); + expect(res.status).toBe(0); + expect(res.stdout).toContain('gh CLI not found'); + // Remote got set from the stdin paste + const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { + encoding: 'utf-8', + }); + expect(remote.stdout.trim()).toBe(bareRemote); + }); + + test('prompts for URL when gh is present but not authed', () => { + makeFakeGh({ authStatus: 'fail' }); + const r = run([], { input: `${bareRemote}\n` }); + expect(r.status).toBe(0); + expect(r.stdout).toContain('gh CLI not found or not authenticated'); + const calls = readGhCalls(); + // Only `gh auth status` was called; no create attempt. + expect(calls.some((c) => c.startsWith('gh auth'))).toBe(true); + expect(calls.some((c) => c.startsWith('gh repo create'))).toBe(false); + }); +}); + +describe('idempotency via flag', () => { + test('--remote skips all gh calls', () => { + makeFakeGh({ authStatus: 'ok', repoCreate: 'success' }); + const r = run(['--remote', bareRemote]); + expect(r.status).toBe(0); + const calls = readGhCalls(); + // Zero calls to gh — the --remote flag short-circuits the interactive path. + expect(calls.length).toBe(0); + }); + + test('re-run with matching --remote is safe (no conflicting-remote error)', () => { + run(['--remote', bareRemote]); + const r2 = run(['--remote', bareRemote]); + expect(r2.status).toBe(0); + }); + + test('re-run with DIFFERENT --remote exits 1 with a conflict message', () => { + run(['--remote', bareRemote]); + const otherRemote = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-init-other-')); + spawnSync('git', ['init', '--bare', '-q', '-b', 'main', otherRemote]); + try { + const r2 = run(['--remote', otherRemote]); + expect(r2.status).not.toBe(0); + expect(r2.stderr).toContain('already a git repo'); + } finally { + fs.rmSync(otherRemote, { recursive: true, force: true }); + } + }); +});