import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { slugify, personaNameFromMarkdown, loadBoardPersonas, loadPersonaOverrides, loadForgeConfig, getEffectivePersonas, } from '../src/persona-loader.js'; describe('slugify', () => { it('converts to lowercase and replaces non-alphanumeric with hyphens', () => { expect(slugify('Chief Executive Officer')).toBe('chief-executive-officer'); }); it('strips leading and trailing hyphens', () => { expect(slugify('--hello--')).toBe('hello'); }); it('returns "persona" for empty string', () => { expect(slugify('')).toBe('persona'); }); it('handles special characters', () => { expect(slugify('CTO — Technical')).toBe('cto-technical'); }); }); describe('personaNameFromMarkdown', () => { it('extracts name from heading', () => { expect(personaNameFromMarkdown('# CEO — Chief Executive Officer', 'FALLBACK')).toBe('CEO'); }); it('strips markdown heading markers', () => { expect(personaNameFromMarkdown('## CTO - Technical Lead', 'FALLBACK')).toBe('CTO'); }); it('returns fallback for empty content', () => { expect(personaNameFromMarkdown('', 'FALLBACK')).toBe('FALLBACK'); }); it('returns full heading if no separator', () => { expect(personaNameFromMarkdown('# SimpleTitle', 'FALLBACK')).toBe('SimpleTitle'); }); }); describe('loadBoardPersonas', () => { let tmpDir: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-personas-')); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it('returns empty array for non-existent directory', () => { expect(loadBoardPersonas('/nonexistent')).toEqual([]); }); it('loads personas from markdown files', () => { fs.writeFileSync( path.join(tmpDir, 'ceo.md'), '# CEO — Visionary Leader\n\nThe CEO sets direction.', ); fs.writeFileSync( path.join(tmpDir, 'cto.md'), '# CTO — Technical Realist\n\nThe CTO evaluates feasibility.', ); const personas = loadBoardPersonas(tmpDir); expect(personas).toHaveLength(2); expect(personas[0]!.name).toBe('CEO'); expect(personas[0]!.slug).toBe('ceo'); expect(personas[1]!.name).toBe('CTO'); }); it('sorts by filename', () => { fs.writeFileSync(path.join(tmpDir, 'z-last.md'), '# Z Last'); fs.writeFileSync(path.join(tmpDir, 'a-first.md'), '# A First'); const personas = loadBoardPersonas(tmpDir); expect(personas[0]!.slug).toBe('a-first'); expect(personas[1]!.slug).toBe('z-last'); }); it('ignores non-markdown files', () => { fs.writeFileSync(path.join(tmpDir, 'notes.txt'), 'not a persona'); fs.writeFileSync(path.join(tmpDir, 'ceo.md'), '# CEO'); const personas = loadBoardPersonas(tmpDir); expect(personas).toHaveLength(1); }); }); describe('loadPersonaOverrides', () => { let tmpDir: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-overrides-')); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it('returns empty object when .forge/personas/ does not exist', () => { expect(loadPersonaOverrides(tmpDir)).toEqual({}); }); it('loads override files', () => { const overridesDir = path.join(tmpDir, '.forge', 'personas'); fs.mkdirSync(overridesDir, { recursive: true }); fs.writeFileSync(path.join(overridesDir, 'ceo.md'), 'Additional CEO context'); const overrides = loadPersonaOverrides(tmpDir); expect(overrides['ceo']).toBe('Additional CEO context'); }); }); describe('loadForgeConfig', () => { let tmpDir: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-config-')); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it('returns empty config when file does not exist', () => { expect(loadForgeConfig(tmpDir)).toEqual({}); }); it('parses board skipMembers', () => { const configDir = path.join(tmpDir, '.forge'); fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync( path.join(configDir, 'config.yaml'), 'board:\n skipMembers:\n - cfo\n - coo\n', ); const config = loadForgeConfig(tmpDir); expect(config.board?.skipMembers).toEqual(['cfo', 'coo']); }); }); describe('getEffectivePersonas', () => { let tmpDir: string; let boardDir: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-effective-')); boardDir = path.join(tmpDir, 'board-agents'); fs.mkdirSync(boardDir, { recursive: true }); fs.writeFileSync(path.join(boardDir, 'ceo.md'), '# CEO — Visionary'); fs.writeFileSync(path.join(boardDir, 'cto.md'), '# CTO — Technical'); fs.writeFileSync(path.join(boardDir, 'cfo.md'), '# CFO — Financial'); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it('returns all personas with no overrides or config', () => { const personas = getEffectivePersonas(tmpDir, boardDir); expect(personas).toHaveLength(3); }); it('appends project overrides to base description', () => { const overridesDir = path.join(tmpDir, '.forge', 'personas'); fs.mkdirSync(overridesDir, { recursive: true }); fs.writeFileSync(path.join(overridesDir, 'ceo.md'), 'Focus on AI strategy'); const personas = getEffectivePersonas(tmpDir, boardDir); const ceo = personas.find((p) => p.slug === 'ceo')!; expect(ceo.description).toContain('# CEO — Visionary'); expect(ceo.description).toContain('Focus on AI strategy'); }); it('removes skipped members via config', () => { const configDir = path.join(tmpDir, '.forge'); fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync(path.join(configDir, 'config.yaml'), 'board:\n skipMembers:\n - cfo\n'); const personas = getEffectivePersonas(tmpDir, boardDir); expect(personas).toHaveLength(2); expect(personas.find((p) => p.slug === 'cfo')).toBeUndefined(); }); });