/**
* Sync Feature Tests
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
readAllClientConfigs,
analyzeSyncState,
areMCPServersEqual,
buildMergedConfig,
prepareSyncPayload,
isSyncNeeded,
getClientDisplayName,
getCanonicalConfig,
executeSyncToClients,
type SyncAnalysis,
type MCPDiff,
type ConflictResolution,
type ClientConfigSnapshot,
} from '../../src/features/sync.js';
import type { MCPServer, MCPConfig } from '../../src/types/index.js';
// Mock dependencies
vi.mock('../../src/utils/mcp-paths.js', () => ({
detectAvailableClients: vi.fn(),
getMCPConfigPath: vi.fn(),
configFileExists: vi.fn(),
MCP_CLIENTS: {
cursor: { id: 'cursor', name: 'Cursor', category: 'ide' },
'claude-desktop': {
id: 'claude-desktop',
name: 'Claude Desktop',
category: 'desktop',
},
'claude-code': { id: 'claude-code', name: 'Claude Code', category: 'cli' },
},
}));
vi.mock('../../src/utils/mcp-io.js', () => ({
readMCPConfig: vi.fn(),
writeMCPConfig: vi.fn(),
}));
import {
detectAvailableClients,
getMCPConfigPath,
configFileExists,
} from '../../src/utils/mcp-paths.js';
import { readMCPConfig, writeMCPConfig } from '../../src/utils/mcp-io.js';
describe('areMCPServersEqual', () => {
it('should return true for identical configs', () => {
const a: MCPServer = {
command: 'npx',
args: ['octocode-mcp@latest'],
};
const b: MCPServer = {
command: 'npx',
args: ['octocode-mcp@latest'],
};
expect(areMCPServersEqual(a, b)).toBe(true);
});
it('should return false for different commands', () => {
const a: MCPServer = {
command: 'npx',
args: ['octocode-mcp@latest'],
};
const b: MCPServer = {
command: 'node',
args: ['octocode-mcp@latest'],
};
expect(areMCPServersEqual(a, b)).toBe(false);
});
it('should return false for different args', () => {
const a: MCPServer = {
command: 'npx',
args: ['octocode-mcp@latest'],
};
const b: MCPServer = {
command: 'npx',
args: ['octocode-mcp@1.0.0'],
};
expect(areMCPServersEqual(a, b)).toBe(false);
});
it('should return false for different args length', () => {
const a: MCPServer = {
command: 'npx',
args: ['octocode-mcp@latest'],
};
const b: MCPServer = {
command: 'npx',
args: ['octocode-mcp@latest', '--flag'],
};
expect(areMCPServersEqual(a, b)).toBe(false);
});
it('should handle env vars correctly', () => {
const a: MCPServer = {
command: 'npx',
args: ['test'],
env: { KEY: 'value' },
};
const b: MCPServer = {
command: 'npx',
args: ['test'],
env: { KEY: 'value' },
};
expect(areMCPServersEqual(a, b)).toBe(true);
});
it('should return false for different env vars', () => {
const a: MCPServer = {
command: 'npx',
args: ['test'],
env: { KEY: 'value1' },
};
const b: MCPServer = {
command: 'npx',
args: ['test'],
env: { KEY: 'value2' },
};
expect(areMCPServersEqual(a, b)).toBe(false);
});
it('should handle missing env vars', () => {
const a: MCPServer = {
command: 'npx',
args: ['test'],
env: { KEY: 'value' },
};
const b: MCPServer = {
command: 'npx',
args: ['test'],
};
expect(areMCPServersEqual(a, b)).toBe(false);
});
it('should handle empty env vars', () => {
const a: MCPServer = {
command: 'npx',
args: ['test'],
env: {},
};
const b: MCPServer = {
command: 'npx',
args: ['test'],
};
expect(areMCPServersEqual(a, b)).toBe(true);
});
});
describe('buildMergedConfig', () => {
it('should add MCPs to empty config', () => {
const mcpsToSync = [
{ mcpId: 'octocode', server: { command: 'npx', args: ['test'] } },
];
const result = buildMergedConfig(null, mcpsToSync);
expect(result.mcpServers).toEqual({
octocode: { command: 'npx', args: ['test'] },
});
});
it('should merge MCPs into existing config', () => {
const currentConfig: MCPConfig = {
mcpServers: {
existing: { command: 'node', args: ['existing.js'] },
},
};
const mcpsToSync = [
{ mcpId: 'octocode', server: { command: 'npx', args: ['test'] } },
];
const result = buildMergedConfig(currentConfig, mcpsToSync);
expect(result.mcpServers).toEqual({
existing: { command: 'node', args: ['existing.js'] },
octocode: { command: 'npx', args: ['test'] },
});
});
it('should override existing MCP with same id', () => {
const currentConfig: MCPConfig = {
mcpServers: {
octocode: { command: 'node', args: ['old.js'] },
},
};
const mcpsToSync = [
{ mcpId: 'octocode', server: { command: 'npx', args: ['new'] } },
];
const result = buildMergedConfig(currentConfig, mcpsToSync);
expect(result.mcpServers).toEqual({
octocode: { command: 'npx', args: ['new'] },
});
});
});
describe('analyzeSyncState', () => {
it('should identify fully consistent MCPs', () => {
const snapshots: ClientConfigSnapshot[] = [
{
client: 'cursor',
configPath: '/path/cursor.json',
config: {
mcpServers: {
octocode: { command: 'npx', args: ['octocode-mcp@latest'] },
},
},
exists: true,
mcpCount: 1,
},
{
client: 'claude-desktop',
configPath: '/path/claude.json',
config: {
mcpServers: {
octocode: { command: 'npx', args: ['octocode-mcp@latest'] },
},
},
exists: true,
mcpCount: 1,
},
];
const analysis = analyzeSyncState(snapshots);
expect(analysis.summary.consistentMCPs).toBe(1);
expect(analysis.summary.needsSyncCount).toBe(0);
expect(analysis.summary.conflictCount).toBe(0);
});
it('should identify MCPs needing sync', () => {
const snapshots: ClientConfigSnapshot[] = [
{
client: 'cursor',
configPath: '/path/cursor.json',
config: {
mcpServers: {
octocode: { command: 'npx', args: ['octocode-mcp@latest'] },
},
},
exists: true,
mcpCount: 1,
},
{
client: 'claude-desktop',
configPath: '/path/claude.json',
config: {
mcpServers: {},
},
exists: true,
mcpCount: 0,
},
];
const analysis = analyzeSyncState(snapshots);
expect(analysis.summary.consistentMCPs).toBe(0);
expect(analysis.summary.needsSyncCount).toBe(1);
expect(analysis.summary.conflictCount).toBe(0);
expect(analysis.needsSync[0].mcpId).toBe('octocode');
expect(analysis.needsSync[0].missingIn).toContain('claude-desktop');
});
it('should identify conflicts', () => {
const snapshots: ClientConfigSnapshot[] = [
{
client: 'cursor',
configPath: '/path/cursor.json',
config: {
mcpServers: {
octocode: { command: 'npx', args: ['octocode-mcp@latest'] },
},
},
exists: true,
mcpCount: 1,
},
{
client: 'claude-desktop',
configPath: '/path/claude.json',
config: {
mcpServers: {
octocode: { command: 'npx', args: ['octocode-mcp@1.0.0'] },
},
},
exists: true,
mcpCount: 1,
},
];
const analysis = analyzeSyncState(snapshots);
expect(analysis.summary.consistentMCPs).toBe(0);
expect(analysis.summary.needsSyncCount).toBe(0);
expect(analysis.summary.conflictCount).toBe(1);
expect(analysis.conflicts[0].mcpId).toBe('octocode');
expect(analysis.conflicts[0].hasConflict).toBe(true);
});
});
describe('prepareSyncPayload', () => {
it('should prepare payload for MCPs needing sync', () => {
const analysis: SyncAnalysis = {
clients: [],
allMCPs: new Set(['octocode']),
diffs: [],
fullyConsistent: [],
needsSync: [
{
mcpId: 'octocode',
presentIn: ['cursor'],
missingIn: ['claude-desktop'],
hasConflict: false,
variants: new Map([
['cursor', { command: 'npx', args: ['octocode-mcp@latest'] }],
]),
},
],
conflicts: [],
summary: {
totalClients: 2,
clientsWithConfig: 2,
totalUniqueMCPs: 1,
consistentMCPs: 0,
needsSyncCount: 1,
conflictCount: 0,
},
};
const payload = prepareSyncPayload(analysis, []);
expect(payload).toHaveLength(1);
expect(payload[0].mcpId).toBe('octocode');
expect(payload[0].server.command).toBe('npx');
});
it('should include resolved conflicts in payload', () => {
const analysis: SyncAnalysis = {
clients: [],
allMCPs: new Set(['octocode']),
diffs: [],
fullyConsistent: [],
needsSync: [],
conflicts: [
{
mcpId: 'octocode',
presentIn: ['cursor', 'claude-desktop'],
missingIn: [],
hasConflict: true,
variants: new Map([
['cursor', { command: 'npx', args: ['octocode-mcp@latest'] }],
[
'claude-desktop',
{ command: 'npx', args: ['octocode-mcp@1.0.0'] },
],
]),
},
],
summary: {
totalClients: 2,
clientsWithConfig: 2,
totalUniqueMCPs: 1,
consistentMCPs: 0,
needsSyncCount: 0,
conflictCount: 1,
},
};
const resolutions: ConflictResolution[] = [
{
mcpId: 'octocode',
chosenConfig: { command: 'npx', args: ['octocode-mcp@latest'] },
sourceClient: 'cursor',
},
];
const payload = prepareSyncPayload(analysis, resolutions);
expect(payload).toHaveLength(1);
expect(payload[0].mcpId).toBe('octocode');
expect(payload[0].server.args).toContain('octocode-mcp@latest');
});
});
describe('isSyncNeeded', () => {
it('should return true when sync is needed', () => {
const analysis: SyncAnalysis = {
clients: [],
allMCPs: new Set(),
diffs: [],
fullyConsistent: [],
needsSync: [{ mcpId: 'test' } as MCPDiff],
conflicts: [],
summary: {
totalClients: 2,
clientsWithConfig: 2,
totalUniqueMCPs: 1,
consistentMCPs: 0,
needsSyncCount: 1,
conflictCount: 0,
},
};
expect(isSyncNeeded(analysis)).toBe(true);
});
it('should return true when there are conflicts', () => {
const analysis: SyncAnalysis = {
clients: [],
allMCPs: new Set(),
diffs: [],
fullyConsistent: [],
needsSync: [],
conflicts: [{ mcpId: 'test' } as MCPDiff],
summary: {
totalClients: 2,
clientsWithConfig: 2,
totalUniqueMCPs: 1,
consistentMCPs: 0,
needsSyncCount: 0,
conflictCount: 1,
},
};
expect(isSyncNeeded(analysis)).toBe(true);
});
it('should return false when everything is synced', () => {
const analysis: SyncAnalysis = {
clients: [],
allMCPs: new Set(),
diffs: [],
fullyConsistent: [{ mcpId: 'test' } as MCPDiff],
needsSync: [],
conflicts: [],
summary: {
totalClients: 2,
clientsWithConfig: 2,
totalUniqueMCPs: 1,
consistentMCPs: 1,
needsSyncCount: 0,
conflictCount: 0,
},
};
expect(isSyncNeeded(analysis)).toBe(false);
});
});
describe('getClientDisplayName', () => {
it('should return the display name for known clients', () => {
expect(getClientDisplayName('cursor')).toBe('Cursor');
expect(getClientDisplayName('claude-desktop')).toBe('Claude Desktop');
});
it('should return the client id for unknown clients', () => {
expect(getClientDisplayName('unknown' as any)).toBe('unknown');
});
});
describe('readAllClientConfigs', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should read configs from all available clients', () => {
vi.mocked(detectAvailableClients).mockReturnValue([
'cursor',
'claude-desktop',
]);
vi.mocked(getMCPConfigPath).mockImplementation(
client => `/path/${client}.json`
);
vi.mocked(configFileExists).mockReturnValue(true);
vi.mocked(readMCPConfig).mockImplementation(_path => ({
mcpServers: {
octocode: { command: 'npx', args: ['test'] },
},
}));
const snapshots = readAllClientConfigs();
expect(snapshots).toHaveLength(2);
expect(snapshots[0].client).toBe('cursor');
expect(snapshots[1].client).toBe('claude-desktop');
});
it('should handle non-existent configs', () => {
vi.mocked(detectAvailableClients).mockReturnValue(['cursor']);
vi.mocked(getMCPConfigPath).mockReturnValue('/path/cursor.json');
vi.mocked(configFileExists).mockReturnValue(false);
const snapshots = readAllClientConfigs();
expect(snapshots).toHaveLength(1);
expect(snapshots[0].exists).toBe(false);
expect(snapshots[0].mcpCount).toBe(0);
});
});
describe('getCanonicalConfig', () => {
it('should return resolution config when provided', () => {
const diff: MCPDiff = {
mcpId: 'octocode',
presentIn: ['cursor', 'claude-desktop'],
missingIn: [],
hasConflict: true,
variants: new Map([
['cursor', { command: 'npx', args: ['octocode-mcp@latest'] }],
['claude-desktop', { command: 'npx', args: ['octocode-mcp@1.0.0'] }],
]),
};
const resolution: ConflictResolution = {
mcpId: 'octocode',
chosenConfig: { command: 'npx', args: ['octocode-mcp@2.0.0'] },
sourceClient: 'cursor',
};
const result = getCanonicalConfig(diff, resolution);
expect(result).toEqual({ command: 'npx', args: ['octocode-mcp@2.0.0'] });
});
it('should return first variant when no conflict and variants exist', () => {
const diff: MCPDiff = {
mcpId: 'octocode',
presentIn: ['cursor'],
missingIn: ['claude-desktop'],
hasConflict: false,
variants: new Map([
['cursor', { command: 'npx', args: ['octocode-mcp@latest'] }],
]),
};
const result = getCanonicalConfig(diff);
expect(result).toEqual({ command: 'npx', args: ['octocode-mcp@latest'] });
});
it('should return null when there is a conflict without resolution', () => {
const diff: MCPDiff = {
mcpId: 'octocode',
presentIn: ['cursor', 'claude-desktop'],
missingIn: [],
hasConflict: true,
variants: new Map([
['cursor', { command: 'npx', args: ['octocode-mcp@latest'] }],
['claude-desktop', { command: 'npx', args: ['octocode-mcp@1.0.0'] }],
]),
};
const result = getCanonicalConfig(diff);
expect(result).toBeNull();
});
it('should return null when no variants exist', () => {
const diff: MCPDiff = {
mcpId: 'octocode',
presentIn: [],
missingIn: ['cursor', 'claude-desktop'],
hasConflict: false,
variants: new Map(),
};
const result = getCanonicalConfig(diff);
expect(result).toBeNull();
});
});
describe('executeSyncToClients', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should sync MCPs to all existing clients successfully', () => {
vi.mocked(writeMCPConfig).mockReturnValue({
success: true,
backupPath: '/backup/path.json',
});
const snapshots: ClientConfigSnapshot[] = [
{
client: 'cursor',
configPath: '/path/cursor.json',
config: { mcpServers: {} },
exists: true,
mcpCount: 0,
},
{
client: 'claude-desktop',
configPath: '/path/claude.json',
config: { mcpServers: {} },
exists: true,
mcpCount: 0,
},
];
const mcpsToSync = [
{ mcpId: 'octocode', server: { command: 'npx', args: ['test'] } },
];
const result = executeSyncToClients(snapshots, mcpsToSync);
expect(result.success).toBe(true);
expect(result.mcpsSynced).toContain('octocode');
expect(result.errors).toHaveLength(0);
expect(result.clientResults.get('cursor')?.success).toBe(true);
expect(result.clientResults.get('claude-desktop')?.success).toBe(true);
});
it('should handle write failures and collect errors', () => {
vi.mocked(writeMCPConfig).mockImplementation(path => {
if (path.includes('cursor')) {
return { success: true };
}
return { success: false, error: 'Permission denied' };
});
const snapshots: ClientConfigSnapshot[] = [
{
client: 'cursor',
configPath: '/path/cursor.json',
config: { mcpServers: {} },
exists: true,
mcpCount: 0,
},
{
client: 'claude-desktop',
configPath: '/path/claude.json',
config: { mcpServers: {} },
exists: true,
mcpCount: 0,
},
];
const mcpsToSync = [
{ mcpId: 'octocode', server: { command: 'npx', args: ['test'] } },
];
const result = executeSyncToClients(snapshots, mcpsToSync);
expect(result.success).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toContain('Permission denied');
expect(result.clientResults.get('cursor')?.success).toBe(true);
expect(result.clientResults.get('claude-desktop')?.success).toBe(false);
});
it('should only sync to specified target clients', () => {
vi.mocked(writeMCPConfig).mockReturnValue({ success: true });
const snapshots: ClientConfigSnapshot[] = [
{
client: 'cursor',
configPath: '/path/cursor.json',
config: { mcpServers: {} },
exists: true,
mcpCount: 0,
},
{
client: 'claude-desktop',
configPath: '/path/claude.json',
config: { mcpServers: {} },
exists: true,
mcpCount: 0,
},
];
const mcpsToSync = [
{ mcpId: 'octocode', server: { command: 'npx', args: ['test'] } },
];
const result = executeSyncToClients(snapshots, mcpsToSync, ['cursor']);
expect(result.success).toBe(true);
expect(result.clientResults.size).toBe(1);
expect(result.clientResults.has('cursor')).toBe(true);
expect(result.clientResults.has('claude-desktop')).toBe(false);
});
it('should skip non-existing configs when no target clients specified', () => {
vi.mocked(writeMCPConfig).mockReturnValue({ success: true });
const snapshots: ClientConfigSnapshot[] = [
{
client: 'cursor',
configPath: '/path/cursor.json',
config: { mcpServers: {} },
exists: true,
mcpCount: 0,
},
{
client: 'claude-desktop',
configPath: '/path/claude.json',
config: null,
exists: false,
mcpCount: 0,
},
];
const mcpsToSync = [
{ mcpId: 'octocode', server: { command: 'npx', args: ['test'] } },
];
const result = executeSyncToClients(snapshots, mcpsToSync);
expect(result.success).toBe(true);
expect(result.clientResults.size).toBe(1);
expect(result.clientResults.has('cursor')).toBe(true);
});
it('should sync multiple MCPs and deduplicate mcpsSynced', () => {
vi.mocked(writeMCPConfig).mockReturnValue({ success: true });
const snapshots: ClientConfigSnapshot[] = [
{
client: 'cursor',
configPath: '/path/cursor.json',
config: { mcpServers: {} },
exists: true,
mcpCount: 0,
},
];
const mcpsToSync = [
{ mcpId: 'octocode', server: { command: 'npx', args: ['octocode'] } },
{ mcpId: 'github', server: { command: 'npx', args: ['github'] } },
];
const result = executeSyncToClients(snapshots, mcpsToSync);
expect(result.success).toBe(true);
expect(result.mcpsSynced).toHaveLength(2);
expect(result.mcpsSynced).toContain('octocode');
expect(result.mcpsSynced).toContain('github');
});
it('should handle unknown write error gracefully', () => {
vi.mocked(writeMCPConfig).mockReturnValue({ success: false });
const snapshots: ClientConfigSnapshot[] = [
{
client: 'cursor',
configPath: '/path/cursor.json',
config: { mcpServers: {} },
exists: true,
mcpCount: 0,
},
];
const mcpsToSync = [
{ mcpId: 'octocode', server: { command: 'npx', args: ['test'] } },
];
const result = executeSyncToClients(snapshots, mcpsToSync);
expect(result.success).toBe(false);
expect(result.errors[0]).toContain('Unknown write error');
});
});