mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 05:05:08 +02:00
test: 10 new cases close coverage gaps (helper defensive paths + migration)
/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) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string> } = {}) {
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user