agent-ecosystem/test/main/services/extensions/PluginInstallService.test.ts
iliya 60cf80f90a fix: resolve CLI binary dynamically for MCP diagnostics and extension installs
Use ClaudeBinaryResolver instead of null binary path in extension services
(McpHealthDiagnosticsService, PluginInstallService, McpInstallService).
Packaged Electron on macOS has minimal PATH — bare `claude` lookup fails
with ENOENT. Now all CLI calls resolve the binary via ClaudeBinaryResolver
which checks PATH, NVM, standard install dirs and login shell env.

- Add buildEnrichedEnv() helper for child process env (PATH, HOME, USERPROFILE)
- Add stale cache re-verification with 30s TTL in ClaudeBinaryResolver
- Guard execCli() against null binaryPath with explicit error
- Replace projectPath.startsWith('/') with path.isAbsolute() for Windows
- Extract CLI_NOT_FOUND_MARKER/MESSAGE constants for consistent error detection
- Show amber info banner instead of red error when CLI not installed
2026-03-22 13:10:11 +02:00

175 lines
6.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PluginInstallService } from '@main/services/extensions/install/PluginInstallService';
import type { PluginCatalogService } from '@main/services/extensions/catalog/PluginCatalogService';
// ── Mock execCli ─────────────────────────────────────────────────────────────
vi.mock('@main/utils/childProcess', () => ({
execCli: vi.fn(),
}));
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
ClaudeBinaryResolver: {
resolve: vi.fn().mockResolvedValue('/usr/local/bin/claude'),
},
}));
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { execCli } from '@main/utils/childProcess';
const mockExecCli = vi.mocked(execCli);
// ── Mock catalog service ──────────────────────────────────────────────────────
function createMockCatalog(overrides?: Partial<PluginCatalogService>): PluginCatalogService {
return {
getPlugins: vi.fn(),
getPluginReadme: vi.fn(),
resolvePlugin: vi.fn().mockResolvedValue({
qualifiedName: 'context7@claude-plugins-official',
}),
...overrides,
} as unknown as PluginCatalogService;
}
describe('PluginInstallService', () => {
let service: PluginInstallService;
let catalog: PluginCatalogService;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
catalog = createMockCatalog();
service = new PluginInstallService(catalog);
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── install ─────────────────────────────────────────────────────────────────
describe('install', () => {
it('builds correct CLI args for user scope', async () => {
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
const result = await service.install({
pluginId: 'context7',
scope: 'user',
});
expect(result.state).toBe('success');
expect(mockExecCli).toHaveBeenCalledWith(
'/usr/local/bin/claude',
['plugin', 'install', 'context7@claude-plugins-official'],
expect.objectContaining({ timeout: 120_000 }),
);
});
it('adds scope flag for non-user scope', async () => {
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
await service.install({
pluginId: 'context7',
scope: 'project',
projectPath: '/tmp/test-project',
});
expect(mockExecCli).toHaveBeenCalledWith(
'/usr/local/bin/claude',
['plugin', 'install', '-s', 'project', 'context7@claude-plugins-official'],
expect.objectContaining({ cwd: '/tmp/test-project' }),
);
});
it('returns error if plugin not found in catalog', async () => {
catalog = createMockCatalog({
resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'],
});
service = new PluginInstallService(catalog);
const result = await service.install({ pluginId: 'nonexistent', scope: 'user' });
expect(result.state).toBe('error');
expect(result.error).toContain('not found in catalog');
expect(mockExecCli).not.toHaveBeenCalled();
});
it('returns error if qualifiedName has invalid format', async () => {
catalog = createMockCatalog({
resolvePlugin: vi.fn().mockResolvedValue({
qualifiedName: '../../../etc/passwd',
}) as PluginCatalogService['resolvePlugin'],
});
service = new PluginInstallService(catalog);
const result = await service.install({ pluginId: 'evil', scope: 'user' });
expect(result.state).toBe('error');
expect(result.error).toContain('Invalid plugin identifier');
expect(mockExecCli).not.toHaveBeenCalled();
});
it('returns error if CLI execution fails', async () => {
mockExecCli.mockRejectedValue(new Error('Command failed: exit code 1'));
const result = await service.install({ pluginId: 'context7', scope: 'user' });
expect(result.state).toBe('error');
expect(result.error).toContain('Command failed');
});
});
// ── uninstall ───────────────────────────────────────────────────────────────
describe('uninstall', () => {
it('builds correct CLI args for user scope', async () => {
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
const result = await service.uninstall('context7');
expect(result.state).toBe('success');
expect(mockExecCli).toHaveBeenCalledWith(
'/usr/local/bin/claude',
['plugin', 'uninstall', 'context7@claude-plugins-official'],
expect.objectContaining({ timeout: 30_000 }),
);
});
it('adds scope flag for project scope', async () => {
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
await service.uninstall('context7', 'project', '/tmp/test-project');
expect(mockExecCli).toHaveBeenCalledWith(
'/usr/local/bin/claude',
['plugin', 'uninstall', '-s', 'project', 'context7@claude-plugins-official'],
expect.objectContaining({ cwd: '/tmp/test-project' }),
);
});
it('returns error if plugin not in catalog', async () => {
catalog = createMockCatalog({
resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'],
});
service = new PluginInstallService(catalog);
const result = await service.uninstall('nonexistent');
expect(result.state).toBe('error');
expect(result.error).toContain('not found in catalog');
});
it('returns error if CLI fails', async () => {
mockExecCli.mockRejectedValue(new Error('Cannot uninstall'));
const result = await service.uninstall('context7');
expect(result.state).toBe('error');
expect(result.error).toContain('Cannot uninstall');
});
});
});