271 lines
9.9 KiB
TypeScript
271 lines
9.9 KiB
TypeScript
import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { constants } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { loadFleetRoster } from './fleet.js';
|
|
import { generateRoster, runProvision } from './fleet-provision.js';
|
|
import { loadProfile } from './fleet-profiles.js';
|
|
|
|
// These are INTEGRATION tests: each exercises real filesystem I/O — scanning the
|
|
// committed framework/fleet persona library, rendering YAML, writing to a temp
|
|
// mosaicHome, and round-tripping through the real roster parser. On a heavily
|
|
// contended CI runner (the whole monorepo's suites run in parallel) that genuine
|
|
// I/O can exceed vitest's 5s default even though it completes in ~400ms locally.
|
|
// Give the legitimately I/O-bound work generous headroom so CI is deterministic.
|
|
vi.setConfig({ testTimeout: 30_000 });
|
|
|
|
// The real, committed library: packages/mosaic/src/commands -> framework/fleet.
|
|
const frameworkFleet = resolve(
|
|
dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
'..',
|
|
'framework',
|
|
'fleet',
|
|
);
|
|
const rolesDir = join(frameworkFleet, 'roles');
|
|
const profilesDir = join(frameworkFleet, 'profiles');
|
|
|
|
/** A fresh temp mosaicHome whose fleet/ dir is empty (for write-path tests). */
|
|
async function freshMosaicHome(): Promise<string> {
|
|
const home = await mkdtemp(join(tmpdir(), 'mosaic-provision-'));
|
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
|
return home;
|
|
}
|
|
|
|
async function fileExists(path: string): Promise<boolean> {
|
|
try {
|
|
await access(path, constants.F_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
describe('provision software-delivery (floor, default)', () => {
|
|
it('materializes only the floor seats with correct flags + valid scaffold', async () => {
|
|
const profile = await loadProfile('software-delivery', { profilesDir, rolesDir });
|
|
const { seats, yaml } = await generateRoster(profile, { profilesDir, rolesDir });
|
|
|
|
// Floor is orchestrator + enhancer.
|
|
expect(seats.map((s) => s.name).sort()).toEqual(['enhancer', 'orchestrator']);
|
|
const orch = seats.find((s) => s.name === 'orchestrator');
|
|
const enh = seats.find((s) => s.name === 'enhancer');
|
|
// RULE 2: floor + lead get persistent_persona.
|
|
expect(orch?.persistentPersona).toBe(true);
|
|
expect(enh?.persistentPersona).toBe(true);
|
|
// RULE 3: floor/lead do NOT reset between tasks.
|
|
expect(orch?.resetBetweenTasks).toBeUndefined();
|
|
expect(enh?.resetBetweenTasks).toBeUndefined();
|
|
// RULE 4: default runtime claude.
|
|
expect(orch?.runtime).toBe('claude');
|
|
|
|
// Scaffold round-trips through the real parser.
|
|
expect(yaml).toContain('version: 1');
|
|
expect(yaml).toContain('transport: tmux');
|
|
expect(yaml).toContain('socket_name: mosaic-fleet');
|
|
});
|
|
});
|
|
|
|
describe('provision --full', () => {
|
|
it('expands the entire roster, including multiplicity, deterministically', async () => {
|
|
const profile = await loadProfile('software-delivery', { profilesDir, rolesDir });
|
|
const { seats } = await generateRoster(profile, { full: true, profilesDir, rolesDir });
|
|
|
|
const names = seats.map((s) => s.name);
|
|
// code multiplicity 2 -> code0/code1 (RULE 1).
|
|
expect(names).toContain('code0');
|
|
expect(names).toContain('code1');
|
|
expect(names).not.toContain('code');
|
|
// Singleton seats keep the bare class name.
|
|
expect(names).toContain('planner');
|
|
expect(names).toContain('merge-gate');
|
|
|
|
// Deterministic ordering: profile roster order, multiplicity expanded inline.
|
|
const codeIdx0 = names.indexOf('code0');
|
|
expect(names[codeIdx0 + 1]).toBe('code1');
|
|
|
|
// RULE 3: a non-floor, non-lead execution seat resets between tasks.
|
|
const code0 = seats.find((s) => s.name === 'code0');
|
|
expect(code0?.resetBetweenTasks).toBe(true);
|
|
expect(code0?.persistentPersona).toBeUndefined();
|
|
|
|
// Seat count == sum of multiplicities.
|
|
const expected = profile.roster.reduce((n, e) => n + e.multiplicity, 0);
|
|
expect(seats.length).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe('generated roster round-trips through the real parser', () => {
|
|
it('feeds generated YAML back through loadFleetRoster (key correctness proof)', async () => {
|
|
const home = await freshMosaicHome();
|
|
try {
|
|
const result = await runProvision('software-delivery', {
|
|
mosaicHome: home,
|
|
profilesDir,
|
|
rolesDir,
|
|
full: true,
|
|
write: true,
|
|
});
|
|
expect(result.wrote).toBe(join(home, 'fleet', 'roster.yaml'));
|
|
|
|
const parsed = await loadFleetRoster(result.wrote!);
|
|
// It parses with no error and carries every seat.
|
|
const profile = await loadProfile('software-delivery', { profilesDir, rolesDir });
|
|
const expected = profile.roster.reduce((n, e) => n + e.multiplicity, 0);
|
|
expect(parsed.agents.length).toBe(expected);
|
|
// Classes survive the round-trip.
|
|
expect(parsed.agents.some((a) => a.className === 'orchestrator')).toBe(true);
|
|
expect(parsed.agents.filter((a) => a.className === 'code').length).toBe(2);
|
|
// reports_to must NOT have been emitted (parser rejects unknown keys).
|
|
expect(result.yaml).not.toContain('reports_to');
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('override-aware persona validation', () => {
|
|
let dir: string;
|
|
beforeEach(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), 'mosaic-provision-ov-'));
|
|
});
|
|
afterEach(async () => {
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('resolves a user-added (roles.local-only) persona without a false unresolved error', async () => {
|
|
const overrideDir = join(dir, 'roles.local');
|
|
const customProfilesDir = join(dir, 'profiles');
|
|
await mkdir(overrideDir, { recursive: true });
|
|
await mkdir(customProfilesDir, { recursive: true });
|
|
// A brand-new class that exists ONLY in roles.local.
|
|
await writeFile(
|
|
join(overrideDir, 'widget-maker.md'),
|
|
'# widget-maker\n\nThe widget-maker persona (`class: widget-maker`).\n',
|
|
);
|
|
await writeFile(
|
|
join(customProfilesDir, 'custom.yaml'),
|
|
[
|
|
'id: custom',
|
|
'title: Custom',
|
|
'description: a custom system',
|
|
'lead: widget-maker',
|
|
'floor: [widget-maker]',
|
|
'roster:',
|
|
' - class: widget-maker',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
|
|
const result = await runProvision('custom', {
|
|
mosaicHome: dir,
|
|
profilesDir: customProfilesDir,
|
|
// Point baseline rolesDir at a missing dir so resolution depends on override.
|
|
rolesDir: join(dir, 'no-baseline'),
|
|
overrideDir,
|
|
});
|
|
expect(result.yaml).toContain('class: widget-maker');
|
|
// It resolved from the override layer.
|
|
// (generateRoster records personaLayer; the seat is present.)
|
|
expect(result.summary).toContain('persona=override');
|
|
});
|
|
|
|
it('FAILS with a clear message when a profile references a bogus class', async () => {
|
|
const customProfilesDir = join(dir, 'profiles');
|
|
await mkdir(customProfilesDir, { recursive: true });
|
|
await writeFile(
|
|
join(customProfilesDir, 'bogus.yaml'),
|
|
[
|
|
'id: bogus',
|
|
'title: Bogus',
|
|
'description: bad system',
|
|
'lead: orchestrator',
|
|
'floor: [orchestrator]',
|
|
'roster:',
|
|
' - class: orchestrator',
|
|
' - class: not-a-real-persona-xyz',
|
|
' reports_to: orchestrator',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
await expect(
|
|
runProvision('bogus', {
|
|
mosaicHome: dir,
|
|
profilesDir: customProfilesDir,
|
|
rolesDir,
|
|
overrideDir: join(dir, 'roles.local'),
|
|
}),
|
|
).rejects.toThrow(/not-a-real-persona-xyz|not a known persona class/);
|
|
});
|
|
});
|
|
|
|
describe('--write protection', () => {
|
|
it('refuses to clobber an existing roster.yaml without --force', async () => {
|
|
const home = await freshMosaicHome();
|
|
try {
|
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
await writeFile(rosterPath, 'version: 1\n# operator customizations\n');
|
|
await expect(
|
|
runProvision('software-delivery', { mosaicHome: home, profilesDir, rolesDir, write: true }),
|
|
).rejects.toThrow(/Refusing to overwrite/);
|
|
// The original file is untouched.
|
|
const { readFile } = await import('node:fs/promises');
|
|
expect(await readFile(rosterPath, 'utf8')).toContain('operator customizations');
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('--write --force overwrites an existing roster', async () => {
|
|
const home = await freshMosaicHome();
|
|
try {
|
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
await writeFile(rosterPath, 'version: 1\n# old\n');
|
|
const result = await runProvision('software-delivery', {
|
|
mosaicHome: home,
|
|
profilesDir,
|
|
rolesDir,
|
|
write: true,
|
|
force: true,
|
|
});
|
|
expect(result.wrote).toBe(rosterPath);
|
|
const parsed = await loadFleetRoster(rosterPath);
|
|
expect(parsed.agents.length).toBeGreaterThan(0);
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('--write to a fresh mosaicHome creates the roster file', async () => {
|
|
const home = await freshMosaicHome();
|
|
try {
|
|
const result = await runProvision('software-delivery', {
|
|
mosaicHome: home,
|
|
profilesDir,
|
|
rolesDir,
|
|
write: true,
|
|
});
|
|
expect(await fileExists(result.wrote!)).toBe(true);
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('dry run (no --write) writes nothing', async () => {
|
|
const home = await freshMosaicHome();
|
|
try {
|
|
const result = await runProvision('software-delivery', {
|
|
mosaicHome: home,
|
|
profilesDir,
|
|
rolesDir,
|
|
});
|
|
expect(result.wrote).toBeUndefined();
|
|
expect(await fileExists(join(home, 'fleet', 'roster.yaml'))).toBe(false);
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|