diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index adb33456..e0e5a499 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1886,19 +1886,95 @@ describe('Factory generation (--host factory)', () => { }); }); +// ─── Parameterized host smoke tests (config-driven) ───────── + +import { ALL_HOST_CONFIGS, getExternalHosts } from '../hosts/index'; + +describe('Parameterized host smoke tests', () => { + for (const hostConfig of getExternalHosts()) { + describe(`${hostConfig.displayName} (--host ${hostConfig.name})`, () => { + const hostDir = path.join(ROOT, hostConfig.hostSubdir, 'skills'); + + test('generates output that exists on disk', () => { + // Generated dir should exist (created by earlier bun run gen:skill-docs --host all) + if (!fs.existsSync(hostDir)) { + // Generate if not already done + Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', hostConfig.name], { + cwd: ROOT, stdout: 'pipe', stderr: 'pipe', + }); + } + expect(fs.existsSync(hostDir)).toBe(true); + const skills = fs.readdirSync(hostDir).filter(d => + fs.existsSync(path.join(hostDir, d, 'SKILL.md')) + ); + expect(skills.length).toBeGreaterThan(0); + }); + + test('no .claude/skills path leakage in non-root skills', () => { + if (!fs.existsSync(hostDir)) return; // skip if not generated + const skills = fs.readdirSync(hostDir); + for (const skill of skills) { + // Skip root gstack skill — it contains preamble with intentional .claude/skills + // fallback paths for binary lookup and skill prefix instructions + if (skill === 'gstack') continue; + const skillMd = path.join(hostDir, skill, 'SKILL.md'); + if (!fs.existsSync(skillMd)) continue; + const content = fs.readFileSync(skillMd, 'utf-8'); + // Strip bash blocks (which have legitimate fallback paths) + const noBash = content.replace(/```bash\n[\s\S]*?```/g, ''); + const leaks = noBash.split('\n').filter(l => l.includes('.claude/skills')); + if (leaks.length > 0) { + throw new Error(`${skill}: .claude/skills leakage:\n${leaks.slice(0, 3).join('\n')}`); + } + } + }); + + test('frontmatter has name and description', () => { + if (!fs.existsSync(hostDir)) return; + const skills = fs.readdirSync(hostDir); + for (const skill of skills) { + const skillMd = path.join(hostDir, skill, 'SKILL.md'); + if (!fs.existsSync(skillMd)) continue; + const content = fs.readFileSync(skillMd, 'utf-8'); + expect(content).toMatch(/^---\n/); + expect(content).toMatch(/^name:\s/m); + expect(content).toMatch(/^description:\s/m); + } + }); + + test('--dry-run freshness check passes', () => { + const result = Bun.spawnSync( + ['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', hostConfig.name, '--dry-run'], + { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' } + ); + expect(result.exitCode).toBe(0); + const output = result.stdout.toString(); + expect(output).not.toContain('STALE'); + }); + + if (hostConfig.generation.skipSkills?.includes('codex')) { + test('/codex skill excluded', () => { + expect(fs.existsSync(path.join(hostDir, 'gstack-codex', 'SKILL.md'))).toBe(false); + }); + } + }); + } +}); + // ─── --host all tests ──────────────────────────────────────── describe('--host all', () => { - test('--host all generates for claude, codex, and factory', () => { + test('--host all generates for all registered hosts', () => { const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--dry-run'], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', }); expect(result.exitCode).toBe(0); const output = result.stdout.toString(); - // All three hosts should appear in output + // All hosts should appear in output expect(output).toContain('FRESH: SKILL.md'); // claude - expect(output).toContain('FRESH: .agents/skills/'); // codex - expect(output).toContain('FRESH: .factory/skills/'); // factory + for (const hostConfig of getExternalHosts()) { + expect(output).toContain(`FRESH: ${hostConfig.hostSubdir}/skills/`); + } }); });