267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
// @vitest-environment node
|
|
import type { PathLike } from 'fs';
|
|
import * as path from 'path';
|
|
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 originalPathext = process.env.PATHEXT;
|
|
const workspaceRoot = '/Users/belief/dev/projects/claude/claude_team_runtime';
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
mockBuildMergedCliPath.mockReturnValue(['/usr/local/bin', '/usr/bin'].join(path.delimiter));
|
|
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/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,
|
|
});
|
|
if (originalPathext === undefined) {
|
|
delete process.env.PATHEXT;
|
|
} else {
|
|
process.env.PATHEXT = originalPathext;
|
|
}
|
|
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('resolves extensionless Windows explicit overrides to a real executable file first', async () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
value: 'win32',
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
mockGetConfiguredCliFlavor.mockReturnValue('claude');
|
|
process.env.PATHEXT = '.EXE;.CMD';
|
|
process.env.CLAUDE_CLI_PATH = 'C:\\Tools\\claude';
|
|
const expectedBinary = 'C:\\Tools\\claude.exe';
|
|
|
|
statMock.mockImplementation((filePath) => {
|
|
if (filePath === expectedBinary) {
|
|
return Promise.resolve({ isFile: () => true });
|
|
}
|
|
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(statMock.mock.calls[0]?.[0]).toBe(expectedBinary);
|
|
});
|
|
|
|
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 = path.join('/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 = path.join('/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 = path.join(
|
|
'/Applications/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 = path.join(
|
|
'/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);
|
|
});
|
|
});
|