mirror of https://github.com/garrytan/gstack.git
Merge 5359a784cf into 2beb636f7c
This commit is contained in:
commit
b4bb922309
|
|
@ -0,0 +1,210 @@
|
||||||
|
/**
|
||||||
|
* Edge case tests for multi-agent browse binary discovery.
|
||||||
|
*
|
||||||
|
* Tests the priority chain (.codex > .agents > .claude) with real
|
||||||
|
* filesystem fixtures. Validates that binary discovery works correctly
|
||||||
|
* when multiple agent hosts have gstack installed simultaneously.
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - Priority ordering when multiple markers exist
|
||||||
|
* - Workspace-local vs global fallback
|
||||||
|
* - Missing binary at marker path (marker dir exists but no binary)
|
||||||
|
* - Symlink resolution
|
||||||
|
* - Path construction correctness
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
|
// We test the path construction logic directly since locateBinary()
|
||||||
|
// depends on the real git root and home directory.
|
||||||
|
|
||||||
|
const ROOT = path.resolve(import.meta.dir, '..', '..');
|
||||||
|
|
||||||
|
describe('find-browse path construction', () => {
|
||||||
|
const src = fs.readFileSync(
|
||||||
|
path.join(ROOT, 'browse', 'src', 'find-browse.ts'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('markers array has exactly 3 entries in correct order', () => {
|
||||||
|
const match = src.match(/const markers = \[([^\]]+)\]/);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
const items = match![1].split(',').map(s => s.trim().replace(/['"]/g, ''));
|
||||||
|
expect(items).toEqual(['.codex', '.agents', '.claude']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace-local paths are checked before global paths', () => {
|
||||||
|
// The function should check root-based paths first, then home-based
|
||||||
|
const rootCheck = src.indexOf('if (root)');
|
||||||
|
const globalComment = src.indexOf('Global fallback');
|
||||||
|
expect(rootCheck).toBeGreaterThan(-1);
|
||||||
|
expect(globalComment).toBeGreaterThan(rootCheck);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('binary path includes skills/gstack/browse/dist/browse', () => {
|
||||||
|
expect(src).toContain("'skills', 'gstack', 'browse', 'dist', 'browse'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null (not throws) when binary is not found', () => {
|
||||||
|
expect(src).toContain('return null');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prints to stderr on failure, not stdout', () => {
|
||||||
|
expect(src).toContain('process.stderr.write');
|
||||||
|
// Should NOT print errors to stdout (that would corrupt output parsing)
|
||||||
|
const mainFn = src.slice(src.indexOf('function main()'));
|
||||||
|
expect(mainFn).not.toContain('console.error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exits with code 1 on failure', () => {
|
||||||
|
expect(src).toContain('process.exit(1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outputs binary path to stdout on success', () => {
|
||||||
|
expect(src).toContain('console.log(bin)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('find-browse filesystem edge cases', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-find-browse-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('path.join constructs correct paths for all markers', () => {
|
||||||
|
const markers = ['.codex', '.agents', '.claude'];
|
||||||
|
const root = '/fake/project';
|
||||||
|
|
||||||
|
for (const m of markers) {
|
||||||
|
const expected = `/fake/project/${m}/skills/gstack/browse/dist/browse`;
|
||||||
|
const actual = path.join(root, m, 'skills', 'gstack', 'browse', 'dist', 'browse');
|
||||||
|
expect(actual).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('path.join constructs correct global paths', () => {
|
||||||
|
const home = os.homedir();
|
||||||
|
const markers = ['.codex', '.agents', '.claude'];
|
||||||
|
|
||||||
|
for (const m of markers) {
|
||||||
|
const globalPath = path.join(home, m, 'skills', 'gstack', 'browse', 'dist', 'browse');
|
||||||
|
expect(globalPath).toContain(home);
|
||||||
|
expect(globalPath).toContain(m);
|
||||||
|
expect(globalPath).toContain('browse/dist/browse');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('existsSync returns false for non-existent binary paths', () => {
|
||||||
|
const fakePath = path.join(tmpDir, '.codex', 'skills', 'gstack', 'browse', 'dist', 'browse');
|
||||||
|
expect(fs.existsSync(fakePath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('existsSync returns true when binary is created at marker path', () => {
|
||||||
|
const binDir = path.join(tmpDir, '.claude', 'skills', 'gstack', 'browse', 'dist');
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
const binPath = path.join(binDir, 'browse');
|
||||||
|
fs.writeFileSync(binPath, '#!/bin/sh\necho test');
|
||||||
|
fs.chmodSync(binPath, 0o755);
|
||||||
|
|
||||||
|
expect(fs.existsSync(binPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marker directory without binary does not satisfy check', () => {
|
||||||
|
// Create .agents marker dir but no binary inside
|
||||||
|
const agentsDir = path.join(tmpDir, '.agents', 'skills', 'gstack');
|
||||||
|
fs.mkdirSync(agentsDir, { recursive: true });
|
||||||
|
|
||||||
|
const binPath = path.join(tmpDir, '.agents', 'skills', 'gstack', 'browse', 'dist', 'browse');
|
||||||
|
expect(fs.existsSync(binPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('symlinked binary is found via existsSync', () => {
|
||||||
|
// Create a real binary
|
||||||
|
const realDir = path.join(tmpDir, 'real-binary');
|
||||||
|
fs.mkdirSync(realDir, { recursive: true });
|
||||||
|
const realBin = path.join(realDir, 'browse');
|
||||||
|
fs.writeFileSync(realBin, '#!/bin/sh\necho real');
|
||||||
|
fs.chmodSync(realBin, 0o755);
|
||||||
|
|
||||||
|
// Symlink it into the .codex path
|
||||||
|
const codexDir = path.join(tmpDir, '.codex', 'skills', 'gstack', 'browse', 'dist');
|
||||||
|
fs.mkdirSync(codexDir, { recursive: true });
|
||||||
|
const symlinkPath = path.join(codexDir, 'browse');
|
||||||
|
try { fs.unlinkSync(symlinkPath); } catch { /* ignore */ }
|
||||||
|
fs.symlinkSync(realBin, symlinkPath);
|
||||||
|
|
||||||
|
expect(fs.existsSync(symlinkPath)).toBe(true);
|
||||||
|
expect(fs.readFileSync(symlinkPath, 'utf-8')).toContain('real');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multi-agent skill path consistency', () => {
|
||||||
|
// Verify that Codex-generated skills exist in .agents/skills/
|
||||||
|
test('.agents/skills/ directory exists with Codex skills', () => {
|
||||||
|
const agentsDir = path.join(ROOT, '.agents', 'skills');
|
||||||
|
if (!fs.existsSync(agentsDir)) {
|
||||||
|
// Skip if Codex skills haven't been generated yet
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const skills = fs.readdirSync(agentsDir);
|
||||||
|
expect(skills.length).toBeGreaterThan(0);
|
||||||
|
// All Codex skills should be prefixed with gstack-
|
||||||
|
for (const skill of skills) {
|
||||||
|
expect(skill.startsWith('gstack')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Claude and Codex skill counts match (minus /codex self-reference)', () => {
|
||||||
|
const agentsDir = path.join(ROOT, '.agents', 'skills');
|
||||||
|
if (!fs.existsSync(agentsDir)) return;
|
||||||
|
|
||||||
|
// Count Claude skills (directories with SKILL.md.tmpl at root)
|
||||||
|
const claudeSkills = fs.readdirSync(ROOT, { withFileTypes: true })
|
||||||
|
.filter(e => e.isDirectory() && fs.existsSync(path.join(ROOT, e.name, 'SKILL.md.tmpl')))
|
||||||
|
.map(e => e.name)
|
||||||
|
.filter(n => n !== 'codex'); // /codex is Claude-only
|
||||||
|
|
||||||
|
// Count Codex skills (directories in .agents/skills/)
|
||||||
|
const codexSkills = fs.readdirSync(agentsDir)
|
||||||
|
.filter(name => fs.existsSync(path.join(agentsDir, name, 'SKILL.md')));
|
||||||
|
|
||||||
|
// Root-level SKILL.md.tmpl generates a gstack/ entry in .agents/skills/
|
||||||
|
// Plus each skill directory. Codex count should equal Claude count.
|
||||||
|
expect(codexSkills.length).toBeGreaterThanOrEqual(claudeSkills.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every Codex SKILL.md has auto-generated header', () => {
|
||||||
|
const agentsDir = path.join(ROOT, '.agents', 'skills');
|
||||||
|
if (!fs.existsSync(agentsDir)) return;
|
||||||
|
|
||||||
|
const skills = fs.readdirSync(agentsDir);
|
||||||
|
for (const skill of skills) {
|
||||||
|
const mdPath = path.join(agentsDir, skill, 'SKILL.md');
|
||||||
|
if (!fs.existsSync(mdPath)) continue;
|
||||||
|
const content = fs.readFileSync(mdPath, 'utf-8');
|
||||||
|
expect(content).toContain('AUTO-GENERATED');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no Codex SKILL.md references ~/.claude/ paths', () => {
|
||||||
|
const agentsDir = path.join(ROOT, '.agents', 'skills');
|
||||||
|
if (!fs.existsSync(agentsDir)) return;
|
||||||
|
|
||||||
|
const skills = fs.readdirSync(agentsDir);
|
||||||
|
for (const skill of skills) {
|
||||||
|
const mdPath = path.join(agentsDir, skill, 'SKILL.md');
|
||||||
|
if (!fs.existsSync(mdPath)) continue;
|
||||||
|
const content = fs.readFileSync(mdPath, 'utf-8');
|
||||||
|
expect(content).not.toContain('~/.claude/skills/');
|
||||||
|
expect(content).not.toContain('.claude/skills/gstack/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue