import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { join } from 'node:path';
import { resetConfig } from '../../../src/config.js';
vi.mock('node:child_process', () => ({
execFileSync: vi.fn(),
spawnSync: vi.fn(),
}));
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
return {
...actual,
existsSync: vi.fn(),
readdirSync: vi.fn(),
readFileSync: vi.fn(),
mkdirSync: vi.fn(),
rmSync: vi.fn(),
statSync: vi.fn(),
};
});
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync, mkdirSync, rmSync, statSync } from 'node:fs';
import {
runPluginAdd,
runPluginList,
runPluginRemove,
runPluginCommand,
} from '../../../src/cli/plugin.js';
describe('runPluginAdd', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
process.env.WIGOLO_PLUGINS_DIR = '/tmp/test-plugins';
process.env.WIGOLO_PLUGIN_AUTO_YES = '1';
resetConfig();
vi.clearAllMocks();
});
afterEach(() => {
process.env = originalEnv;
resetConfig();
});
it('clones a git repo into the plugins directory', async () => {
vi.mocked(existsSync).mockReturnValue(false);
vi.mocked(mkdirSync).mockReturnValue(undefined);
vi.mocked(execFileSync).mockReturnValue(Buffer.from(''));
await runPluginAdd('https://github.com/user/wigolo-plugin-example.git');
expect(mkdirSync).toHaveBeenCalledWith('/tmp/test-plugins', { recursive: true });
expect(execFileSync).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['clone', '--depth', '1']),
expect.objectContaining({ cwd: '/tmp/test-plugins' }),
);
});
it('extracts repo name from git URL for the clone directory', async () => {
vi.mocked(existsSync).mockReturnValue(false);
vi.mocked(mkdirSync).mockReturnValue(undefined);
vi.mocked(execFileSync).mockReturnValue(Buffer.from(''));
await runPluginAdd('https://github.com/user/my-plugin.git');
expect(execFileSync).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['my-plugin']),
expect.anything(),
);
});
it('extracts repo name from URL without .git suffix', async () => {
vi.mocked(existsSync).mockReturnValue(false);
vi.mocked(mkdirSync).mockReturnValue(undefined);
vi.mocked(execFileSync).mockReturnValue(Buffer.from(''));
await runPluginAdd('https://github.com/user/no-git-suffix');
expect(execFileSync).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['no-git-suffix']),
expect.anything(),
);
});
it('throws if plugin directory already exists', async () => {
vi.mocked(existsSync).mockReturnValue(true);
await expect(runPluginAdd('https://github.com/user/my-plugin.git')).rejects.toThrow(
/already exists/i,
);
});
it('throws on git clone failure', async () => {
vi.mocked(existsSync).mockReturnValue(false);
vi.mocked(mkdirSync).mockReturnValue(undefined);
vi.mocked(execFileSync).mockImplementation(() => {
throw new Error('fatal: repository not found');
});
await expect(runPluginAdd('https://github.com/user/nonexistent.git')).rejects.toThrow(
/clone failed/i,
);
});
it('throws on empty git URL', async () => {
await expect(runPluginAdd('')).rejects.toThrow(/url/i);
});
it('throws on malformed git URL without path segments', async () => {
await expect(runPluginAdd('not-a-url')).rejects.toThrow();
});
it('handles SSH-style git URLs (git@github.com:user/repo.git)', async () => {
vi.mocked(existsSync).mockReturnValue(false);
vi.mocked(mkdirSync).mockReturnValue(undefined);
vi.mocked(execFileSync).mockReturnValue(Buffer.from(''));
await runPluginAdd('git@github.com:user/ssh-plugin.git');
expect(execFileSync).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['ssh-plugin']),
expect.anything(),
);
});
it('refuses to install when stdin is not a TTY and no --yes flag is set', async () => {
delete process.env.WIGOLO_PLUGIN_AUTO_YES;
const originalIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
vi.mocked(existsSync).mockReturnValue(false);
try {
await expect(
runPluginAdd('https://github.com/user/evil-plugin.git'),
).rejects.toThrow(/non-interactively/i);
expect(execFileSync).not.toHaveBeenCalled();
} finally {
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
process.env.WIGOLO_PLUGIN_AUTO_YES = '1';
}
});
it('skips the confirm prompt when --yes is passed', async () => {
delete process.env.WIGOLO_PLUGIN_AUTO_YES;
vi.mocked(existsSync).mockReturnValue(false);
vi.mocked(mkdirSync).mockReturnValue(undefined);
vi.mocked(execFileSync).mockReturnValue(Buffer.from(''));
await runPluginAdd('https://github.com/user/trusted-plugin.git', { assumeYes: true });
expect(execFileSync).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['clone']),
expect.anything(),
);
});
});
describe('runPluginList', () => {
const originalEnv = process.env;
let stderrOutput: string;
beforeEach(() => {
process.env = { ...originalEnv };
process.env.WIGOLO_PLUGINS_DIR = '/tmp/test-plugins';
resetConfig();
vi.clearAllMocks();
stderrOutput = '';
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
stderrOutput += String(chunk);
return true;
});
});
afterEach(() => {
process.env = originalEnv;
resetConfig();
vi.restoreAllMocks();
});
it('lists installed plugins with name and version', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readdirSync).mockReturnValue(['plugin-a', 'plugin-b'] as any);
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as any);
vi.mocked(readFileSync).mockImplementation((p) => {
if (String(p).includes('plugin-a')) {
return JSON.stringify({ name: 'plugin-a', version: '1.0.0', main: 'index.mjs' });
}
return JSON.stringify({ name: 'plugin-b', version: '2.3.0', main: 'index.mjs' });
});
runPluginList();
expect(stderrOutput).toContain('plugin-a');
expect(stderrOutput).toContain('1.0.0');
expect(stderrOutput).toContain('plugin-b');
expect(stderrOutput).toContain('2.3.0');
});
it('shows a message when no plugins are installed', () => {
vi.mocked(existsSync).mockReturnValue(false);
runPluginList();
expect(stderrOutput).toContain('no plugins');
});
it('handles empty plugins directory', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readdirSync).mockReturnValue([] as any);
runPluginList();
expect(stderrOutput).toContain('no plugins');
});
it('skips non-directory entries', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readdirSync).mockReturnValue(['file.txt', 'plugin-a'] as any);
vi.mocked(statSync).mockImplementation((p) => {
if (String(p).includes('file.txt')) {
return { isDirectory: () => false, isSymbolicLink: () => false } as any;
}
return { isDirectory: () => true, isSymbolicLink: () => false } as any;
});
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ name: 'plugin-a', version: '1.0.0', main: 'index.mjs' }),
);
runPluginList();
expect(stderrOutput).toContain('plugin-a');
expect(stderrOutput).not.toContain('file.txt');
});
it('handles plugin with malformed package.json gracefully', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readdirSync).mockReturnValue(['bad-plugin'] as any);
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as any);
vi.mocked(readFileSync).mockImplementation(() => { throw new Error('parse error'); });
runPluginList();
expect(stderrOutput).toContain('bad-plugin');
});
});
describe('runPluginRemove', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
process.env.WIGOLO_PLUGINS_DIR = '/tmp/test-plugins';
resetConfig();
vi.clearAllMocks();
});
afterEach(() => {
process.env = originalEnv;
resetConfig();
});
it('removes the plugin directory', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(rmSync).mockReturnValue(undefined);
runPluginRemove('my-plugin');
expect(rmSync).toHaveBeenCalledWith(
expect.stringContaining('my-plugin'),
{ recursive: true, force: true },
);
});
it('throws when plugin does not exist', () => {
vi.mocked(existsSync).mockReturnValue(false);
expect(() => runPluginRemove('nonexistent')).toThrow(/not found/i);
});
it('throws on empty name', () => {
expect(() => runPluginRemove('')).toThrow(/name/i);
});
it('prevents path traversal in plugin name', () => {
expect(() => runPluginRemove('../etc')).toThrow(/invalid/i);
});
it('prevents absolute path in plugin name', () => {
expect(() => runPluginRemove('/etc/passwd')).toThrow(/invalid/i);
});
it('handles plugin names with dashes and underscores', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(rmSync).mockReturnValue(undefined);
runPluginRemove('my-cool_plugin');
expect(rmSync).toHaveBeenCalledWith(
expect.stringContaining('my-cool_plugin'),
expect.anything(),
);
});
it('throws on removal failure', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(rmSync).mockImplementation(() => { throw new Error('EACCES'); });
expect(() => runPluginRemove('locked-plugin')).toThrow(/remove.*failed/i);
});
});
describe('runPluginCommand -- dispatcher', () => {
const originalEnv = process.env;
let stderrOutput: string;
beforeEach(() => {
process.env = { ...originalEnv };
process.env.WIGOLO_PLUGINS_DIR = '/tmp/test-plugins';
process.env.WIGOLO_PLUGIN_AUTO_YES = '1';
resetConfig();
vi.clearAllMocks();
stderrOutput = '';
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
stderrOutput += String(chunk);
return true;
});
});
afterEach(() => {
process.env = originalEnv;
resetConfig();
vi.restoreAllMocks();
});
it('routes "add" subcommand', async () => {
vi.mocked(existsSync).mockReturnValue(false);
vi.mocked(mkdirSync).mockReturnValue(undefined);
vi.mocked(execFileSync).mockReturnValue(Buffer.from(''));
await runPluginCommand(['add', 'https://github.com/user/repo.git']);
expect(execFileSync).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['clone']),
expect.anything(),
);
});
it('routes "list" subcommand', async () => {
vi.mocked(existsSync).mockReturnValue(false);
await runPluginCommand(['list']);
expect(stderrOutput).toContain('no plugins');
});
it('routes "remove" subcommand', async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(rmSync).mockReturnValue(undefined);
await runPluginCommand(['remove', 'my-plugin']);
expect(rmSync).toHaveBeenCalled();
});
it('shows usage for unknown subcommand', async () => {
const code = await runPluginCommand(['unknown']);
expect(stderrOutput).toContain('Usage');
expect(code).toBe(1);
});
it('shows usage when no args provided', async () => {
const code = await runPluginCommand([]);
expect(stderrOutput).toContain('Usage');
expect(code).toBe(1);
});
it('routes "validate" and exits 0 when every installed plugin is well-formed', async () => {
vi.mocked(existsSync).mockImplementation((p) => {
const s = String(p);
return s.endsWith('/test-plugins') || s.endsWith('package.json') || s.endsWith('index.mjs');
});
vi.mocked(readdirSync).mockReturnValue(['good'] as never);
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as never);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ name: 'good', version: '1.0.0', main: 'index.mjs' }) as never);
const code = await runPluginCommand(['validate']);
expect(code).toBe(0);
});
it('routes "validate" and exits 1 when a plugin fails validation (missing main file)', async () => {
vi.mocked(existsSync).mockImplementation((p) => {
const s = String(p);
return s.endsWith('/test-plugins') || s.endsWith('package.json');
});
vi.mocked(readdirSync).mockReturnValue(['bad'] as never);
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as never);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ name: 'bad', version: '1.0.0', main: 'missing.mjs' }) as never);
const code = await runPluginCommand(['validate']);
expect(code).toBe(1);
});
it('routes "validate --json" on a bad plugin → exit 1 + exactly one {status:"error"} JSON doc on stdout', async () => {
vi.mocked(existsSync).mockImplementation((p) => {
const s = String(p);
return s.endsWith('/test-plugins') || s.endsWith('package.json');
});
vi.mocked(readdirSync).mockReturnValue(['bad'] as never);
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as never);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ name: 'bad', version: '1.0.0', main: 'missing.mjs' }) as never);
const stdoutChunks: string[] = [];
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
stdoutChunks.push(String(chunk));
return true;
});
const code = await runPluginCommand(['validate', '--json']);
expect(code).toBe(1);
const lines = stdoutChunks.join('').trim().split('\n').filter((l) => l.trim().length > 0);
expect(lines).toHaveLength(1);
const doc = JSON.parse(lines[0]) as { status: string; plugins: Array<{ valid: boolean }> };
expect(doc.status).toBe('error');
expect(doc.plugins[0].valid).toBe(false);
});
});