290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { Command } from 'commander';
|
|
import { registerConfigCommand } from './config.js';
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
/** Build a fresh Command tree with the config command registered. */
|
|
function buildProgram(): Command {
|
|
const program = new Command();
|
|
program.exitOverride(); // prevent process.exit during tests
|
|
registerConfigCommand(program);
|
|
return program;
|
|
}
|
|
|
|
/** Locate the 'config' command registered on the root program. */
|
|
function getConfigCmd(program: Command): Command {
|
|
const found = program.commands.find((c) => c.name() === 'config');
|
|
if (!found) throw new Error('config command not found');
|
|
return found;
|
|
}
|
|
|
|
// ── subcommand registration ───────────────────────────────────────────────────
|
|
|
|
describe('registerConfigCommand', () => {
|
|
it('registers a "config" command on the program', () => {
|
|
const program = buildProgram();
|
|
const names = program.commands.map((c) => c.name());
|
|
expect(names).toContain('config');
|
|
});
|
|
|
|
it('registers exactly the five required subcommands', () => {
|
|
const program = buildProgram();
|
|
const config = getConfigCmd(program);
|
|
const subs = config.commands.map((c) => c.name()).sort();
|
|
expect(subs).toEqual(['edit', 'get', 'path', 'set', 'show']);
|
|
});
|
|
});
|
|
|
|
// ── mock config service ───────────────────────────────────────────────────────
|
|
|
|
const mockSoul = {
|
|
agentName: 'TestBot',
|
|
roleDescription: 'test role',
|
|
communicationStyle: 'direct' as const,
|
|
};
|
|
const mockUser = { userName: 'Tester', pronouns: 'they/them', timezone: 'UTC' };
|
|
const mockTools = { credentialsLocation: '/dev/null' };
|
|
|
|
const mockSvc = {
|
|
readSoul: vi.fn().mockResolvedValue(mockSoul),
|
|
readUser: vi.fn().mockResolvedValue(mockUser),
|
|
readTools: vi.fn().mockResolvedValue(mockTools),
|
|
writeSoul: vi.fn().mockResolvedValue(undefined),
|
|
writeUser: vi.fn().mockResolvedValue(undefined),
|
|
writeTools: vi.fn().mockResolvedValue(undefined),
|
|
syncFramework: vi.fn().mockResolvedValue(undefined),
|
|
readAll: vi.fn().mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools }),
|
|
getValue: vi.fn().mockResolvedValue('TestBot'),
|
|
setValue: vi.fn().mockResolvedValue('OldBot'),
|
|
getConfigPath: vi
|
|
.fn()
|
|
.mockImplementation((section?: string) =>
|
|
section
|
|
? `/home/user/.config/mosaic/${section.toUpperCase()}.md`
|
|
: '/home/user/.config/mosaic',
|
|
),
|
|
isInitialized: vi.fn().mockReturnValue(true),
|
|
};
|
|
|
|
// Mock the config-service module so commands use our mock.
|
|
vi.mock('../config/config-service.js', () => ({
|
|
createConfigService: vi.fn(() => mockSvc),
|
|
}));
|
|
|
|
// Also mock child_process for the edit command.
|
|
vi.mock('node:child_process', () => ({
|
|
spawnSync: vi.fn().mockReturnValue({ status: 0, error: undefined }),
|
|
}));
|
|
|
|
// ── config show ───────────────────────────────────────────────────────────────
|
|
|
|
describe('config show', () => {
|
|
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
vi.clearAllMocks();
|
|
mockSvc.isInitialized.mockReturnValue(true);
|
|
mockSvc.readAll.mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools });
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('calls readAll() and prints a table by default', async () => {
|
|
const program = buildProgram();
|
|
await program.parseAsync(['node', 'mosaic', 'config', 'show']);
|
|
expect(mockSvc.readAll).toHaveBeenCalledOnce();
|
|
// Should have printed something
|
|
expect(consoleSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('prints JSON when --format json is passed', async () => {
|
|
const program = buildProgram();
|
|
await program.parseAsync(['node', 'mosaic', 'config', 'show', '--format', 'json']);
|
|
expect(mockSvc.readAll).toHaveBeenCalledOnce();
|
|
// Verify JSON was logged
|
|
const allOutput = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
|
expect(allOutput).toContain('"agentName"');
|
|
});
|
|
});
|
|
|
|
// ── config get ────────────────────────────────────────────────────────────────
|
|
|
|
describe('config get', () => {
|
|
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
vi.clearAllMocks();
|
|
mockSvc.isInitialized.mockReturnValue(true);
|
|
mockSvc.getValue.mockResolvedValue('TestBot');
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('delegates to getValue() with the provided key', async () => {
|
|
const program = buildProgram();
|
|
await program.parseAsync(['node', 'mosaic', 'config', 'get', 'soul.agentName']);
|
|
expect(mockSvc.getValue).toHaveBeenCalledWith('soul.agentName');
|
|
});
|
|
|
|
it('prints the returned value', async () => {
|
|
const program = buildProgram();
|
|
await program.parseAsync(['node', 'mosaic', 'config', 'get', 'soul.agentName']);
|
|
expect(consoleSpy).toHaveBeenCalledWith('TestBot');
|
|
});
|
|
});
|
|
|
|
// ── config set ────────────────────────────────────────────────────────────────
|
|
|
|
describe('config set', () => {
|
|
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
vi.clearAllMocks();
|
|
mockSvc.isInitialized.mockReturnValue(true);
|
|
mockSvc.setValue.mockResolvedValue('OldBot');
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('delegates to setValue() with key and value', async () => {
|
|
const program = buildProgram();
|
|
await program.parseAsync(['node', 'mosaic', 'config', 'set', 'soul.agentName', 'NewBot']);
|
|
expect(mockSvc.setValue).toHaveBeenCalledWith('soul.agentName', 'NewBot');
|
|
});
|
|
|
|
it('prints old and new values', async () => {
|
|
const program = buildProgram();
|
|
await program.parseAsync(['node', 'mosaic', 'config', 'set', 'soul.agentName', 'NewBot']);
|
|
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
|
expect(output).toContain('OldBot');
|
|
expect(output).toContain('NewBot');
|
|
});
|
|
});
|
|
|
|
// ── config path ───────────────────────────────────────────────────────────────
|
|
|
|
describe('config path', () => {
|
|
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
vi.clearAllMocks();
|
|
mockSvc.getConfigPath.mockImplementation((section?: string) =>
|
|
section
|
|
? `/home/user/.config/mosaic/${section.toUpperCase()}.md`
|
|
: '/home/user/.config/mosaic',
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('prints the mosaicHome directory when no section is specified', async () => {
|
|
const program = buildProgram();
|
|
await program.parseAsync(['node', 'mosaic', 'config', 'path']);
|
|
expect(mockSvc.getConfigPath).toHaveBeenCalledWith();
|
|
expect(consoleSpy).toHaveBeenCalledWith('/home/user/.config/mosaic');
|
|
});
|
|
|
|
it('prints the section file path when --section is given', async () => {
|
|
const program = buildProgram();
|
|
await program.parseAsync(['node', 'mosaic', 'config', 'path', '--section', 'soul']);
|
|
expect(mockSvc.getConfigPath).toHaveBeenCalledWith('soul');
|
|
expect(consoleSpy).toHaveBeenCalledWith('/home/user/.config/mosaic/SOUL.md');
|
|
});
|
|
});
|
|
|
|
// ── config edit ───────────────────────────────────────────────────────────────
|
|
|
|
describe('config edit', () => {
|
|
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
let spawnSyncMock: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(async () => {
|
|
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
vi.clearAllMocks();
|
|
mockSvc.isInitialized.mockReturnValue(true);
|
|
mockSvc.readAll.mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools });
|
|
mockSvc.getConfigPath.mockImplementation((section?: string) =>
|
|
section
|
|
? `/home/user/.config/mosaic/${section.toUpperCase()}.md`
|
|
: '/home/user/.config/mosaic',
|
|
);
|
|
|
|
// Re-import to get the mock reference
|
|
const cp = await import('node:child_process');
|
|
spawnSyncMock = cp.spawnSync as ReturnType<typeof vi.fn>;
|
|
spawnSyncMock.mockReturnValue({ status: 0, error: undefined });
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('calls spawnSync with the editor binary and config path', async () => {
|
|
process.env['EDITOR'] = 'nano';
|
|
const program = buildProgram();
|
|
await program.parseAsync(['node', 'mosaic', 'config', 'edit']);
|
|
expect(spawnSyncMock).toHaveBeenCalledWith(
|
|
'nano',
|
|
['/home/user/.config/mosaic'],
|
|
expect.objectContaining({ stdio: 'inherit' }),
|
|
);
|
|
delete process.env['EDITOR'];
|
|
});
|
|
|
|
it('falls back to "vi" when EDITOR is not set', async () => {
|
|
delete process.env['EDITOR'];
|
|
const program = buildProgram();
|
|
await program.parseAsync(['node', 'mosaic', 'config', 'edit']);
|
|
expect(spawnSyncMock).toHaveBeenCalledWith('vi', expect.any(Array), expect.any(Object));
|
|
});
|
|
|
|
it('opens the section-specific file when --section is provided', async () => {
|
|
process.env['EDITOR'] = 'code';
|
|
const program = buildProgram();
|
|
await program.parseAsync(['node', 'mosaic', 'config', 'edit', '--section', 'soul']);
|
|
expect(spawnSyncMock).toHaveBeenCalledWith(
|
|
'code',
|
|
['/home/user/.config/mosaic/SOUL.md'],
|
|
expect.any(Object),
|
|
);
|
|
delete process.env['EDITOR'];
|
|
});
|
|
});
|
|
|
|
// ── not-initialized guard ────────────────────────────────────────────────────
|
|
|
|
describe('not-initialized guard', () => {
|
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
vi.clearAllMocks();
|
|
mockSvc.isInitialized.mockReturnValue(false);
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleErrorSpy.mockRestore();
|
|
mockSvc.isInitialized.mockReturnValue(true);
|
|
});
|
|
|
|
it('prints a helpful message when config is missing (show)', async () => {
|
|
const program = buildProgram();
|
|
// process.exit is intercepted; catch the resulting error from exitOverride
|
|
await expect(program.parseAsync(['node', 'mosaic', 'config', 'show'])).rejects.toThrow();
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('mosaic wizard'));
|
|
});
|
|
});
|