mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-06 13:45:35 +02:00
Merge origin/main into garrytan/browserharness
Resolves three conflicts: - VERSION: kept 1.19.0.0 (this branch's consolidated Phase 1 + 2a ship); main brought 1.17.0.0 (setup-gbrain wireup) on top of 1.16.0.0 (tunnel allowlist), this branch leaps past with a gap (CLAUDE.md allows version gaps). - package.json: synced to 1.19.0.0 to match VERSION (HEAD had 1.16.0.0 stale from the prior release commit; this fixes the drift). - CHANGELOG.md: kept the v1.19.0.0 entry on top, then main's v1.17.0.0 + v1.16.0.0 entries below. browse/src/server.ts auto-merged: main's TUNNEL_COMMANDS expanded from 17→26 (export const) with the canDispatchOverTunnel pure gate is intact alongside this branch's LOCAL_LISTEN_PORT addition for $B skill run. Verification: 858/0 pass across browser-skill-write + browser-skill-commands + browser-skills-storage + skill-token + dual-listener + tunnel-gate-unit + skill-validation + gen-skill-docs (covers both this branch's Phase 2a and main's tunnel allowlist work). Regenerated all SKILL.md files (--host all) to absorb main's setup-gbrain template changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* gstack-gbrain-source-wireup — unit tests with mocked gbrain CLI.
|
||||
*
|
||||
* The helper registers the gstack brain repo as a gbrain federated source
|
||||
* via `git worktree`, runs an initial sync, and exposes --uninstall + --probe.
|
||||
*
|
||||
* Strategy: put a fake `gbrain` binary on PATH that records every call into
|
||||
* a log file and reads/writes its "registered sources" state from a JSON
|
||||
* file in the test's tmp dir. The helper sees a consistent gbrain-CLI surface
|
||||
* but no real database, no real gbrain.
|
||||
*/
|
||||
|
||||
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 WIREUP_BIN = path.join(BIN_DIR, 'gstack-gbrain-source-wireup');
|
||||
|
||||
let tmpHome: string;
|
||||
let gstackHome: string;
|
||||
let worktreeDir: string;
|
||||
let fakeBinDir: string;
|
||||
let gbrainCallLog: string;
|
||||
let gbrainStateFile: string;
|
||||
|
||||
function makeFakeGbrain(opts: {
|
||||
version?: string | null; // null = "binary missing" (don't write the file)
|
||||
syncFails?: boolean;
|
||||
}) {
|
||||
const version = opts.version ?? '0.18.2';
|
||||
if (version === null) return; // simulate missing binary by NOT writing one
|
||||
const syncFails = opts.syncFails ?? false;
|
||||
|
||||
// Stub gbrain reads/writes state from a JSON file. Fields:
|
||||
// sources: [{id, local_path, federated}]
|
||||
fs.writeFileSync(gbrainStateFile, JSON.stringify({ sources: [] }, null, 2));
|
||||
|
||||
const script = `#!/bin/bash
|
||||
LOG="${gbrainCallLog}"
|
||||
STATE="${gbrainStateFile}"
|
||||
# Record the call AND any GBRAIN_DATABASE_URL that the parent passed via env.
|
||||
# Format: "gbrain <args> [GBRAIN_DATABASE_URL=<url>]" so tests can assert
|
||||
# the wireup helper exported the locked URL into our env.
|
||||
LINE="gbrain $@"
|
||||
[ -n "\${GBRAIN_DATABASE_URL:-}" ] && LINE="\$LINE [GBRAIN_DATABASE_URL=\$GBRAIN_DATABASE_URL]"
|
||||
echo "\$LINE" >> "$LOG"
|
||||
|
||||
# --version
|
||||
if [ "$1" = "--version" ]; then
|
||||
echo "gbrain ${version}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# sources list --json → emits state
|
||||
if [ "$1" = "sources" ] && [ "$2" = "list" ]; then
|
||||
cat "$STATE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# sources add <id> --path <p> --federated → adds entry
|
||||
if [ "$1" = "sources" ] && [ "$2" = "add" ]; then
|
||||
shift 2
|
||||
ID="$1"; shift
|
||||
PATH_VAL=""
|
||||
FED="false"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--path) PATH_VAL="$2"; shift 2 ;;
|
||||
--federated) FED="true"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
python3 -c "
|
||||
import json, sys
|
||||
state = json.load(open('$STATE'))
|
||||
state['sources'].append({'id': '$ID', 'local_path': '$PATH_VAL', 'federated': '$FED' == 'true'})
|
||||
json.dump(state, open('$STATE','w'), indent=2)
|
||||
" || exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# sources remove <id> --yes → drops entry
|
||||
if [ "$1" = "sources" ] && [ "$2" = "remove" ]; then
|
||||
shift 2
|
||||
ID="$1"
|
||||
python3 -c "
|
||||
import json
|
||||
state = json.load(open('$STATE'))
|
||||
state['sources'] = [s for s in state['sources'] if s['id'] != '$ID']
|
||||
json.dump(state, open('$STATE','w'), indent=2)
|
||||
"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# sync --repo <p> → records, optionally fails
|
||||
if [ "$1" = "sync" ]; then
|
||||
${syncFails ? 'echo "sync failed: connection error" >&2; exit 1' : 'echo "1 page imported"; exit 0'}
|
||||
fi
|
||||
|
||||
echo "fake gbrain: unhandled subcommand: $@" >&2
|
||||
exit 99
|
||||
`;
|
||||
const gbrainPath = path.join(fakeBinDir, 'gbrain');
|
||||
fs.writeFileSync(gbrainPath, script, { mode: 0o755 });
|
||||
}
|
||||
|
||||
function run(
|
||||
argv: string[],
|
||||
opts: { env?: Record<string, string> } = {}
|
||||
) {
|
||||
const env = {
|
||||
PATH: `${fakeBinDir}:${process.env.PATH || '/usr/bin:/bin:/opt/homebrew/bin'}`,
|
||||
HOME: tmpHome,
|
||||
GSTACK_HOME: gstackHome,
|
||||
GSTACK_BRAIN_WORKTREE: worktreeDir,
|
||||
GSTACK_BRAIN_NO_SYNC: '0',
|
||||
...(opts.env || {}),
|
||||
};
|
||||
return spawnSync(WIREUP_BIN, argv, {
|
||||
env,
|
||||
encoding: 'utf-8',
|
||||
cwd: ROOT,
|
||||
});
|
||||
}
|
||||
|
||||
function readState(): { sources: Array<{ id: string; local_path: string; federated: boolean }> } {
|
||||
if (!fs.existsSync(gbrainStateFile)) return { sources: [] };
|
||||
return JSON.parse(fs.readFileSync(gbrainStateFile, 'utf-8'));
|
||||
}
|
||||
|
||||
function gbrainCalls(): string[] {
|
||||
if (!fs.existsSync(gbrainCallLog)) return [];
|
||||
return fs.readFileSync(gbrainCallLog, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim());
|
||||
}
|
||||
|
||||
function setupGstackRepo(remoteUrl: string) {
|
||||
// Real git repo at gstackHome with at least one commit + an origin remote.
|
||||
fs.mkdirSync(gstackHome, { recursive: true });
|
||||
spawnSync('git', ['-C', gstackHome, 'init', '-q', '-b', 'main'], { stdio: 'pipe' });
|
||||
spawnSync('git', ['-C', gstackHome, 'config', 'user.email', 'test@example.com'], { stdio: 'pipe' });
|
||||
spawnSync('git', ['-C', gstackHome, 'config', 'user.name', 'test'], { stdio: 'pipe' });
|
||||
fs.writeFileSync(path.join(gstackHome, '.brain-allowlist'), '# allowlist\n');
|
||||
spawnSync('git', ['-C', gstackHome, 'add', '.'], { stdio: 'pipe' });
|
||||
spawnSync('git', ['-C', gstackHome, 'commit', '-q', '-m', 'init'], { stdio: 'pipe' });
|
||||
spawnSync('git', ['-C', gstackHome, 'remote', 'add', 'origin', remoteUrl], { stdio: 'pipe' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-wireup-test-'));
|
||||
gstackHome = path.join(tmpHome, '.gstack');
|
||||
worktreeDir = path.join(tmpHome, '.gstack-brain-worktree');
|
||||
fakeBinDir = path.join(tmpHome, 'fake-bin');
|
||||
fs.mkdirSync(fakeBinDir, { recursive: true });
|
||||
gbrainCallLog = path.join(tmpHome, 'gbrain-calls.log');
|
||||
gbrainStateFile = path.join(tmpHome, 'gbrain-state.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
describe('gstack-gbrain-source-wireup — wireup mode', () => {
|
||||
test('fresh state: registers source + creates worktree + syncs', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({});
|
||||
const r = run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(fs.existsSync(worktreeDir)).toBe(true);
|
||||
const state = readState();
|
||||
expect(state.sources).toHaveLength(1);
|
||||
expect(state.sources[0].id).toBe('gstack-brain-user');
|
||||
expect(state.sources[0].local_path).toBe(worktreeDir);
|
||||
expect(state.sources[0].federated).toBe(true);
|
||||
});
|
||||
|
||||
test('idempotent re-run after success: no new sources add call', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({});
|
||||
run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||
const callsAfterFirst = gbrainCalls().filter((c) => c.startsWith('gbrain sources add')).length;
|
||||
expect(callsAfterFirst).toBe(1);
|
||||
run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||
const callsAfterSecond = gbrainCalls().filter((c) => c.startsWith('gbrain sources add')).length;
|
||||
expect(callsAfterSecond).toBe(1); // no new add
|
||||
});
|
||||
|
||||
test('drift recovery: existing source with different path triggers remove + add', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({});
|
||||
// Pre-seed the fake gbrain state with a source at the wrong path
|
||||
fs.writeFileSync(
|
||||
gbrainStateFile,
|
||||
JSON.stringify({
|
||||
sources: [{ id: 'gstack-brain-user', local_path: '/old/stale/path', federated: true }],
|
||||
})
|
||||
);
|
||||
const r = run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||
expect(r.status).toBe(0);
|
||||
const calls = gbrainCalls();
|
||||
expect(calls.some((c) => c.startsWith('gbrain sources remove gstack-brain-user'))).toBe(true);
|
||||
expect(calls.some((c) => c.includes(`gbrain sources add gstack-brain-user --path ${worktreeDir}`))).toBe(true);
|
||||
const state = readState();
|
||||
expect(state.sources[0].local_path).toBe(worktreeDir);
|
||||
});
|
||||
|
||||
test('--strict + gbrain too old: exits 2', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({ version: '0.17.0' });
|
||||
const r = run(['--strict']);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('< 0.18.0');
|
||||
});
|
||||
|
||||
test('non-strict + gbrain too old: warn + exit 0', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({ version: '0.17.0' });
|
||||
const r = run([]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('benign skip');
|
||||
});
|
||||
|
||||
test('--strict + gbrain missing on PATH: exits 2', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
// Don't make a fake gbrain — fakeBinDir is empty. Keep system dirs on PATH
|
||||
// so basic commands (git, awk, sed, etc.) work; only `gbrain` is absent.
|
||||
const r = run(['--strict'], {
|
||||
env: { PATH: `${fakeBinDir}:/usr/bin:/bin:/opt/homebrew/bin` },
|
||||
});
|
||||
expect(r.status).toBe(2);
|
||||
});
|
||||
|
||||
test('source-id derived from origin URL', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-alice.git');
|
||||
makeFakeGbrain({});
|
||||
const r = run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(readState().sources[0].id).toBe('gstack-brain-alice');
|
||||
});
|
||||
|
||||
test('source-id fallback to ~/.gstack-brain-remote.txt when .git is gone', () => {
|
||||
// No git repo at gstackHome; just the remote-file
|
||||
fs.mkdirSync(tmpHome, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, '.gstack-brain-remote.txt'),
|
||||
'git@github.com:user/gstack-brain-bob.git\n'
|
||||
);
|
||||
makeFakeGbrain({});
|
||||
// No --strict: helper should benign-skip because .gstack/.git is missing
|
||||
const r = run([]);
|
||||
// ensure_worktree returns 2 → benign skip, exit 0
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
|
||||
test('source-id from --source-id flag overrides everything', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-different.git');
|
||||
makeFakeGbrain({});
|
||||
run(['--source-id', 'custom-id'], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||
const state = readState();
|
||||
expect(state.sources[0].id).toBe('custom-id');
|
||||
});
|
||||
|
||||
test('--probe: read-only, prints state without mutating', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({});
|
||||
const r = run(['--probe']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('source_id=gstack-brain-user');
|
||||
expect(r.stdout).toContain('worktree=');
|
||||
expect(r.stdout).toContain('gbrain=ok');
|
||||
expect(r.stdout).toContain('source_status=absent');
|
||||
// Probe should NOT call sources add / sync
|
||||
const calls = gbrainCalls();
|
||||
expect(calls.some((c) => c.startsWith('gbrain sources add'))).toBe(false);
|
||||
expect(calls.some((c) => c.startsWith('gbrain sync'))).toBe(false);
|
||||
});
|
||||
|
||||
test('gbrain sync failure: exits 1 with stderr', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({ syncFails: true });
|
||||
const r = run([]);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('sync failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-gbrain-source-wireup — --database-url lock (defends against external config rewrites)', () => {
|
||||
test('--database-url flag is exported as GBRAIN_DATABASE_URL to child gbrain calls', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({});
|
||||
const TARGET = 'postgresql://postgres.abc:pw@aws.pooler.supabase.com:5432/postgres';
|
||||
const r = run(['--database-url', TARGET], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||
expect(r.status).toBe(0);
|
||||
const calls = gbrainCalls();
|
||||
// every gbrain invocation should carry the locked URL
|
||||
const writingCalls = calls.filter((c) => c.includes('sources') || c.includes('sync'));
|
||||
expect(writingCalls.length).toBeGreaterThan(0);
|
||||
for (const c of writingCalls) {
|
||||
expect(c).toContain(`[GBRAIN_DATABASE_URL=${TARGET}]`);
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to ~/.gbrain/config.json database_url when no flag and no env', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({});
|
||||
const FILE_URL = 'postgresql://postgres.xyz:pw@aws.pooler.supabase.com:5432/postgres';
|
||||
fs.mkdirSync(path.join(tmpHome, '.gbrain'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, '.gbrain', 'config.json'),
|
||||
JSON.stringify({ engine: 'postgres', database_url: FILE_URL })
|
||||
);
|
||||
// Important: don't pass GBRAIN_DATABASE_URL or DATABASE_URL in env; helper
|
||||
// should read from $HOME/.gbrain/config.json (HOME is tmpHome here).
|
||||
const r = run([], {
|
||||
env: {
|
||||
GSTACK_BRAIN_NO_SYNC: '1',
|
||||
GBRAIN_DATABASE_URL: '',
|
||||
DATABASE_URL: '',
|
||||
},
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
const calls = gbrainCalls();
|
||||
const writingCalls = calls.filter((c) => c.includes('sources add'));
|
||||
expect(writingCalls.length).toBe(1);
|
||||
expect(writingCalls[0]).toContain(`[GBRAIN_DATABASE_URL=${FILE_URL}]`);
|
||||
});
|
||||
|
||||
test('--database-url overrides env GBRAIN_DATABASE_URL and config.json', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({});
|
||||
const FLAG_URL = 'postgresql://postgres.flag:pw@a.b:5432/postgres';
|
||||
const ENV_URL = 'postgresql://postgres.env:pw@x.y:5432/postgres';
|
||||
const FILE_URL = 'postgresql://postgres.file:pw@p.q:5432/postgres';
|
||||
fs.mkdirSync(path.join(tmpHome, '.gbrain'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, '.gbrain', 'config.json'),
|
||||
JSON.stringify({ engine: 'postgres', database_url: FILE_URL })
|
||||
);
|
||||
const r = run(['--database-url', FLAG_URL], {
|
||||
env: {
|
||||
GSTACK_BRAIN_NO_SYNC: '1',
|
||||
GBRAIN_DATABASE_URL: ENV_URL,
|
||||
},
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
const calls = gbrainCalls();
|
||||
const writingCalls = calls.filter((c) => c.includes('sources add'));
|
||||
expect(writingCalls.length).toBe(1);
|
||||
expect(writingCalls[0]).toContain(`[GBRAIN_DATABASE_URL=${FLAG_URL}]`);
|
||||
expect(writingCalls[0]).not.toContain(ENV_URL);
|
||||
expect(writingCalls[0]).not.toContain(FILE_URL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-gbrain-source-wireup — uninstall mode', () => {
|
||||
test('after wireup: removes source + worktree', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({});
|
||||
run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||
expect(readState().sources).toHaveLength(1);
|
||||
expect(fs.existsSync(worktreeDir)).toBe(true);
|
||||
const r = run(['--uninstall']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(readState().sources).toHaveLength(0);
|
||||
expect(fs.existsSync(worktreeDir)).toBe(false);
|
||||
});
|
||||
|
||||
test('with no prior state: exits 3 (cannot derive id)', () => {
|
||||
// No git repo, no remote file. --uninstall must fail with code 3.
|
||||
fs.mkdirSync(tmpHome, { recursive: true });
|
||||
makeFakeGbrain({});
|
||||
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.17.0.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.17.0.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.17.0.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