Files
stack/packages/mosaic/src/commands/config.spec.ts
jason.woltje cd8b1f666d
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
feat: wizard remediation — password mask, hooks preview, headless (IUH-M02) (#431)
2026-04-05 17:47:53 +00:00

435 lines
16 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 required subcommands', () => {
const program = buildProgram();
const config = getConfigCmd(program);
const subs = config.commands.map((c) => c.name()).sort();
expect(subs).toEqual(['edit', 'get', 'hooks', 'path', 'set', 'show']);
});
it('registers hooks sub-subcommands: list, enable, disable', () => {
const program = buildProgram();
const config = getConfigCmd(program);
const hooks = config.commands.find((c) => c.name() === 'hooks');
expect(hooks).toBeDefined();
const hookSubs = hooks!.commands.map((c) => c.name()).sort();
expect(hookSubs).toEqual(['disable', 'enable', 'list']);
});
});
// ── 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'];
});
});
// ── config hooks ─────────────────────────────────────────────────────────────
const MOCK_HOOKS_CONFIG = JSON.stringify({
name: 'Test Hooks',
hooks: {
PostToolUse: [
{
matcher: 'Write|Edit',
hooks: [{ type: 'command', command: 'bash', args: ['-c', 'echo'] }],
},
],
},
});
const MOCK_HOOKS_WITH_DISABLED = JSON.stringify({
name: 'Test Hooks',
hooks: {
PostToolUse: [{ matcher: 'Write|Edit', hooks: [] }],
_disabled_PreToolUse: [{ matcher: 'Bash', hooks: [] }],
},
});
vi.mock('node:fs', () => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
}));
async function getFsMock() {
const fs = await import('node:fs');
return {
existsSync: fs.existsSync as ReturnType<typeof vi.fn>,
readFileSync: fs.readFileSync as ReturnType<typeof vi.fn>,
writeFileSync: fs.writeFileSync as ReturnType<typeof vi.fn>,
};
}
describe('config hooks list', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.clearAllMocks();
mockSvc.isInitialized.mockReturnValue(true);
const fs = await getFsMock();
fs.existsSync.mockReturnValue(true);
fs.readFileSync.mockReturnValue(MOCK_HOOKS_CONFIG);
// Ensure CLAUDE_HOME is set to a stable value for tests
process.env['CLAUDE_HOME'] = '/tmp/claude-test';
});
afterEach(() => {
consoleSpy.mockRestore();
delete process.env['CLAUDE_HOME'];
});
it('lists hooks with enabled/disabled status', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('PostToolUse');
expect(output).toContain('enabled');
});
it('shows disabled hooks from MOCK_HOOKS_WITH_DISABLED', async () => {
const fs = await getFsMock();
fs.readFileSync.mockReturnValue(MOCK_HOOKS_WITH_DISABLED);
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('disabled');
expect(output).toContain('PreToolUse');
});
it('prints a message when hooks-config.json is missing', async () => {
const fs = await getFsMock();
fs.existsSync.mockReturnValue(false);
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('No hooks-config.json');
});
});
describe('config hooks disable / enable', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.clearAllMocks();
mockSvc.isInitialized.mockReturnValue(true);
const fs = await getFsMock();
fs.existsSync.mockReturnValue(true);
fs.readFileSync.mockReturnValue(MOCK_HOOKS_CONFIG);
process.env['CLAUDE_HOME'] = '/tmp/claude-test';
});
afterEach(() => {
consoleSpy.mockRestore();
delete process.env['CLAUDE_HOME'];
});
it('disables a hook by event name and writes updated config', async () => {
const fs = await getFsMock();
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'disable', 'PostToolUse']);
expect(fs.writeFileSync).toHaveBeenCalled();
const written = JSON.parse((fs.writeFileSync.mock.calls[0] as [string, string])[1]) as {
hooks: Record<string, unknown>;
};
expect(written.hooks['_disabled_PostToolUse']).toBeDefined();
expect(written.hooks['PostToolUse']).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('disabled'));
});
it('enables a disabled hook and writes updated config', async () => {
const fs = await getFsMock();
fs.readFileSync.mockReturnValue(MOCK_HOOKS_WITH_DISABLED);
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'enable', 'PreToolUse']);
expect(fs.writeFileSync).toHaveBeenCalled();
const written = JSON.parse((fs.writeFileSync.mock.calls[0] as [string, string])[1]) as {
hooks: Record<string, unknown>;
};
expect(written.hooks['PreToolUse']).toBeDefined();
expect(written.hooks['_disabled_PreToolUse']).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('enabled'));
});
});
// ── 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'));
});
});