agent-ecosystem/test/main/services/team/ClaudeBinaryResolver.test.ts

226 lines
8.7 KiB
TypeScript

// @vitest-environment node
import type { PathLike } from 'fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mockBuildMergedCliPath = vi.fn<(binaryPath: string | null) => string>();
const mockGetShellPreferredHome = vi.fn<() => string>();
const mockGetClaudeBasePath = vi.fn<() => string>();
const mockResolveInteractiveShellEnv = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'agent_teams_orchestrator'>();
const mockGetDoctorInvokedCandidates = vi.fn<(commandName: string) => Promise<string[]>>();
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
const statMock = vi.fn<(filePath: PathLike) => Promise<{ isFile: () => boolean }>>();
vi.mock('@main/utils/cliPathMerge', () => ({
buildMergedCliPath: (binaryPath: string | null) => mockBuildMergedCliPath(binaryPath),
}));
vi.mock('@main/utils/shellEnv', () => ({
getShellPreferredHome: () => mockGetShellPreferredHome(),
resolveInteractiveShellEnv: () => mockResolveInteractiveShellEnv(),
}));
vi.mock('@main/utils/pathDecoder', () => ({
getClaudeBasePath: () => mockGetClaudeBasePath(),
}));
vi.mock('@main/services/team/cliFlavor', () => ({
getConfiguredCliFlavor: () => mockGetConfiguredCliFlavor(),
}));
vi.mock('@main/services/team/ClaudeDoctorProbe', () => ({
getDoctorInvokedCandidates: (commandName: string) => mockGetDoctorInvokedCandidates(commandName),
}));
vi.mock('fs', () => ({
default: {
constants: { X_OK: 1 },
promises: {
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
stat: (filePath: PathLike) => statMock(filePath),
},
},
constants: { X_OK: 1 },
promises: {
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
stat: (filePath: PathLike) => statMock(filePath),
},
}));
describe('ClaudeBinaryResolver', () => {
const originalPlatform = process.platform;
const originalCwd = process.cwd;
const originalResourcesPath = process.resourcesPath;
const workspaceRoot = '/Users/belief/dev/projects/claude/claude_team_runtime';
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockBuildMergedCliPath.mockReturnValue('/usr/local/bin:/usr/bin');
mockGetShellPreferredHome.mockReturnValue('/Users/tester');
mockGetClaudeBasePath.mockReturnValue('/Users/tester/.claude');
mockResolveInteractiveShellEnv.mockResolvedValue({});
mockGetConfiguredCliFlavor.mockReturnValue('agent_teams_orchestrator');
mockGetDoctorInvokedCandidates.mockResolvedValue([]);
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
writable: true,
});
process.cwd = vi.fn(() => workspaceRoot);
Object.defineProperty(process, 'resourcesPath', {
value: '/Applications/Claude Agent Teams UI.app/Contents/Resources',
configurable: true,
writable: true,
});
delete process.env.CLAUDE_CLI_PATH;
delete process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
});
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
writable: true,
});
process.cwd = originalCwd;
Object.defineProperty(process, 'resourcesPath', {
value: originalResourcesPath,
configurable: true,
writable: true,
});
vi.unstubAllEnvs();
});
it('resolves agent_teams_orchestrator runtime from an explicit CLAUDE_CLI_PATH override', async () => {
const expectedBinary = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev';
process.env.CLAUDE_CLI_PATH = expectedBinary;
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return Promise.resolve();
}
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
it('prefers the dedicated CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH override', async () => {
const expectedBinary = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev';
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = expectedBinary;
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return Promise.resolve();
}
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
it('ignores the dedicated orchestrator overrides when Claude flavor is selected', async () => {
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
'/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev';
mockGetConfiguredCliFlavor.mockReturnValue('claude');
const expectedBinary = '/usr/local/bin/claude';
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return Promise.resolve();
}
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
it('falls back to claude-multimodel on PATH for agent_teams_orchestrator runtime', async () => {
const expectedBinary = '/usr/local/bin/claude-multimodel';
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return Promise.resolve();
}
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
it('prefers the bundled runtime binary for packaged agent_teams_orchestrator builds', async () => {
const expectedBinary =
'/Applications/Claude Agent Teams UI.app/Contents/Resources/runtime/claude-multimodel';
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return Promise.resolve();
}
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
it('finds npm-local Claude install in the vendor bin directory', async () => {
mockGetConfiguredCliFlavor.mockReturnValue('claude');
const expectedBinary = '/Users/tester/.claude/local/node_modules/.bin/claude';
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return Promise.resolve();
}
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
it('falls back to the doctor Invoked path when normal resolution misses the CLI', async () => {
mockGetConfiguredCliFlavor.mockReturnValue('claude');
mockGetDoctorInvokedCandidates.mockResolvedValue([
'/Users/tester/.local/share/claude/versions/2.1.101',
]);
const expectedBinary = '/Users/tester/.local/share/claude/versions/2.1.101';
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return Promise.resolve();
}
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(mockGetDoctorInvokedCandidates).toHaveBeenCalledWith('claude');
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
});