import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { extractClassesFromDir, listPersonaClasses, personaStatus, resolvePersona, } from './fleet-personas.js'; import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js'; // The real, committed library: packages/mosaic/src/commands -> framework/fleet. const frameworkFleet = resolve( dirname(fileURLToPath(import.meta.url)), '..', '..', 'framework', 'fleet', ); const realRolesDir = join(frameworkFleet, 'roles'); let tmp: string; let rolesDir: string; let overrideDir: string; // A minimal baseline persona file with an inline `class:` + `domain:` marker. function baselinePersona(klass: string, domain: string, marker = 'BASELINE'): string { return `# ${klass} — fleet role definition The **${klass}** is the ${marker} definition (\`class: ${klass}\`, \`domain: ${domain}\`). `; } function overridePersona(klass: string, domain: string, marker = 'OVERRIDE'): string { return `# ${klass} — fleet role definition (override) The **${klass}** is the ${marker} definition (\`class: ${klass}\`, \`domain: ${domain}\`). `; } beforeEach(async () => { tmp = await mkdtemp(join(tmpdir(), 'h4-personas-')); rolesDir = join(tmp, 'roles'); overrideDir = join(tmp, 'roles.local'); await mkdir(rolesDir, { recursive: true }); // Seed two baseline personas. (No override dir yet — created per test.) await writeFile(join(rolesDir, 'ceo.md'), baselinePersona('ceo', 'executive'), 'utf8'); await writeFile(join(rolesDir, 'code.md'), baselinePersona('code', 'engineering'), 'utf8'); }); afterEach(async () => { await rm(tmp, { recursive: true, force: true }); }); describe('extractClassesFromDir (shared extraction)', () => { it('records class + domain from inline markers and degrades on missing dir', async () => { const base = await extractClassesFromDir(rolesDir); expect(base.classes.has('ceo')).toBe(true); expect(base.byClass.get('ceo')?.domain).toBe('executive'); const missing = await extractClassesFromDir(join(tmp, 'nope')); expect(missing.classes.size).toBe(0); }); }); describe('resolvePersona — override wins', () => { it('resolves to the override when a class exists in BOTH layers', async () => { await mkdir(overrideDir, { recursive: true }); await writeFile(join(overrideDir, 'ceo.md'), overridePersona('ceo', 'executive'), 'utf8'); const resolved = await resolvePersona('ceo', { rolesDir, overrideDir }); expect(resolved?.layer).toBe('override'); expect(resolved?.content).toContain('OVERRIDE'); expect(resolved?.file).toBe(join(overrideDir, 'ceo.md')); }); it('resolves to the baseline when no override exists', async () => { const resolved = await resolvePersona('code', { rolesDir, overrideDir }); expect(resolved?.layer).toBe('baseline'); expect(resolved?.content).toContain('BASELINE'); }); it('returns null for an unknown class', async () => { expect(await resolvePersona('does-not-exist', { rolesDir, overrideDir })).toBeNull(); }); }); describe('custom add — override-only class', () => { it('a class present only in roles.local/ appears in listPersonaClasses and resolves', async () => { await mkdir(overrideDir, { recursive: true }); await writeFile( join(overrideDir, 'mascot.md'), overridePersona('mascot', 'fun', 'CUSTOM'), 'utf8', ); const classes = await listPersonaClasses({ rolesDir, overrideDir }); expect(classes.has('mascot')).toBe(true); // Baseline classes are still present (union). expect(classes.has('ceo')).toBe(true); const resolved = await resolvePersona('mascot', { rolesDir, overrideDir }); expect(resolved?.layer).toBe('override'); expect(resolved?.content).toContain('CUSTOM'); }); }); describe('personaStatus classification', () => { it('classifies baseline / overridden / custom correctly', async () => { await mkdir(overrideDir, { recursive: true }); // ceo: overridden (both). code: baseline (only base). mascot: custom (only override). await writeFile(join(overrideDir, 'ceo.md'), overridePersona('ceo', 'executive'), 'utf8'); await writeFile(join(overrideDir, 'mascot.md'), overridePersona('mascot', 'fun'), 'utf8'); const status = await personaStatus({ rolesDir, overrideDir }); const byClass = new Map(status.map((s) => [s.klass, s])); expect(byClass.get('ceo')?.status).toBe('overridden'); expect(byClass.get('code')?.status).toBe('baseline'); expect(byClass.get('mascot')?.status).toBe('custom'); // Domain surfaced. expect(byClass.get('ceo')?.domain).toBe('executive'); }); }); describe('AC-NS-7 — update-survival simulation', () => { it('override and custom-added class survive a baseline reseed', async () => { // 1. User customizes ceo and adds a brand-new persona in the override layer. await mkdir(overrideDir, { recursive: true }); await writeFile(join(overrideDir, 'ceo.md'), overridePersona('ceo', 'executive'), 'utf8'); await writeFile( join(overrideDir, 'mascot.md'), overridePersona('mascot', 'fun', 'CUSTOM'), 'utf8', ); // 2. Simulate `mosaic update`: REPLACE the baseline roles/ entirely (as the // framework reseed/rsync does), leaving roles.local/ untouched. The reseed // even ships a NEW baseline ceo and adds a brand-new baseline persona. await rm(rolesDir, { recursive: true, force: true }); await mkdir(rolesDir, { recursive: true }); await writeFile( join(rolesDir, 'ceo.md'), baselinePersona('ceo', 'executive', 'RESEEDED-BASELINE'), 'utf8', ); await writeFile(join(rolesDir, 'code.md'), baselinePersona('code', 'engineering'), 'utf8'); await writeFile(join(rolesDir, 'new-role.md'), baselinePersona('new-role', 'ops'), 'utf8'); // 3. The override STILL wins (was not clobbered by the reseed). const ceo = await resolvePersona('ceo', { rolesDir, overrideDir }); expect(ceo?.layer).toBe('override'); expect(ceo?.content).toContain('OVERRIDE'); expect(ceo?.content).not.toContain('RESEEDED-BASELINE'); // 4. The custom-added class still exists and resolves. const mascot = await resolvePersona('mascot', { rolesDir, overrideDir }); expect(mascot?.layer).toBe('override'); expect(mascot?.content).toContain('CUSTOM'); // 5. New baseline personas from the reseed are now visible too. const classes = await listPersonaClasses({ rolesDir, overrideDir }); expect(classes.has('new-role')).toBe(true); expect(classes.has('mascot')).toBe(true); }); }); describe('fleet-profiles validation accepts a custom (override-only) persona', () => { it('a profile referencing an override-only class validates', async () => { // Build a profiles dir + roles using the REAL library plus a custom persona. const profilesDir = join(tmp, 'profiles'); const customRolesDir = join(tmp, 'real-roles'); const customOverrideDir = join(tmp, 'real-roles.local'); await mkdir(profilesDir, { recursive: true }); await cp(realRolesDir, customRolesDir, { recursive: true }); await mkdir(customOverrideDir, { recursive: true }); await writeFile(join(customOverrideDir, 'mascot.md'), overridePersona('mascot', 'fun'), 'utf8'); // A profile whose roster references the custom (override-only) persona. const profileYaml = [ 'id: custom-team', 'title: Custom Team', 'description: A team that uses a user-added persona.', 'lead: ceo', 'floor:', ' - ceo', 'roster:', ' - class: ceo', ' - class: mascot', ' reports_to: ceo', ].join('\n'); await writeFile(join(profilesDir, 'custom-team.yaml'), profileYaml, 'utf8'); // Override-aware loadProfiles must accept it (would throw if mascot unknown). const profiles = await loadProfiles({ profilesDir, rolesDir: customRolesDir, overrideDir: customOverrideDir, }); const team = profiles.find((p: FleetProfile) => p.id === 'custom-team'); expect(team).toBeDefined(); // And direct validation against the union confirms zero problems. const validClasses = await listPersonaClasses({ rolesDir: customRolesDir, overrideDir: customOverrideDir, }); expect(validateProfile(team as FleetProfile, validClasses)).toEqual([]); }); });