agent-ecosystem/test/main/services/team/TeamMcpConfigBuilder.test.ts
Mike 7609c548c5 fix(tests): resolve pre-existing test failures on non-standard environments
- TeamProvisioningServiceRelay: add missing stat fields (mode, dev, ino,
  mtimeMs, ctimeMs, birthtimeMs) to fs mock so new fingerprint-based
  TeamConfigReader cache can read config in tests
- TeamMcpConfigBuilder: export clearResolvedNodePathForTests() to reset
  module-level node path cache between tests; restore execFileMock
  implementation in beforeEach after vi.restoreAllMocks() clears it;
  broaden node binary regex to accept versioned names (node-22, node-20)
  common on Fedora/RHEL systems
- ScheduledTaskExecutor: strip CLAUDECODE at spawn site as last defence
  so nested-session detection is prevented even when buildProviderAwareCliEnv
  merges it back in from the outer process environment
2026-05-02 20:10:42 +05:00

655 lines
23 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs';
import Module from 'module';
import * as os from 'os';
import * as path from 'path';
const hoisted = vi.hoisted(() => ({
electronState: {
isPackaged: false,
version: '9.9.9-test',
},
execFileMock: vi.fn(
(
_file: string,
_args: readonly string[],
_options:
| { encoding?: string; timeout?: number }
| ((error: Error | null, stdout: string, stderr: string) => void),
callback?: (error: Error | null, stdout: string, stderr: string) => void
) => {
const cb = typeof _options === 'function' ? _options : callback;
cb?.(null, '/mock/node', '');
}
),
}));
let mockHomeDir = '';
type ModuleLoad = (request: string, parent: NodeModule | undefined, isMain: boolean) => unknown;
const moduleInternal = Module as unknown as { _load: ModuleLoad };
const originalModuleLoad = moduleInternal._load;
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
execFile: hoisted.execFileMock,
};
});
vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
return {
...actual,
getHomeDir: () => mockHomeDir || actual.getHomeDir(),
};
});
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import {
TeamMcpConfigBuilder,
clearResolvedNodePathForTests,
} from '@main/services/team/TeamMcpConfigBuilder';
describe('TeamMcpConfigBuilder', () => {
const createdPaths: string[] = [];
const createdDirs: string[] = [];
let tempAppData: string;
let originalResourcesPath: string | undefined;
function setPackagedMode(isPackaged: boolean, version = '9.9.9-test'): void {
hoisted.electronState.isPackaged = isPackaged;
hoisted.electronState.version = version;
}
function setResourcesPath(resourcesPath: string | undefined): void {
Object.defineProperty(process, 'resourcesPath', {
value: resourcesPath,
configurable: true,
writable: true,
});
}
function createPackagedServerBundle(baseDir: string, body = '// packaged server'): string {
const dir = path.join(baseDir, 'mcp-server');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'index.js'), body);
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'agent-teams-mcp' }));
return dir;
}
function readGeneratedServer(
configPath: string
): { command?: string; args?: string[]; env?: Record<string, string> } | undefined {
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw) as {
mcpServers?: Record<
string,
{ command?: string; args?: string[]; env?: Record<string, string> }
>;
};
return parsed.mcpServers?.['agent-teams'];
}
function expectNodeEntry(
server: { command?: string; args?: string[] } | undefined,
entry: string
): void {
expect(server?.args).toEqual([entry]);
expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/);
}
function expectNodeTsxSourceEntry(
server: { command?: string; args?: string[] } | undefined,
tsxCli: string,
sourceEntry: string
): void {
expect(server?.args).toEqual([tsxCli, sourceEntry]);
expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/);
}
function getBuiltWorkspaceEntry(): string {
return path.join(process.cwd(), 'mcp-server', 'dist', 'index.js');
}
function getSourceWorkspaceEntry(): string {
return path.join(process.cwd(), 'mcp-server', 'src', 'index.ts');
}
function getWorkspaceTsxPackageJson(): string {
return path.join(process.cwd(), 'mcp-server', 'node_modules', 'tsx', 'package.json');
}
function getWorkspaceTsxCli(): string {
return path.join(process.cwd(), 'mcp-server', 'node_modules', 'tsx', 'dist', 'cli.mjs');
}
function mockPathExists(existingPaths: string[], options: { strict?: boolean } = {}): void {
const originalAccess = fs.promises.access.bind(fs.promises);
vi.spyOn(fs.promises, 'access').mockImplementation(async (targetPath, mode) => {
const normalizedPath =
typeof targetPath === 'string'
? targetPath
: Buffer.isBuffer(targetPath)
? targetPath.toString()
: `${targetPath}`;
if (existingPaths.includes(normalizedPath)) {
return;
}
if (options.strict) {
const error = new Error(
`ENOENT: no such file or directory, access '${normalizedPath}'`
) as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
await originalAccess(targetPath, mode);
});
}
function mockSourceWorkspaceEntryAvailable(): {
sourceEntry: string;
tsxPackageJson: string;
tsxCli: string;
builtEntry: string;
} {
const sourceEntry = getSourceWorkspaceEntry();
const tsxPackageJson = getWorkspaceTsxPackageJson();
const tsxCli = getWorkspaceTsxCli();
const builtEntry = getBuiltWorkspaceEntry();
mockPathExists([sourceEntry, tsxPackageJson, tsxCli, builtEntry], { strict: true });
return { sourceEntry, tsxPackageJson, tsxCli, builtEntry };
}
function mockBuiltWorkspaceEntryAvailable(): string {
const builtEntry = getBuiltWorkspaceEntry();
mockPathExists([builtEntry], { strict: true });
return builtEntry;
}
beforeEach(() => {
clearResolvedNodePathForTests();
originalResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
tempAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-appdata-'));
createdDirs.push(tempAppData);
moduleInternal._load = ((request, parent, isMain) => {
if (request === 'electron') {
return {
app: {
get isPackaged() {
return hoisted.electronState.isPackaged;
},
getVersion: () => hoisted.electronState.version,
getPath: () => '/mock/electron-user-data',
},
};
}
return originalModuleLoad(request, parent, isMain);
}) as ModuleLoad;
setAppDataBasePath(tempAppData);
setPackagedMode(false);
setResourcesPath(undefined);
hoisted.execFileMock.mockClear();
});
afterEach(() => {
setAppDataBasePath(null);
setClaudeBasePathOverride(null);
setPackagedMode(false);
setResourcesPath(originalResourcesPath);
moduleInternal._load = originalModuleLoad;
vi.restoreAllMocks();
for (const filePath of createdPaths.splice(0)) {
try {
fs.rmSync(filePath, { force: true });
} catch {
// ignore cleanup issues in temp dir
}
}
for (const dirPath of createdDirs.splice(0)) {
try {
fs.rmSync(dirPath, { recursive: true, force: true });
} catch {
// ignore cleanup issues in temp dir
}
}
mockHomeDir = '';
});
// ── Config storage ──
it('writes config to userData/mcp-configs/, not the system default tmp', async () => {
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
const expectedDir = path.join(tempAppData, 'mcp-configs');
expect(configPath.startsWith(expectedDir)).toBe(true);
// Config must NOT be in the old hardcoded location
expect(configPath).not.toContain('claude-team-mcp');
});
it('config filename contains pid, timestamp, and uuid', async () => {
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
const filename = path.basename(configPath);
expect(filename).toMatch(new RegExp(`^agent-teams-mcp-${process.pid}-\\d+-[0-9a-f-]+\\.json$`));
});
it('prefers the source workspace MCP entry in dev mode when available', async () => {
const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable();
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw) as {
mcpServers?: Record<string, { command?: string; args?: string[] }>;
};
const server = parsed.mcpServers?.['agent-teams'];
expectNodeTsxSourceEntry(server, tsxCli, sourceEntry);
});
it('pins the MCP controller to the active Claude base path', async () => {
const claudeDir = path.join(tempAppData, 'custom-claude-root');
setClaudeBasePathOverride(claudeDir);
mockSourceWorkspaceEntryAvailable();
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
const server = readGeneratedServer(configPath);
expect(server?.env?.AGENT_TEAMS_MCP_CLAUDE_DIR).toBe(claudeDir);
});
it('falls back to the built workspace MCP entry when source execution is unavailable', async () => {
const builtEntry = mockBuiltWorkspaceEntryAvailable();
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw) as {
mcpServers?: Record<string, { command?: string; args?: string[] }>;
};
const server = parsed.mcpServers?.['agent-teams'];
expectNodeEntry(server, builtEntry);
});
it('keeps generated team MCP config minimal and does not inline top-level user MCP', async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
createdDirs.push(homeDir, projectDir);
mockHomeDir = homeDir;
fs.writeFileSync(
path.join(homeDir, '.claude.json'),
JSON.stringify(
{
mcpServers: {
globalOnly: { type: 'http', url: 'https://global.example.com/mcp' },
duplicateServer: { type: 'http', url: 'https://global.example.com/duplicate' },
},
},
null,
2
)
);
fs.writeFileSync(
path.join(projectDir, '.mcp.json'),
JSON.stringify(
{
mcpServers: {
projectOnly: { command: 'node', args: ['project-server.js'] },
duplicateServer: { command: 'node', args: ['project-override.js'] },
},
},
null,
2
)
);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile(projectDir);
createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<
string,
{ command?: string; args?: string[]; type?: string; url?: string }
>;
};
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
expect(parsed.mcpServers.globalOnly).toBeUndefined();
expect(parsed.mcpServers.duplicateServer).toBeUndefined();
});
it('does not inline project MCP config to preserve native Claude precedence', async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
createdDirs.push(homeDir, projectDir);
mockHomeDir = homeDir;
fs.writeFileSync(
path.join(homeDir, '.claude.json'),
JSON.stringify({ mcpServers: {} }, null, 2)
);
fs.writeFileSync(
path.join(projectDir, '.mcp.json'),
JSON.stringify(
{
mcpServers: {
projectOnly: { command: 'node', args: ['project-server.js'] },
},
},
null,
2
)
);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile(projectDir);
createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<string, { command?: string; args?: string[] }>;
};
expect(parsed.mcpServers.projectOnly).toBeUndefined();
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
});
it('generated agent-teams server ignores same-named user MCP entry', async () => {
const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable();
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
createdDirs.push(homeDir);
mockHomeDir = homeDir;
fs.writeFileSync(
path.join(homeDir, '.claude.json'),
JSON.stringify(
{
mcpServers: {
'agent-teams': { command: 'node', args: ['user-server.js'] },
},
},
null,
2
)
);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<string, { command?: string; args?: string[] }>;
};
expectNodeTsxSourceEntry(parsed.mcpServers['agent-teams'], tsxCli, sourceEntry);
});
it('passes the configured Claude root to the MCP server', async () => {
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-claude-root-'));
createdDirs.push(claudeRoot);
setClaudeBasePathOverride(claudeRoot);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
expect(readGeneratedServer(configPath)?.env).toMatchObject({
AGENT_TEAMS_MCP_CLAUDE_DIR: claudeRoot,
});
});
it('ignores malformed user MCP file', async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
createdDirs.push(homeDir, projectDir);
mockHomeDir = homeDir;
fs.writeFileSync(path.join(homeDir, '.claude.json'), '{ invalid json');
const builder = new TeamMcpConfigBuilder();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
let configPath = '';
try {
configPath = await builder.writeConfigFile(projectDir);
} finally {
warnSpy.mockRestore();
}
createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<string, { command?: string; args?: string[] }>;
};
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
});
// ── Cleanup: removeConfigFile ──
it('removeConfigFile deletes the file', async () => {
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
expect(fs.existsSync(configPath)).toBe(true);
await builder.removeConfigFile(configPath);
expect(fs.existsSync(configPath)).toBe(false);
});
it('removeConfigFile ignores ENOENT', async () => {
const builder = new TeamMcpConfigBuilder();
const bogusPath = path.join(tempAppData, 'nonexistent.json');
// Should not throw
await builder.removeConfigFile(bogusPath);
});
it('removeConfigFile defers Windows locked temp config cleanup without warning', async () => {
const builder = new TeamMcpConfigBuilder();
const configPath = path.join(
tempAppData,
'mcp-configs',
`agent-teams-mcp-${process.pid}-locked.json`
);
const originalUnlink = fs.promises.unlink.bind(fs.promises);
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockImplementation(async (targetPath) => {
if (targetPath === configPath) {
const error = new Error('EPERM: operation not permitted, unlink') as NodeJS.ErrnoException;
error.code = 'EPERM';
throw error;
}
await originalUnlink(targetPath);
});
await builder.removeConfigFile(configPath);
expect(unlinkSpy).toHaveBeenCalledTimes(4);
});
// ── Cleanup: gcOwnConfigs ──
it('gcOwnConfigs removes only files owned by current pid', async () => {
const configDir = path.join(tempAppData, 'mcp-configs');
fs.mkdirSync(configDir, { recursive: true });
const ownFile = path.join(configDir, `agent-teams-mcp-${process.pid}-12345-abc.json`);
const otherFile = path.join(configDir, `agent-teams-mcp-99999-12345-xyz.json`);
fs.writeFileSync(ownFile, '{}');
fs.writeFileSync(otherFile, '{}');
const builder = new TeamMcpConfigBuilder();
await builder.gcOwnConfigs();
expect(fs.existsSync(ownFile)).toBe(false);
expect(fs.existsSync(otherFile)).toBe(true);
});
// ── Cleanup: gcStaleConfigs ──
it('gcStaleConfigs removes files older than TTL', async () => {
const configDir = path.join(tempAppData, 'mcp-configs');
fs.mkdirSync(configDir, { recursive: true });
const oldFile = path.join(configDir, `agent-teams-mcp-999-1-old.json`);
fs.writeFileSync(oldFile, '{}');
// Set mtime to 2 days ago
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
fs.utimesSync(oldFile, twoDaysAgo, twoDaysAgo);
const freshFile = path.join(configDir, `agent-teams-mcp-999-2-fresh.json`);
fs.writeFileSync(freshFile, '{}');
const builder = new TeamMcpConfigBuilder();
await builder.gcStaleConfigs(24 * 60 * 60 * 1000); // 24h TTL
expect(fs.existsSync(oldFile)).toBe(false);
expect(fs.existsSync(freshFile)).toBe(true);
});
it('gcStaleConfigs does not remove fresh files', async () => {
const configDir = path.join(tempAppData, 'mcp-configs');
fs.mkdirSync(configDir, { recursive: true });
const freshFile = path.join(configDir, `agent-teams-mcp-1-1234-abc.json`);
fs.writeFileSync(freshFile, '{}');
const builder = new TeamMcpConfigBuilder();
await builder.gcStaleConfigs(24 * 60 * 60 * 1000);
expect(fs.existsSync(freshFile)).toBe(true);
});
it('gcStaleConfigs handles empty or missing directory gracefully', async () => {
const builder = new TeamMcpConfigBuilder();
// Should not throw when directory doesn't exist
await builder.gcStaleConfigs();
});
// ── Packaged copy / fallback ──
it('packaged mode reuses an existing valid stable copy', async () => {
setPackagedMode(true, '1.2.3');
setResourcesPath(tempAppData);
const stableDir = path.join(tempAppData, 'mcp-server', '1.2.3');
fs.mkdirSync(stableDir, { recursive: true });
fs.writeFileSync(path.join(stableDir, 'index.js'), '// stable copy');
fs.writeFileSync(path.join(stableDir, 'package.json'), JSON.stringify({ name: 'stable' }));
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js'));
});
it('packaged mode copies the MCP server from resourcesPath into userData', async () => {
setPackagedMode(true, '2.0.0');
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
createdDirs.push(resourcesDir);
createPackagedServerBundle(resourcesDir, '// copied server');
setResourcesPath(resourcesDir);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
const stableDir = path.join(tempAppData, 'mcp-server', '2.0.0');
expect(fs.existsSync(path.join(stableDir, 'index.js'))).toBe(true);
expect(fs.existsSync(path.join(stableDir, 'package.json'))).toBe(true);
expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js'));
});
it('packaged mode heals a partial stable copy and rebuilds it from resourcesPath', async () => {
setPackagedMode(true, '3.0.0');
const stableDir = path.join(tempAppData, 'mcp-server', '3.0.0');
fs.mkdirSync(stableDir, { recursive: true });
fs.writeFileSync(path.join(stableDir, 'index.js'), '// partial copy only');
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
createdDirs.push(resourcesDir);
createPackagedServerBundle(resourcesDir, '// healed server');
setResourcesPath(resourcesDir);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
expect(fs.readFileSync(path.join(stableDir, 'index.js'), 'utf8')).toContain('healed server');
expect(fs.existsSync(path.join(stableDir, 'package.json'))).toBe(true);
expect(readGeneratedServer(configPath)?.args).toEqual([path.join(stableDir, 'index.js')]);
});
it('packaged mode falls back to resourcesPath when stable copy creation fails', async () => {
setPackagedMode(true, '4.0.0');
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
createdDirs.push(resourcesDir);
createPackagedServerBundle(resourcesDir, '// fallback server');
setResourcesPath(resourcesDir);
vi.spyOn(fs.promises, 'copyFile').mockRejectedValueOnce(new Error('copy failed'));
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
expectNodeEntry(
readGeneratedServer(configPath),
path.join(resourcesDir, 'mcp-server', 'index.js')
);
});
it('packaged mode uses the winner stable copy when atomic rename loses the race', async () => {
setPackagedMode(true, '5.0.0');
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
createdDirs.push(resourcesDir);
createPackagedServerBundle(resourcesDir, '// race source');
setResourcesPath(resourcesDir);
const stableDir = path.join(tempAppData, 'mcp-server', '5.0.0');
const originalRename = fs.promises.rename.bind(fs.promises);
vi.spyOn(fs.promises, 'rename').mockImplementation(async (from, to) => {
if (to === stableDir) {
fs.mkdirSync(stableDir, { recursive: true });
fs.writeFileSync(path.join(stableDir, 'index.js'), '// winner copy');
fs.writeFileSync(path.join(stableDir, 'package.json'), JSON.stringify({ name: 'winner' }));
const err = new Error('EEXIST') as NodeJS.ErrnoException;
err.code = 'EEXIST';
throw err;
}
return originalRename(from, to);
});
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
expect(fs.readFileSync(path.join(stableDir, 'index.js'), 'utf8')).toContain('winner copy');
expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js'));
});
it('packaged mode falls back to the source workspace MCP entry when resourcesPath bundle is missing', async () => {
const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable();
setPackagedMode(true, '6.0.0');
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
createdDirs.push(resourcesDir);
setResourcesPath(resourcesDir);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
expectNodeTsxSourceEntry(readGeneratedServer(configPath), tsxCli, sourceEntry);
});
});