mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-08 06:26:45 +02:00
faf663b22c
listBrowserSkills() walks project > global > bundled (first-wins),
parses SKILL.md frontmatter, no INDEX.json. readBrowserSkill() does
the same for a single name. tombstoneBrowserSkill() moves a skill
into .tombstones/<name>-<ts>/ for recoverability.
Frontmatter parser handles the subset browser-skills need: scalars
(host, description, trusted, version, source), string lists
(triggers), and arg-mapping lists ([{name, description}, ...]).
Quoted values handle colons; trusted defaults to false.
Bundled tier path is auto-detected from the binary install location;
project tier comes from git rev-parse; global is ~/.gstack/. All tier
paths are overridable for hermetic tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
/**
|
|
* browser-skills storage tests — covers the 3-tier walk, frontmatter parsing,
|
|
* tombstone semantics. Uses tmp dirs for hermetic isolation; never touches
|
|
* real ~/.gstack/ or the gstack install.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import {
|
|
parseSkillFile,
|
|
listBrowserSkills,
|
|
readBrowserSkill,
|
|
tombstoneBrowserSkill,
|
|
type TierPaths,
|
|
} from '../src/browser-skills';
|
|
|
|
let tmpRoot: string;
|
|
let tiers: TierPaths;
|
|
|
|
beforeEach(() => {
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'browser-skills-test-'));
|
|
tiers = {
|
|
project: path.join(tmpRoot, 'project', '.gstack', 'browser-skills'),
|
|
global: path.join(tmpRoot, 'home', '.gstack', 'browser-skills'),
|
|
bundled: path.join(tmpRoot, 'gstack-install', 'browser-skills'),
|
|
};
|
|
fs.mkdirSync(tiers.project!, { recursive: true });
|
|
fs.mkdirSync(tiers.global, { recursive: true });
|
|
fs.mkdirSync(tiers.bundled, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
function makeSkill(tierRoot: string, name: string, frontmatter: string, body: string = '\nBody.\n') {
|
|
const dir = path.join(tierRoot, name);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\n${frontmatter}\n---\n${body}`);
|
|
return dir;
|
|
}
|
|
|
|
describe('parseSkillFile', () => {
|
|
it('parses simple frontmatter scalars', () => {
|
|
const md = '---\nname: foo\nhost: example.com\ndescription: hello world\ntrusted: true\n---\nbody';
|
|
const { frontmatter, bodyMd } = parseSkillFile(md);
|
|
expect(frontmatter.name).toBe('foo');
|
|
expect(frontmatter.host).toBe('example.com');
|
|
expect(frontmatter.description).toBe('hello world');
|
|
expect(frontmatter.trusted).toBe(true);
|
|
expect(bodyMd).toBe('body');
|
|
});
|
|
|
|
it('parses string lists', () => {
|
|
const md = `---
|
|
name: foo
|
|
host: example.com
|
|
triggers:
|
|
- first trigger
|
|
- second trigger
|
|
- "with: colons"
|
|
---
|
|
body`;
|
|
const { frontmatter } = parseSkillFile(md);
|
|
expect(frontmatter.triggers).toEqual(['first trigger', 'second trigger', 'with: colons']);
|
|
});
|
|
|
|
it('parses args list of mappings', () => {
|
|
const md = `---
|
|
name: foo
|
|
host: example.com
|
|
args:
|
|
- name: keywords
|
|
description: search query
|
|
- name: limit
|
|
description: max results
|
|
---`;
|
|
const { frontmatter } = parseSkillFile(md);
|
|
expect(frontmatter.args).toEqual([
|
|
{ name: 'keywords', description: 'search query' },
|
|
{ name: 'limit', description: 'max results' },
|
|
]);
|
|
});
|
|
|
|
it('handles empty inline list', () => {
|
|
const md = '---\nname: foo\nhost: example.com\nargs: []\ntriggers: []\n---\n';
|
|
const { frontmatter } = parseSkillFile(md);
|
|
expect(frontmatter.args).toEqual([]);
|
|
expect(frontmatter.triggers).toEqual([]);
|
|
});
|
|
|
|
it('defaults trusted to false', () => {
|
|
const md = '---\nname: foo\nhost: example.com\n---\n';
|
|
const { frontmatter } = parseSkillFile(md);
|
|
expect(frontmatter.trusted).toBe(false);
|
|
});
|
|
|
|
it('throws when frontmatter is missing', () => {
|
|
expect(() => parseSkillFile('no frontmatter here')).toThrow(/missing frontmatter/);
|
|
});
|
|
|
|
it('throws when frontmatter terminator is missing', () => {
|
|
expect(() => parseSkillFile('---\nname: foo\nhost: bar\n')).toThrow(/not terminated/);
|
|
});
|
|
|
|
it('throws when host is missing', () => {
|
|
const md = '---\nname: foo\n---\nbody';
|
|
expect(() => parseSkillFile(md)).toThrow(/missing required field: host/);
|
|
});
|
|
|
|
it('throws when name is absent and no skillName hint', () => {
|
|
const md = '---\nhost: x\n---\nbody';
|
|
expect(() => parseSkillFile(md)).toThrow(/missing required field: name/);
|
|
});
|
|
|
|
it('uses skillName hint when frontmatter omits name', () => {
|
|
const md = '---\nhost: example.com\n---\nbody';
|
|
const { frontmatter } = parseSkillFile(md, { skillName: 'derived-name' });
|
|
expect(frontmatter.name).toBe('derived-name');
|
|
});
|
|
|
|
it('parses source field as union', () => {
|
|
const human = parseSkillFile('---\nname: f\nhost: h\nsource: human\n---\n').frontmatter;
|
|
const agent = parseSkillFile('---\nname: f\nhost: h\nsource: agent\n---\n').frontmatter;
|
|
const bogus = parseSkillFile('---\nname: f\nhost: h\nsource: alien\n---\n').frontmatter;
|
|
expect(human.source).toBe('human');
|
|
expect(agent.source).toBe('agent');
|
|
expect(bogus.source).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('listBrowserSkills', () => {
|
|
it('returns empty when no tiers have skills', () => {
|
|
expect(listBrowserSkills(tiers)).toEqual([]);
|
|
});
|
|
|
|
it('returns bundled-tier skills', () => {
|
|
makeSkill(tiers.bundled, 'foo', 'name: foo\nhost: example.com');
|
|
const skills = listBrowserSkills(tiers);
|
|
expect(skills).toHaveLength(1);
|
|
expect(skills[0].name).toBe('foo');
|
|
expect(skills[0].tier).toBe('bundled');
|
|
});
|
|
|
|
it('returns global-tier skills', () => {
|
|
makeSkill(tiers.global, 'bar', 'name: bar\nhost: example.com');
|
|
const skills = listBrowserSkills(tiers);
|
|
expect(skills).toHaveLength(1);
|
|
expect(skills[0].tier).toBe('global');
|
|
});
|
|
|
|
it('returns project-tier skills', () => {
|
|
makeSkill(tiers.project!, 'baz', 'name: baz\nhost: example.com');
|
|
const skills = listBrowserSkills(tiers);
|
|
expect(skills).toHaveLength(1);
|
|
expect(skills[0].tier).toBe('project');
|
|
});
|
|
|
|
it('global overrides bundled when same name', () => {
|
|
makeSkill(tiers.bundled, 'shared', 'name: shared\nhost: bundled.com');
|
|
makeSkill(tiers.global, 'shared', 'name: shared\nhost: global.com');
|
|
const skills = listBrowserSkills(tiers);
|
|
expect(skills).toHaveLength(1);
|
|
expect(skills[0].tier).toBe('global');
|
|
expect(skills[0].frontmatter.host).toBe('global.com');
|
|
});
|
|
|
|
it('project overrides global and bundled when same name', () => {
|
|
makeSkill(tiers.bundled, 'shared', 'name: shared\nhost: bundled.com');
|
|
makeSkill(tiers.global, 'shared', 'name: shared\nhost: global.com');
|
|
makeSkill(tiers.project!, 'shared', 'name: shared\nhost: project.com');
|
|
const skills = listBrowserSkills(tiers);
|
|
expect(skills).toHaveLength(1);
|
|
expect(skills[0].tier).toBe('project');
|
|
expect(skills[0].frontmatter.host).toBe('project.com');
|
|
});
|
|
|
|
it('returns all unique skills across tiers, sorted alphabetically', () => {
|
|
makeSkill(tiers.bundled, 'zebra', 'name: zebra\nhost: x.com');
|
|
makeSkill(tiers.global, 'apple', 'name: apple\nhost: x.com');
|
|
makeSkill(tiers.project!, 'mango', 'name: mango\nhost: x.com');
|
|
const skills = listBrowserSkills(tiers);
|
|
expect(skills.map(s => s.name)).toEqual(['apple', 'mango', 'zebra']);
|
|
expect(skills.map(s => s.tier)).toEqual(['global', 'project', 'bundled']);
|
|
});
|
|
|
|
it('skips entries without SKILL.md', () => {
|
|
fs.mkdirSync(path.join(tiers.bundled, 'no-skill-md'));
|
|
fs.writeFileSync(path.join(tiers.bundled, 'no-skill-md', 'README'), 'nothing here');
|
|
expect(listBrowserSkills(tiers)).toEqual([]);
|
|
});
|
|
|
|
it('skips dotfiles and .tombstones', () => {
|
|
makeSkill(tiers.bundled, '.hidden', 'name: hidden\nhost: x.com');
|
|
fs.mkdirSync(path.join(tiers.global, '.tombstones', 'old-skill'), { recursive: true });
|
|
fs.writeFileSync(path.join(tiers.global, '.tombstones', 'old-skill', 'SKILL.md'), '---\nname: x\nhost: y\n---\n');
|
|
expect(listBrowserSkills(tiers)).toEqual([]);
|
|
});
|
|
|
|
it('skips malformed SKILL.md silently (best-effort listing)', () => {
|
|
fs.mkdirSync(path.join(tiers.bundled, 'broken'));
|
|
fs.writeFileSync(path.join(tiers.bundled, 'broken', 'SKILL.md'), 'no frontmatter');
|
|
makeSkill(tiers.bundled, 'good', 'name: good\nhost: x.com');
|
|
const skills = listBrowserSkills(tiers);
|
|
expect(skills.map(s => s.name)).toEqual(['good']);
|
|
});
|
|
});
|
|
|
|
describe('readBrowserSkill', () => {
|
|
it('returns null when skill missing in all tiers', () => {
|
|
expect(readBrowserSkill('nope', tiers)).toBeNull();
|
|
});
|
|
|
|
it('finds bundled-tier skill', () => {
|
|
makeSkill(tiers.bundled, 'foo', 'name: foo\nhost: example.com');
|
|
const skill = readBrowserSkill('foo', tiers);
|
|
expect(skill).not.toBeNull();
|
|
expect(skill!.tier).toBe('bundled');
|
|
});
|
|
|
|
it('returns project-tier when same name in all three', () => {
|
|
makeSkill(tiers.bundled, 'shared', 'name: shared\nhost: bundled.com');
|
|
makeSkill(tiers.global, 'shared', 'name: shared\nhost: global.com');
|
|
makeSkill(tiers.project!, 'shared', 'name: shared\nhost: project.com');
|
|
const skill = readBrowserSkill('shared', tiers);
|
|
expect(skill!.tier).toBe('project');
|
|
expect(skill!.frontmatter.host).toBe('project.com');
|
|
});
|
|
|
|
it('falls through to bundled when global is malformed', () => {
|
|
makeSkill(tiers.bundled, 'foo', 'name: foo\nhost: bundled.com');
|
|
fs.mkdirSync(path.join(tiers.global, 'foo'));
|
|
fs.writeFileSync(path.join(tiers.global, 'foo', 'SKILL.md'), 'malformed');
|
|
const skill = readBrowserSkill('foo', tiers);
|
|
expect(skill!.tier).toBe('bundled');
|
|
expect(skill!.frontmatter.host).toBe('bundled.com');
|
|
});
|
|
|
|
it('reads bodyMd correctly', () => {
|
|
makeSkill(tiers.bundled, 'foo', 'name: foo\nhost: x.com', '\n# Heading\n\nProse.\n');
|
|
const skill = readBrowserSkill('foo', tiers);
|
|
expect(skill!.bodyMd).toContain('# Heading');
|
|
expect(skill!.bodyMd).toContain('Prose.');
|
|
});
|
|
});
|
|
|
|
describe('tombstoneBrowserSkill', () => {
|
|
it('moves a global-tier skill to .tombstones/', () => {
|
|
makeSkill(tiers.global, 'gone', 'name: gone\nhost: x.com');
|
|
const dst = tombstoneBrowserSkill('gone', 'global', tiers);
|
|
expect(fs.existsSync(path.join(tiers.global, 'gone'))).toBe(false);
|
|
expect(fs.existsSync(dst)).toBe(true);
|
|
expect(dst).toContain('.tombstones');
|
|
});
|
|
|
|
it('moves a project-tier skill to .tombstones/', () => {
|
|
makeSkill(tiers.project!, 'gone', 'name: gone\nhost: x.com');
|
|
const dst = tombstoneBrowserSkill('gone', 'project', tiers);
|
|
expect(fs.existsSync(path.join(tiers.project!, 'gone'))).toBe(false);
|
|
expect(fs.existsSync(dst)).toBe(true);
|
|
});
|
|
|
|
it('after tombstone, listBrowserSkills no longer returns it', () => {
|
|
makeSkill(tiers.global, 'gone', 'name: gone\nhost: x.com');
|
|
expect(listBrowserSkills(tiers)).toHaveLength(1);
|
|
tombstoneBrowserSkill('gone', 'global', tiers);
|
|
expect(listBrowserSkills(tiers)).toEqual([]);
|
|
});
|
|
|
|
it('throws when skill not found in target tier', () => {
|
|
expect(() => tombstoneBrowserSkill('nope', 'global', tiers)).toThrow(/not found/);
|
|
});
|
|
|
|
it('after tombstone, listBrowserSkills falls through to bundled', () => {
|
|
makeSkill(tiers.bundled, 'shared', 'name: shared\nhost: bundled.com');
|
|
makeSkill(tiers.global, 'shared', 'name: shared\nhost: global.com');
|
|
expect(listBrowserSkills(tiers)[0].tier).toBe('global');
|
|
tombstoneBrowserSkill('shared', 'global', tiers);
|
|
expect(listBrowserSkills(tiers)[0].tier).toBe('bundled');
|
|
});
|
|
});
|