Skip to main content
Glama

XC-MCP: XCode CLI wrapper

by conorluddy
response-cache.test.ts21.5 kB
import { jest } from '@jest/globals'; import { randomUUID } from 'crypto'; import { responseCache, extractBuildSummary, extractTestSummary, extractSimulatorSummary, createProgressiveSimulatorResponse, } from '../../../src/utils/response-cache.js'; import { persistenceManager } from '../../../src/utils/persistence.js'; // Mock crypto jest.mock('crypto', () => ({ randomUUID: jest.fn(), })); // Mock persistence manager jest.mock('../../../src/utils/persistence.js', () => ({ persistenceManager: { isEnabled: jest.fn(), loadState: jest.fn(), saveState: jest.fn(), }, })); const mockRandomUUID = randomUUID as jest.MockedFunction<typeof randomUUID>; const mockPersistenceManager = persistenceManager as jest.Mocked<typeof persistenceManager>; describe('ResponseCache', () => { beforeEach(() => { jest.clearAllMocks(); responseCache.clear(); // Mock UUID generation let counter = 0; mockRandomUUID.mockImplementation(() => { counter++; return `mock-uuid-${counter}` as `${string}-${string}-${string}-${string}-${string}`; }); // Default persistence mocks mockPersistenceManager.isEnabled.mockReturnValue(false); mockPersistenceManager.loadState.mockResolvedValue(null); mockPersistenceManager.saveState.mockResolvedValue(undefined); }); describe('store and get', () => { it('should store and retrieve cached responses', () => { const data = { tool: 'xcodebuild-build', fullOutput: 'Build succeeded', stderr: '', exitCode: 0, command: 'xcodebuild build', metadata: { scheme: 'MyApp' }, }; const id = responseCache.store(data); expect(id).toBe('mock-uuid-1'); const cached = responseCache.get(id); expect(cached).toMatchObject({ ...data, id: 'mock-uuid-1', }); expect(cached?.timestamp).toBeInstanceOf(Date); }); it('should return undefined for non-existent IDs', () => { const cached = responseCache.get('non-existent-id'); expect(cached).toBeUndefined(); }); it('should return undefined for expired entries', () => { const data = { tool: 'simctl-list', fullOutput: 'Device list', stderr: '', exitCode: 0, command: 'simctl list', metadata: {}, }; const id = responseCache.store(data); // Mock the cache to have an old timestamp // eslint-disable-next-line @typescript-eslint/no-explicit-any const cache = (responseCache as any).cache; const entry = cache.get(id); entry.timestamp = new Date(Date.now() - 31 * 60 * 1000); // 31 minutes ago const cached = responseCache.get(id); expect(cached).toBeUndefined(); expect(cache.has(id)).toBe(false); // Should be deleted }); }); describe('getRecentByTool', () => { it('should return recent entries for a specific tool', () => { const tool = 'xcodebuild-build'; // Store multiple entries with slight delays to ensure timestamp ordering const entries = []; for (let i = 1; i <= 3; i++) { const id = responseCache.store({ tool, fullOutput: `Build ${i}`, stderr: '', exitCode: 0, command: `xcodebuild build ${i}`, metadata: {}, }); entries.push(id); // Manually adjust timestamp to ensure proper ordering for test // eslint-disable-next-line @typescript-eslint/no-explicit-any const cache = (responseCache as any).cache; const entry = cache.get(id); entry.timestamp = new Date(Date.now() + i * 1000); // Each entry 1 second later } // Store entry for different tool responseCache.store({ tool: 'simctl-list', fullOutput: 'Devices', stderr: '', exitCode: 0, command: 'simctl list', metadata: {}, }); const recent = responseCache.getRecentByTool(tool); expect(recent).toHaveLength(3); expect(recent[0].fullOutput).toBe('Build 3'); // Most recent first expect(recent[1].fullOutput).toBe('Build 2'); expect(recent[2].fullOutput).toBe('Build 1'); expect(recent.every(entry => entry.tool === tool)).toBe(true); }); it('should respect the limit parameter', () => { const tool = 'simctl-boot'; for (let i = 1; i <= 10; i++) { const id = responseCache.store({ tool, fullOutput: `Boot ${i}`, stderr: '', exitCode: 0, command: `simctl boot device-${i}`, metadata: {}, }); // Manually adjust timestamp to ensure proper ordering for test // eslint-disable-next-line @typescript-eslint/no-explicit-any const cache = (responseCache as any).cache; const entry = cache.get(id); entry.timestamp = new Date(Date.now() + i * 1000); // Each entry 1 second later } const recent = responseCache.getRecentByTool(tool, 3); expect(recent).toHaveLength(3); expect(recent[0].fullOutput).toBe('Boot 10'); }); it('should return empty array for unknown tool', () => { const recent = responseCache.getRecentByTool('unknown-tool'); expect(recent).toHaveLength(0); }); }); describe('delete', () => { it('should delete existing entries', () => { const id = responseCache.store({ tool: 'test', fullOutput: 'output', stderr: '', exitCode: 0, command: 'test', metadata: {}, }); expect(responseCache.get(id)).toBeDefined(); expect(responseCache.delete(id)).toBe(true); expect(responseCache.get(id)).toBeUndefined(); }); it('should return false for non-existent entries', () => { expect(responseCache.delete('non-existent')).toBe(false); }); }); describe('clear', () => { it('should remove all entries', () => { responseCache.store({ tool: 'test1', fullOutput: 'output1', stderr: '', exitCode: 0, command: 'test1', metadata: {}, }); responseCache.store({ tool: 'test2', fullOutput: 'output2', stderr: '', exitCode: 0, command: 'test2', metadata: {}, }); expect(responseCache.getStats().totalEntries).toBe(2); responseCache.clear(); expect(responseCache.getStats().totalEntries).toBe(0); }); }); describe('getStats', () => { it('should return correct statistics', () => { responseCache.store({ tool: 'xcodebuild-build', fullOutput: 'build1', stderr: '', exitCode: 0, command: 'build1', metadata: {}, }); responseCache.store({ tool: 'xcodebuild-build', fullOutput: 'build2', stderr: '', exitCode: 0, command: 'build2', metadata: {}, }); responseCache.store({ tool: 'simctl-list', fullOutput: 'list1', stderr: '', exitCode: 0, command: 'list1', metadata: {}, }); const stats = responseCache.getStats(); expect(stats.totalEntries).toBe(3); expect(stats.byTool).toEqual({ 'xcodebuild-build': 2, 'simctl-list': 1, }); }); it('should return empty stats for empty cache', () => { const stats = responseCache.getStats(); expect(stats.totalEntries).toBe(0); expect(stats.byTool).toEqual({}); }); }); describe('cleanup', () => { it('should remove entries exceeding maxEntries limit', () => { // Store more than maxEntries (100) - we'll store 105 // eslint-disable-next-line @typescript-eslint/no-explicit-any const maxEntries = (responseCache as any).maxEntries; expect(maxEntries).toBe(100); // Add delay between stores to ensure different timestamps for (let i = 1; i <= 105; i++) { responseCache.store({ tool: `tool-${i}`, fullOutput: `output-${i}`, stderr: '', exitCode: 0, command: `command-${i}`, metadata: {}, }); } const stats = responseCache.getStats(); expect(stats.totalEntries).toBe(100); // Should be limited to maxEntries }); it('should remove oldest entries when over limit', () => { // Override maxEntries for testing // eslint-disable-next-line @typescript-eslint/no-explicit-any const originalMaxEntries = (responseCache as any).maxEntries; // eslint-disable-next-line @typescript-eslint/no-explicit-any (responseCache as any).maxEntries = 3; try { // Store 5 entries const ids = []; for (let i = 1; i <= 5; i++) { const id = responseCache.store({ tool: `tool-${i}`, fullOutput: `output-${i}`, stderr: '', exitCode: 0, command: `command-${i}`, metadata: {}, }); ids.push(id); } // Should only have the 3 most recent expect(responseCache.getStats().totalEntries).toBe(3); expect(responseCache.get(ids[0])).toBeUndefined(); // First two should be removed expect(responseCache.get(ids[1])).toBeUndefined(); expect(responseCache.get(ids[2])).toBeDefined(); // Last three should remain expect(responseCache.get(ids[3])).toBeDefined(); expect(responseCache.get(ids[4])).toBeDefined(); } finally { // eslint-disable-next-line @typescript-eslint/no-explicit-any (responseCache as any).maxEntries = originalMaxEntries; } }); }); }); describe('extractBuildSummary', () => { it('should extract successful build summary', () => { const output = ` Building target MyApp with configuration Debug ** BUILD SUCCEEDED ** Total time: 45.2 seconds `; const summary = extractBuildSummary(output, '', 0); expect(summary).toEqual({ success: true, exitCode: 0, errorCount: 0, warningCount: 0, duration: 45.2, target: 'MyApp', hasErrors: false, hasWarnings: false, firstError: undefined, buildSizeBytes: output.length, }); }); it('should extract failed build summary', () => { const output = 'Building...'; const stderr = ` error: Build failed ** BUILD FAILED ** `; const summary = extractBuildSummary(output, stderr, 1); expect(summary).toEqual({ success: false, exitCode: 1, errorCount: 2, // "error:" and "** BUILD FAILED **" warningCount: 0, duration: undefined, target: undefined, hasErrors: true, hasWarnings: false, firstError: 'error: Build failed', buildSizeBytes: output.length + stderr.length, }); }); it('should extract warnings', () => { const output = ` warning: Unused variable 'foo' warning: Deprecated API usage ** BUILD SUCCEEDED ** `; const summary = extractBuildSummary(output, '', 0); expect(summary.warningCount).toBe(2); expect(summary.hasWarnings).toBe(true); }); it('should handle output without timing info', () => { const output = '** BUILD SUCCEEDED **'; const summary = extractBuildSummary(output, '', 0); expect(summary.duration).toBeUndefined(); }); it('should handle mixed success indicators and errors', () => { const output = ` ** BUILD SUCCEEDED ** error: Some error that happened during success `; const summary = extractBuildSummary(output, '', 0); expect(summary.success).toBe(true); // Exit code 0 and has success indicator expect(summary.hasErrors).toBe(true); // But still has errors expect(summary.errorCount).toBe(1); }); }); describe('extractTestSummary', () => { it('should extract successful test summary', () => { const output = ` Test Suite MyAppTests.xctest started Test Suite MyAppTests.xctest passed 15 tests executed `; const summary = extractTestSummary(output, '', 0); expect(summary).toEqual({ success: true, exitCode: 0, testsRun: 15, passed: true, resultSummary: expect.any(Array), }); }); it('should extract failed test summary', () => { const output = ` Test Suite MyAppTests.xctest started Test Suite MyAppTests.xctest failed 8 tests executed `; const summary = extractTestSummary(output, '', 1); expect(summary).toEqual({ success: false, exitCode: 1, testsRun: 8, passed: false, resultSummary: expect.any(Array), }); }); it('should handle multiple test counts', () => { const output = ` 5 tests passed 3 tests failed 8 tests total `; const summary = extractTestSummary(output, '', 0); expect(summary.testsRun).toBe(16); // 5 + 3 + 8 }); it('should handle no test count info', () => { const output = 'Test Suite passed'; const summary = extractTestSummary(output, '', 0); expect(summary.testsRun).toBe(0); }); }); describe('extractSimulatorSummary', () => { const mockCachedList = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ { name: 'iPhone 15', udid: '123-456-789', state: 'Booted', isAvailable: true, lastUsed: new Date('2023-12-01T10:00:00Z'), }, { name: 'iPhone 14', udid: '987-654-321', state: 'Shutdown', isAvailable: true, lastUsed: new Date('2023-12-01T09:00:00Z'), }, ], 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ { name: 'iPad Pro', udid: '111-222-333', state: 'Shutdown', isAvailable: true, }, ], }, lastUpdated: new Date('2023-12-01T12:00:00Z'), }; it('should extract simulator summary correctly', () => { const summary = extractSimulatorSummary(mockCachedList); expect(summary).toMatchObject({ totalDevices: 3, availableDevices: 3, bootedDevices: 1, deviceTypes: ['iPhone', 'iPad'], commonRuntimes: ['iOS 18.0', 'iOS 17.0'], lastUpdated: mockCachedList.lastUpdated, cacheAge: expect.any(String), }); expect(summary.bootedList).toHaveLength(1); expect(summary.bootedList[0]).toMatchObject({ name: 'iPhone 15', udid: '123-456-789', state: 'Booted', }); expect(summary.recentlyUsed).toHaveLength(2); expect(summary.recentlyUsed[0].name).toBe('iPhone 15'); // Most recent first }); it('should handle empty device list', () => { const emptyList = { devices: {}, lastUpdated: new Date(), }; const summary = extractSimulatorSummary(emptyList); expect(summary.totalDevices).toBe(0); expect(summary.availableDevices).toBe(0); expect(summary.bootedDevices).toBe(0); expect(summary.deviceTypes).toHaveLength(0); expect(summary.commonRuntimes).toHaveLength(0); }); it('should filter unavailable devices', () => { const listWithUnavailable = { devices: { 'iOS-18-0': [ { name: 'iPhone 15', udid: '123', state: 'Booted', isAvailable: true, }, { name: 'iPhone 14', udid: '456', state: 'Shutdown', isAvailable: false, // Unavailable }, ], }, lastUpdated: new Date(), }; const summary = extractSimulatorSummary(listWithUnavailable); expect(summary.totalDevices).toBe(2); expect(summary.availableDevices).toBe(1); expect(summary.bootedDevices).toBe(1); }); }); describe('createProgressiveSimulatorResponse', () => { const mockSummary = { totalDevices: 10, availableDevices: 8, bootedDevices: 2, deviceTypes: ['iPhone', 'iPad'], commonRuntimes: ['iOS 18.0', 'iOS 17.0'], lastUpdated: new Date('2023-12-01T12:00:00Z'), cacheAge: '5 minutes ago', bootedList: [{ name: 'iPhone 15', udid: '123', state: 'Booted', runtime: 'iOS 18.0' }], recentlyUsed: [{ name: 'iPhone 15', udid: '123', lastUsed: '5 minutes ago' }], }; it('should create progressive response structure', () => { const response = createProgressiveSimulatorResponse(mockSummary, 'cache-123', { deviceType: 'iPhone', runtime: 'iOS 18.0', }); expect(response).toMatchObject({ cacheId: 'cache-123', summary: { totalDevices: 10, availableDevices: 8, bootedDevices: 2, deviceTypes: ['iPhone', 'iPad'], commonRuntimes: ['iOS 18.0', 'iOS 17.0'], lastUpdated: '2023-12-01T12:00:00.000Z', cacheAge: '5 minutes ago', }, quickAccess: { bootedDevices: mockSummary.bootedList, recentlyUsed: mockSummary.recentlyUsed, recommendedForBuild: mockSummary.bootedList, }, nextSteps: expect.arrayContaining([ expect.stringContaining('Found 8 available simulators'), expect.stringContaining('simctl-get-details'), expect.stringContaining('deviceType=iPhone'), ]), availableDetails: ['full-list', 'devices-only', 'runtimes-only', 'available-only'], smartFilters: expect.objectContaining({ commonDeviceTypes: ['iPhone', 'iPad'], commonRuntimes: ['iOS 18.0', 'iOS 17.0'], suggestedFilters: expect.stringContaining('deviceType=iPhone'), }), }); }); it('should recommend recent devices when none are booted', () => { const summaryWithoutBooted = { ...mockSummary, bootedDevices: 0, bootedList: [], }; const response = createProgressiveSimulatorResponse(summaryWithoutBooted, 'cache-123', {}); expect(response.quickAccess.recommendedForBuild).toHaveLength(1); expect(response.quickAccess.recommendedForBuild[0]).toEqual(mockSummary.recentlyUsed[0]); }); it('should handle missing filters gracefully', () => { const response = createProgressiveSimulatorResponse(mockSummary, 'cache-123', {}); expect( response.nextSteps.some( step => step.includes('deviceType=iPhone') && step.includes('runtime=iOS 18.5') ) ).toBe(true); }); }); describe('edge cases and cleanup', () => { it('should cleanup expired entries correctly', () => { const now = Date.now(); const expiredData = { tool: 'expired-tool', fullOutput: 'expired output', stderr: '', exitCode: 0, command: 'expired command', metadata: {}, }; // Store an entry const id = responseCache.store(expiredData); // Mock the timestamp to be expired const cached = responseCache.get(id); if (cached) { // Directly manipulate the cache to simulate expired entry cached.timestamp = new Date(now - 35 * 60 * 1000); // 35 minutes ago (past 30min maxAge) // eslint-disable-next-line @typescript-eslint/no-explicit-any (responseCache as any).cache.set(id, cached); } // Force cleanup by calling store again (which triggers cleanup) responseCache.store({ tool: 'new-tool', fullOutput: 'new output', stderr: '', exitCode: 0, command: 'new command', metadata: {}, }); // The expired entry should be removed expect(responseCache.get(id)).toBeUndefined(); }); }); describe('ExtractSimulatorSummary edge cases', () => { it('should handle various device types in extractSimulatorSummary', () => { const complexList = { devices: { 'iOS-18-0': [ { name: 'iPhone 15 Pro', udid: '1', state: 'Booted', isAvailable: true }, { name: 'iPad Air', udid: '2', state: 'Shutdown', isAvailable: true }, ], 'watchOS-10-0': [ { name: 'Apple Watch Series 9', udid: '3', state: 'Shutdown', isAvailable: true }, ], 'tvOS-17-0': [{ name: 'Apple TV 4K', udid: '4', state: 'Shutdown', isAvailable: true }], 'visionOS-1-0': [ { name: 'Apple Vision Pro', udid: '5', state: 'Shutdown', isAvailable: true }, ], }, lastUpdated: new Date(), }; const summary = extractSimulatorSummary(complexList); expect(summary.deviceTypes).toEqual([ 'iPhone', 'iPad', 'Apple Watch', 'Apple TV', 'Apple Vision Pro', ]); }); it('should handle unrecognized device names', () => { const listWithUnrecognized = { devices: { 'iOS-18-0': [ { name: 'Unknown Device XYZ', udid: '1', state: 'Shutdown', isAvailable: true }, ], }, lastUpdated: new Date(), }; const summary = extractSimulatorSummary(listWithUnrecognized); expect(summary.deviceTypes).toEqual(['Other']); }); it('should handle various runtime formats', () => { const listWithVariousRuntimes = { devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ { name: 'iPhone 15', udid: '1', state: 'Shutdown', isAvailable: true }, ], 'com.apple.CoreSimulator.SimRuntime.iOS-17-5': [ { name: 'iPhone 14', udid: '2', state: 'Shutdown', isAvailable: true }, ], 'watchOS-10-0': [{ name: 'Apple Watch', udid: '3', state: 'Shutdown', isAvailable: true }], }, lastUpdated: new Date(), }; const summary = extractSimulatorSummary(listWithVariousRuntimes); expect(summary.commonRuntimes).toContain('iOS 18.0'); expect(summary.commonRuntimes).toContain('iOS 17.5'); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/conorluddy/xc-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server