From 8aeac5325ed3c1ee62707bca6254b0bf7ab3ebc6 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 26 Apr 2026 14:32:58 -0700 Subject: [PATCH] test: 10 new cases close coverage gaps (helper defensive paths + migration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /ship Step 7 coverage audit reported 48% (22/46 branches). Added 10 cases covering the highest-impact gaps: Helper (test/gstack-gbrain-source-wireup.test.ts, +3 cases → 19 total): - --uninstall when gbrain is missing: best-effort exit 0, worktree still cleaned - --no-pull skips HEAD advance on existing worktree (was untested) - Stray non-git directory at worktree path is cleaned up + worktree created Migration (test/gstack-upgrade-migration-v1_15_1_0.test.ts, NEW, 7 cases): - HOME unset → defensive exit 0 - gbrain_sync_mode=off → exit 0 silently - gbrain_sync_mode unset → exit 0 silently - no ~/.gstack/.git → exit 0 silently - helper missing on PATH → warning + exit 0 - happy path → invokes helper without --strict - helper exits non-zero → migration prints retry hint, still exits 0 (non-blocking) Also syncs package.json version from 1.15.0.0 → 1.15.1.0 to match VERSION file (DRIFT_STALE_PKG repair from /ship Step 12 idempotency check; was a manual-edit-bypass artifact from the merge step). Coverage estimate: 48% → ~75%. Mainline + migration script + key defensive paths all exercised. 26 tests total covering the new code surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- test/gstack-gbrain-source-wireup.test.ts | 58 +++++++ ...gstack-upgrade-migration-v1_15_1_0.test.ts | 151 ++++++++++++++++++ 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 test/gstack-upgrade-migration-v1_15_1_0.test.ts diff --git a/package.json b/package.json index a2dd52d4..221de999 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.15.0.0", + "version": "1.15.1.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/gstack-gbrain-source-wireup.test.ts b/test/gstack-gbrain-source-wireup.test.ts index 3ca90a99..d7a30b76 100644 --- a/test/gstack-gbrain-source-wireup.test.ts +++ b/test/gstack-gbrain-source-wireup.test.ts @@ -379,4 +379,62 @@ describe('gstack-gbrain-source-wireup — uninstall mode', () => { const r = run(['--uninstall']); expect(r.status).toBe(3); }); + + test('--uninstall when gbrain is missing: exits 0 (best-effort), still removes worktree', () => { + setupGstackRepo('git@github.com:user/gstack-brain-user.git'); + // First wireup with fake gbrain to create the worktree + register source + makeFakeGbrain({}); + run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } }); + expect(fs.existsSync(worktreeDir)).toBe(true); + // Now remove the fake gbrain so uninstall sees gbrain missing + fs.rmSync(path.join(fakeBinDir, 'gbrain'), { force: true }); + const r = run(['--uninstall'], { + env: { PATH: `${fakeBinDir}:/usr/bin:/bin:/opt/homebrew/bin` }, + }); + expect(r.status).toBe(0); // best-effort, never fails on gbrain absence + expect(fs.existsSync(worktreeDir)).toBe(false); // worktree still cleaned up + }); +}); + +describe('gstack-gbrain-source-wireup — defensive paths', () => { + test('--no-pull skips HEAD advance on existing worktree', () => { + setupGstackRepo('git@github.com:user/gstack-brain-user.git'); + makeFakeGbrain({}); + // First run to create worktree + run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } }); + // Make a new commit on parent so worktree HEAD is "behind" + fs.writeFileSync(path.join(gstackHome, 'newfile.md'), 'new'); + spawnSync('git', ['-C', gstackHome, 'add', '.'], { stdio: 'pipe' }); + spawnSync('git', ['-C', gstackHome, 'commit', '-q', '-m', 'second commit'], { stdio: 'pipe' }); + const parentHeadAfter = spawnSync('git', ['-C', gstackHome, 'rev-parse', 'HEAD'], { + encoding: 'utf-8', + }).stdout.trim(); + const worktreeHeadBefore = spawnSync('git', ['-C', worktreeDir, 'rev-parse', 'HEAD'], { + encoding: 'utf-8', + }).stdout.trim(); + expect(parentHeadAfter).not.toBe(worktreeHeadBefore); // sanity: parent advanced + // --no-pull should leave worktree HEAD where it was + const r = run(['--no-pull'], { env: { GSTACK_BRAIN_NO_SYNC: '1' } }); + expect(r.status).toBe(0); + const worktreeHeadAfter = spawnSync('git', ['-C', worktreeDir, 'rev-parse', 'HEAD'], { + encoding: 'utf-8', + }).stdout.trim(); + expect(worktreeHeadAfter).toBe(worktreeHeadBefore); + expect(worktreeHeadAfter).not.toBe(parentHeadAfter); + }); + + test('stray non-git directory at worktree path is cleaned up + worktree created', () => { + setupGstackRepo('git@github.com:user/gstack-brain-user.git'); + makeFakeGbrain({}); + // Plant a stray non-git directory at the worktree path + fs.mkdirSync(worktreeDir, { recursive: true }); + fs.writeFileSync(path.join(worktreeDir, 'unrelated.txt'), 'not a worktree'); + expect(fs.existsSync(path.join(worktreeDir, 'unrelated.txt'))).toBe(true); + expect(fs.existsSync(path.join(worktreeDir, '.git'))).toBe(false); + // Helper should remove the stray dir + create a real worktree + const r = run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } }); + expect(r.status).toBe(0); + expect(fs.existsSync(path.join(worktreeDir, '.git'))).toBe(true); // real worktree + expect(fs.existsSync(path.join(worktreeDir, 'unrelated.txt'))).toBe(false); // stray gone + }); }); diff --git a/test/gstack-upgrade-migration-v1_15_1_0.test.ts b/test/gstack-upgrade-migration-v1_15_1_0.test.ts new file mode 100644 index 00000000..a09ce7b5 --- /dev/null +++ b/test/gstack-upgrade-migration-v1_15_1_0.test.ts @@ -0,0 +1,151 @@ +/** + * gstack-upgrade/migrations/v1.15.1.0.sh — migration script unit tests. + * + * The migration runs on /gstack-upgrade for users with brain-sync configured but + * never wired up to gbrain. It has 4 skip conditions and one happy path. + * + * Strategy: stub gstack-config and gstack-gbrain-source-wireup binaries on PATH + * so each skip condition can be triggered independently. The migration script + * itself is plain bash — we exercise it directly. + */ + +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 MIGRATION = path.join(ROOT, 'gstack-upgrade', 'migrations', 'v1.15.1.0.sh'); + +let tmpHome: string; +let fakeBinDir: string; +let stubLog: string; + +function makeFakeStubs(opts: { + configValue?: string; // value gstack-config returns for gbrain_sync_mode + configMissing?: boolean; // gstack-config binary itself missing (test edge) + wireupMissing?: boolean; // wireup binary missing + wireupExitCode?: number; +}) { + const skillsBin = path.join(tmpHome, '.claude', 'skills', 'gstack', 'bin'); + fs.mkdirSync(skillsBin, { recursive: true }); + + if (!opts.configMissing) { + const cfg = `#!/bin/bash +echo "gstack-config $@" >> "${stubLog}" +[ "$1" = "get" ] && [ "$2" = "gbrain_sync_mode" ] && echo "${opts.configValue ?? ''}" +exit 0 +`; + fs.writeFileSync(path.join(skillsBin, 'gstack-config'), cfg, { mode: 0o755 }); + } + + if (!opts.wireupMissing) { + const wu = `#!/bin/bash +echo "gstack-gbrain-source-wireup $@" >> "${stubLog}" +exit ${opts.wireupExitCode ?? 0} +`; + fs.writeFileSync(path.join(skillsBin, 'gstack-gbrain-source-wireup'), wu, { mode: 0o755 }); + } +} + +function makeBrainGitRepo() { + const gstackHome = path.join(tmpHome, '.gstack'); + fs.mkdirSync(path.join(gstackHome, '.git'), { recursive: true }); +} + +function run(opts: { env?: Record } = {}) { + const env = { + PATH: '/usr/bin:/bin:/opt/homebrew/bin', + HOME: tmpHome, + ...(opts.env || {}), + }; + return spawnSync('bash', [MIGRATION], { + env, + encoding: 'utf-8', + cwd: tmpHome, + }); +} + +function stubCalls(): string[] { + if (!fs.existsSync(stubLog)) return []; + return fs.readFileSync(stubLog, 'utf-8').split('\n').filter((l) => l.trim()); +} + +beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-migration-test-')); + fakeBinDir = path.join(tmpHome, 'fake-bin'); + fs.mkdirSync(fakeBinDir, { recursive: true }); + stubLog = path.join(tmpHome, 'stub-calls.log'); +}); + +afterEach(() => { + try { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } catch {} +}); + +describe('migrations/v1.15.1.0.sh', () => { + test('HOME unset: prints message + exit 0 (defensive)', () => { + // Override HOME to empty string. Bash's [ -z "${HOME:-}" ] guard should fire. + const r = run({ env: { HOME: '' } }); + expect(r.status).toBe(0); + expect(r.stderr).toContain('HOME is unset or empty'); + }); + + test('gbrain_sync_mode = off: exit 0 silently (no helper invoked)', () => { + makeFakeStubs({ configValue: 'off' }); + const r = run(); + expect(r.status).toBe(0); + // Helper should not have been invoked + const calls = stubCalls(); + expect(calls.some((c) => c.startsWith('gstack-gbrain-source-wireup'))).toBe(false); + }); + + test('gbrain_sync_mode unset/empty: exit 0 silently', () => { + makeFakeStubs({ configValue: '' }); // empty string return + const r = run(); + expect(r.status).toBe(0); + const calls = stubCalls(); + expect(calls.some((c) => c.startsWith('gstack-gbrain-source-wireup'))).toBe(false); + }); + + test('no ~/.gstack/.git: exit 0 silently (no brain-sync configured)', () => { + makeFakeStubs({ configValue: 'full' }); + // Do NOT call makeBrainGitRepo() — no .gstack/.git directory exists + const r = run(); + expect(r.status).toBe(0); + const calls = stubCalls(); + expect(calls.some((c) => c.startsWith('gstack-gbrain-source-wireup'))).toBe(false); + }); + + test('helper missing on PATH: prints warning, exit 0 (defensive)', () => { + makeFakeStubs({ configValue: 'full', wireupMissing: true }); + makeBrainGitRepo(); + const r = run(); + expect(r.status).toBe(0); + expect(r.stderr).toContain('missing or non-executable'); + }); + + test('happy path: invokes the helper', () => { + makeFakeStubs({ configValue: 'full' }); + makeBrainGitRepo(); + const r = run(); + expect(r.status).toBe(0); + const calls = stubCalls(); + expect(calls.some((c) => c.startsWith('gstack-gbrain-source-wireup'))).toBe(true); + // Note: migration invokes WITHOUT --strict (benign-skip semantics for batch upgrade) + const helperCall = calls.find((c) => c.startsWith('gstack-gbrain-source-wireup')); + expect(helperCall).not.toContain('--strict'); + }); + + test('helper exits non-zero: migration prints retry hint, exit 0 (non-blocking)', () => { + // The migration uses `|| { echo retry-hint; }` so non-zero helper still + // exits 0 and prints a retry hint to stderr. + makeFakeStubs({ configValue: 'full', wireupExitCode: 2 }); + makeBrainGitRepo(); + const r = run(); + expect(r.status).toBe(0); // migration is non-blocking + expect(r.stderr).toContain('Wireup exited non-zero'); + }); +});