diff --git a/docs/CLI.md b/docs/CLI.md index 576ff47a..737627ee 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -77,18 +77,6 @@ xcodebuildmcp simulator launch-app --simulator-id --bundle-id io.sentry.M xcodebuildmcp simulator build-and-run --scheme MyApp --project-path ./MyApp.xcodeproj ``` -### Log Capture Workflow - -```bash -# Start log capture -xcodebuildmcp logging start-simulator-log-capture --simulator-id --bundle-id io.sentry.MyApp - -> Log capture started successfully. Session ID: 51e2142a-1a99-442a-af01-0586540043df. - -# Stop and retrieve logs -xcodebuildmcp logging stop-simulator-log-capture --session-id -``` - ### Testing ```bash @@ -219,7 +207,6 @@ Most tools run directly without the daemon: ### Stateful Tools (require daemon) Some tools maintain state and route through the daemon: -- Log capture: `start-simulator-log-capture`, `stop-simulator-log-capture` - Video recording: `record-video` - Debugging: `attach`, `continue`, etc. - Background processes: `run`, `stop` diff --git a/manifests/tools/build_run_device.yaml b/manifests/tools/build_run_device.yaml index 67a643f4..4c9ecd4f 100644 --- a/manifests/tools/build_run_device.yaml +++ b/manifests/tools/build_run_device.yaml @@ -12,9 +12,6 @@ annotations: destructiveHint: false openWorldHint: false nextSteps: - - label: Capture device logs - toolId: start_device_log_cap - priority: 1 - label: Stop app on device toolId: stop_app_device - priority: 2 + priority: 1 diff --git a/manifests/tools/build_run_sim.yaml b/manifests/tools/build_run_sim.yaml index bb797480..f53938e1 100644 --- a/manifests/tools/build_run_sim.yaml +++ b/manifests/tools/build_run_sim.yaml @@ -12,15 +12,6 @@ annotations: destructiveHint: false openWorldHint: false nextSteps: - - label: Capture structured logs (app continues running) - toolId: start_sim_log_cap - priority: 1 - label: Stop app in simulator toolId: stop_app_sim - priority: 2 - - label: Capture console + structured logs (app restarts) - toolId: start_sim_log_cap - priority: 3 - - label: Launch app with logs in one step - toolId: launch_app_logs_sim - priority: 4 + priority: 1 diff --git a/manifests/tools/launch_app_logs_sim.yaml b/manifests/tools/launch_app_logs_sim.yaml deleted file mode 100644 index fa349c84..00000000 --- a/manifests/tools/launch_app_logs_sim.yaml +++ /dev/null @@ -1,17 +0,0 @@ -id: launch_app_logs_sim -module: mcp/tools/simulator/launch_app_logs_sim -names: - mcp: launch_app_logs_sim - cli: launch-app-with-logs -description: Launch sim app with logs. -routing: - stateful: true -annotations: - title: Launch App Logs Simulator - readOnlyHint: false - destructiveHint: false - openWorldHint: false -nextSteps: - - label: Stop capture and retrieve logs - toolId: stop_sim_log_cap - priority: 1 diff --git a/manifests/tools/launch_app_sim.yaml b/manifests/tools/launch_app_sim.yaml index dfa2e104..c30e1c6c 100644 --- a/manifests/tools/launch_app_sim.yaml +++ b/manifests/tools/launch_app_sim.yaml @@ -13,16 +13,3 @@ nextSteps: - label: Open Simulator app to see it toolId: open_sim priority: 1 - - label: Capture structured logs (app continues running) - toolId: start_sim_log_cap - params: - simulatorId: SIMULATOR_UUID - bundleId: BUNDLE_ID - priority: 2 - - label: Capture console + structured logs (app restarts) - toolId: start_sim_log_cap - params: - simulatorId: SIMULATOR_UUID - bundleId: BUNDLE_ID - captureConsole: true - priority: 3 diff --git a/manifests/tools/open_sim.yaml b/manifests/tools/open_sim.yaml index fad36b58..d707c2e2 100644 --- a/manifests/tools/open_sim.yaml +++ b/manifests/tools/open_sim.yaml @@ -15,22 +15,3 @@ nextSteps: params: simulatorId: UUID_FROM_LIST_SIMS priority: 1 - - label: Capture structured logs (app continues running) - toolId: start_sim_log_cap - params: - simulatorId: UUID - bundleId: YOUR_APP_BUNDLE_ID - priority: 2 - - label: Capture console + structured logs (app restarts) - toolId: start_sim_log_cap - params: - simulatorId: UUID - bundleId: YOUR_APP_BUNDLE_ID - captureConsole: true - priority: 3 - - label: Launch app with logs in one step - toolId: launch_app_logs_sim - params: - simulatorId: UUID - bundleId: YOUR_APP_BUNDLE_ID - priority: 4 diff --git a/manifests/tools/start_device_log_cap.yaml b/manifests/tools/start_device_log_cap.yaml deleted file mode 100644 index d0e36b00..00000000 --- a/manifests/tools/start_device_log_cap.yaml +++ /dev/null @@ -1,17 +0,0 @@ -id: start_device_log_cap -module: mcp/tools/logging/start_device_log_cap -names: - mcp: start_device_log_cap - cli: start-device-log-capture -description: Start device log capture. -routing: - stateful: true -annotations: - title: Start Device Log Capture - readOnlyHint: false - destructiveHint: false - openWorldHint: false -nextSteps: - - label: Stop capture and retrieve logs - toolId: stop_device_log_cap - priority: 1 diff --git a/manifests/tools/start_sim_log_cap.yaml b/manifests/tools/start_sim_log_cap.yaml deleted file mode 100644 index 5d98d889..00000000 --- a/manifests/tools/start_sim_log_cap.yaml +++ /dev/null @@ -1,17 +0,0 @@ -id: start_sim_log_cap -module: mcp/tools/logging/start_sim_log_cap -names: - mcp: start_sim_log_cap - cli: start-simulator-log-capture -description: Start sim log capture. -routing: - stateful: true -annotations: - title: Start Simulator Log Capture - readOnlyHint: false - destructiveHint: false - openWorldHint: false -nextSteps: - - label: Stop capture and retrieve logs - toolId: stop_sim_log_cap - priority: 1 diff --git a/manifests/tools/stop_device_log_cap.yaml b/manifests/tools/stop_device_log_cap.yaml deleted file mode 100644 index 9cc9fe1f..00000000 --- a/manifests/tools/stop_device_log_cap.yaml +++ /dev/null @@ -1,13 +0,0 @@ -id: stop_device_log_cap -module: mcp/tools/logging/stop_device_log_cap -names: - mcp: stop_device_log_cap - cli: stop-device-log-capture -description: Stop device app and return logs. -routing: - stateful: true -annotations: - title: Stop Device and Return Logs - readOnlyHint: false - destructiveHint: false - openWorldHint: false diff --git a/manifests/tools/stop_sim_log_cap.yaml b/manifests/tools/stop_sim_log_cap.yaml deleted file mode 100644 index 2f0382bb..00000000 --- a/manifests/tools/stop_sim_log_cap.yaml +++ /dev/null @@ -1,13 +0,0 @@ -id: stop_sim_log_cap -module: mcp/tools/logging/stop_sim_log_cap -names: - mcp: stop_sim_log_cap - cli: stop-simulator-log-capture -description: Stop sim app and return logs. -routing: - stateful: true -annotations: - title: Stop Simulator and Return Logs - readOnlyHint: false - destructiveHint: false - openWorldHint: false diff --git a/manifests/workflows/device.yaml b/manifests/workflows/device.yaml index 60c8c329..abeef9e7 100644 --- a/manifests/workflows/device.yaml +++ b/manifests/workflows/device.yaml @@ -15,7 +15,5 @@ tools: - list_schemes - show_build_settings - get_app_bundle_id - - start_device_log_cap - - stop_device_log_cap - get_coverage_report - get_file_coverage diff --git a/manifests/workflows/logging.yaml b/manifests/workflows/logging.yaml deleted file mode 100644 index 4d2ad6d3..00000000 --- a/manifests/workflows/logging.yaml +++ /dev/null @@ -1,8 +0,0 @@ -id: logging -title: Log Capture -description: Capture and retrieve logs from simulator and device apps. -tools: - - start_sim_log_cap - - stop_sim_log_cap - - start_device_log_cap - - stop_device_log_cap diff --git a/manifests/workflows/simulator.yaml b/manifests/workflows/simulator.yaml index b6fcbbc1..cd70c6a2 100644 --- a/manifests/workflows/simulator.yaml +++ b/manifests/workflows/simulator.yaml @@ -14,7 +14,6 @@ tools: - get_sim_app_path - install_app_sim - launch_app_sim - - launch_app_logs_sim - stop_app_sim - record_sim_video - clean @@ -24,7 +23,5 @@ tools: - get_app_bundle_id - screenshot - snapshot_ui - - stop_sim_log_cap - - start_sim_log_cap - get_coverage_report - get_file_coverage diff --git a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts deleted file mode 100644 index ccdbfb2c..00000000 --- a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts +++ /dev/null @@ -1,531 +0,0 @@ -/** - * Tests for start_device_log_cap plugin - * Following CLAUDE.md testing standards with pure dependency injection - */ -import { describe, it, expect, beforeEach } from 'vitest'; -import { EventEmitter } from 'events'; -import { Readable } from 'stream'; -import type { ChildProcess } from 'child_process'; -import * as z from 'zod'; -import { - createMockExecutor, - createMockFileSystemExecutor, -} from '../../../../test-utils/mock-executors.ts'; -import { schema, handler, start_device_log_capLogic } from '../start_device_log_cap.ts'; -import { activeDeviceLogSessions } from '../../../../utils/log-capture/device-log-sessions.ts'; -import { sessionStore } from '../../../../utils/session-store.ts'; -import { - __resetConfigStoreForTests, - initConfigStore, - type RuntimeConfigOverrides, -} from '../../../../utils/config-store.ts'; - -const cwd = '/repo'; - -async function initConfigStoreForTest(overrides?: RuntimeConfigOverrides): Promise { - __resetConfigStoreForTests(); - await initConfigStore({ cwd, fs: createMockFileSystemExecutor(), overrides }); -} - -type Mutable = { - -readonly [K in keyof T]: T[K]; -}; - -type MockChildProcess = Mutable & { - stdout: Readable; - stderr: Readable; -}; - -describe('start_device_log_cap plugin', () => { - // Mock state tracking - let commandCalls: Array<{ - command: string[]; - logPrefix?: string; - useShell?: boolean; - env?: Record; - }> = []; - let mkdirCalls: string[] = []; - let writeFileCalls: Array<{ path: string; content: string }> = []; - - beforeEach(async () => { - sessionStore.clear(); - activeDeviceLogSessions.clear(); - await initConfigStoreForTest({ launchJsonWaitMs: 25 }); - }); - - describe('Plugin Structure', () => { - it('should export schema and handler', () => { - expect(schema).toBeDefined(); - expect(handler).toBeDefined(); - }); - - it('should have correct schema structure', () => { - // Schema should be a plain object for MCP protocol compliance - expect(typeof schema).toBe('object'); - expect(Object.keys(schema)).toEqual([]); - - // Validate that schema fields are Zod types that can be used for validation - const schemaObj = z.strictObject(schema); - expect(schemaObj.safeParse({ bundleId: 'com.test.app' }).success).toBe(false); - expect(schemaObj.safeParse({}).success).toBe(true); - }); - - it('should have handler as a function', () => { - expect(typeof handler).toBe('function'); - }); - }); - - describe('Handler Requirements', () => { - it('should require deviceId and bundleId when not provided', async () => { - const result = await handler({}); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Missing required session defaults'); - expect(result.content[0].text).toContain('Provide deviceId and bundleId'); - }); - }); - - describe('Handler Functionality', () => { - it('should start log capture successfully', async () => { - // Mock successful command execution - const mockExecutor = createMockExecutor({ - success: true, - output: 'App launched successfully', - }); - - const mockFileSystemExecutor = createMockFileSystemExecutor({ - mkdir: async (path: string) => { - mkdirCalls.push(path); - }, - writeFile: async (path: string, content: string) => { - writeFileCalls.push({ path, content }); - }, - }); - - const result = await start_device_log_capLogic( - { - deviceId: '00008110-001A2C3D4E5F', - bundleId: 'io.sentry.MyApp', - }, - mockExecutor, - mockFileSystemExecutor, - ); - - expect(result.content[0].text).toMatch(/✅ Device log capture started successfully/); - expect(result.content[0].text).toMatch(/Session ID: [a-f0-9-]{36}/); - expect(result.isError ?? false).toBe(false); - }); - - it('should include next steps in success response', async () => { - // Mock successful command execution - const mockExecutor = createMockExecutor({ - success: true, - output: 'App launched successfully', - }); - - const mockFileSystemExecutor = createMockFileSystemExecutor({ - mkdir: async (path: string) => { - mkdirCalls.push(path); - }, - writeFile: async (path: string, content: string) => { - writeFileCalls.push({ path, content }); - }, - }); - - const result = await start_device_log_capLogic( - { - deviceId: '00008110-001A2C3D4E5F', - bundleId: 'io.sentry.MyApp', - }, - mockExecutor, - mockFileSystemExecutor, - ); - - expect(result.content[0].text).toContain( - 'Do not call launch_app_device during this capture session', - ); - expect(result.content[0].text).toContain('Interact with your app'); - const responseText = String(result.content[0].text); - const sessionIdMatch = responseText.match(/Session ID: ([a-f0-9-]{36})/); - expect(sessionIdMatch).not.toBeNull(); - const sessionId = sessionIdMatch?.[1]; - expect(typeof sessionId).toBe('string'); - - expect(result.nextStepParams?.stop_device_log_cap).toBeDefined(); - expect(result.nextStepParams?.stop_device_log_cap).toMatchObject({ - logSessionId: sessionId, - }); - }); - - it('should surface early launch failures when process exits immediately', async () => { - const failingProcess = new EventEmitter() as MockChildProcess; - - const stubOutput = new Readable({ - read() {}, - }); - const stubError = new Readable({ - read() {}, - }); - - failingProcess.stdout = stubOutput; - failingProcess.stderr = stubError; - failingProcess.exitCode = null; - failingProcess.killed = false; - failingProcess.kill = () => { - failingProcess.killed = true; - failingProcess.exitCode = 0; - failingProcess.emit('close', 0, null); - return true; - }; - - const mockExecutor = createMockExecutor({ - success: true, - output: '', - process: failingProcess, - }); - - let createdLogPath = ''; - const mockFileSystemExecutor = createMockFileSystemExecutor({ - mkdir: async () => {}, - writeFile: async (path: string, content: string) => { - createdLogPath = path; - writeFileCalls.push({ path, content }); - }, - }); - - const resultPromise = start_device_log_capLogic( - { - deviceId: '00008110-001A2C3D4E5F', - bundleId: 'com.invalid.App', - }, - mockExecutor, - mockFileSystemExecutor, - ); - - setTimeout(() => { - stubError.emit( - 'data', - 'ERROR: The application failed to launch. (com.apple.dt.CoreDeviceError error 10002)\nNSLocalizedRecoverySuggestion = Provide a valid bundle identifier.\n', - ); - failingProcess.exitCode = 70; - failingProcess.emit('close', 70, null); - }, 10); - - const result = await resultPromise; - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Provide a valid bundle identifier'); - expect(activeDeviceLogSessions.size).toBe(0); - expect(createdLogPath).not.toBe(''); - }); - - it('should surface JSON-reported failures when launch cannot start', async () => { - const jsonFailure = { - error: { - domain: 'com.apple.dt.CoreDeviceError', - code: 10002, - localizedDescription: 'The application failed to launch.', - userInfo: { - NSLocalizedRecoverySuggestion: 'Provide a valid bundle identifier.', - NSLocalizedFailureReason: 'The requested application com.invalid.App is not installed.', - BundleIdentifier: 'com.invalid.App', - }, - }, - }; - - const failingProcess = new EventEmitter() as MockChildProcess; - - const stubOutput = new Readable({ - read() {}, - }); - const stubError = new Readable({ - read() {}, - }); - - failingProcess.stdout = stubOutput; - failingProcess.stderr = stubError; - failingProcess.exitCode = null; - failingProcess.killed = false; - failingProcess.kill = () => { - failingProcess.killed = true; - return true; - }; - - const mockExecutor = createMockExecutor({ - success: true, - output: '', - process: failingProcess, - }); - - let jsonPathSeen = ''; - let removedJsonPath = ''; - - const mockFileSystemExecutor = createMockFileSystemExecutor({ - mkdir: async () => {}, - writeFile: async () => {}, - existsSync: (filePath: string): boolean => { - if (filePath.includes('devicectl-launch-')) { - jsonPathSeen = filePath; - return true; - } - return false; - }, - readFile: async (filePath: string): Promise => { - if (filePath.includes('devicectl-launch-')) { - jsonPathSeen = filePath; - return JSON.stringify(jsonFailure); - } - return ''; - }, - rm: async (filePath: string) => { - if (filePath.includes('devicectl-launch-')) { - removedJsonPath = filePath; - } - }, - }); - - setTimeout(() => { - failingProcess.exitCode = 0; - failingProcess.emit('close', 0, null); - }, 5); - - const result = await start_device_log_capLogic( - { - deviceId: '00008110-001A2C3D4E5F', - bundleId: 'com.invalid.App', - }, - mockExecutor, - mockFileSystemExecutor, - ); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Provide a valid bundle identifier'); - expect(jsonPathSeen).not.toBe(''); - expect(removedJsonPath).toBe(jsonPathSeen); - expect(activeDeviceLogSessions.size).toBe(0); - expect(failingProcess.killed).toBe(true); - }); - - it('should treat JSON success payload as confirmation of launch', async () => { - const jsonSuccess = { - result: { - process: { - processIdentifier: 4321, - }, - }, - }; - - const runningProcess = new EventEmitter() as MockChildProcess; - - const stubOutput = new Readable({ - read() {}, - }); - const stubError = new Readable({ - read() {}, - }); - - runningProcess.stdout = stubOutput; - runningProcess.stderr = stubError; - runningProcess.exitCode = null; - runningProcess.killed = false; - runningProcess.kill = () => { - runningProcess.killed = true; - runningProcess.emit('close', 0, null); - return true; - }; - - const mockExecutor = createMockExecutor({ - success: true, - output: '', - process: runningProcess, - }); - - let jsonPathSeen = ''; - let removedJsonPath = ''; - let jsonRemoved = false; - - const mockFileSystemExecutor = createMockFileSystemExecutor({ - mkdir: async () => {}, - writeFile: async () => {}, - existsSync: (filePath: string): boolean => { - if (filePath.includes('devicectl-launch-')) { - jsonPathSeen = filePath; - return !jsonRemoved; - } - return false; - }, - readFile: async (filePath: string): Promise => { - if (filePath.includes('devicectl-launch-')) { - jsonPathSeen = filePath; - return JSON.stringify(jsonSuccess); - } - return ''; - }, - rm: async (filePath: string) => { - if (filePath.includes('devicectl-launch-')) { - jsonRemoved = true; - removedJsonPath = filePath; - } - }, - }); - - setTimeout(() => { - runningProcess.emit('close', 0, null); - }, 5); - - const result = await start_device_log_capLogic( - { - deviceId: '00008110-001A2C3D4E5F', - bundleId: 'io.sentry.MyApp', - }, - mockExecutor, - mockFileSystemExecutor, - ); - - expect(result.content[0].text).toContain('Device log capture started successfully'); - expect(result.isError ?? false).toBe(false); - expect(jsonPathSeen).not.toBe(''); - expect(removedJsonPath).toBe(jsonPathSeen); - expect(activeDeviceLogSessions.size).toBe(1); - }); - - it('should handle directory creation failure', async () => { - // Mock mkdir to fail - const mockExecutor = createMockExecutor({ - success: false, - output: '', - error: 'Command failed', - }); - - const mockFileSystemExecutor = createMockFileSystemExecutor({ - mkdir: async (path: string) => { - mkdirCalls.push(path); - throw new Error('Permission denied'); - }, - }); - - const result = await start_device_log_capLogic( - { - deviceId: '00008110-001A2C3D4E5F', - bundleId: 'io.sentry.MyApp', - }, - mockExecutor, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to start device log capture: Permission denied', - }, - ], - isError: true, - }); - }); - - it('should handle file write failure', async () => { - // Mock writeFile to fail - const mockExecutor = createMockExecutor({ - success: false, - output: '', - error: 'Command failed', - }); - - const mockFileSystemExecutor = createMockFileSystemExecutor({ - mkdir: async (path: string) => { - mkdirCalls.push(path); - }, - writeFile: async (path: string, content: string) => { - writeFileCalls.push({ path, content }); - throw new Error('Disk full'); - }, - }); - - const result = await start_device_log_capLogic( - { - deviceId: '00008110-001A2C3D4E5F', - bundleId: 'io.sentry.MyApp', - }, - mockExecutor, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to start device log capture: Disk full', - }, - ], - isError: true, - }); - }); - - it('should handle spawn process error', async () => { - // Mock spawn to throw error - const mockExecutor = createMockExecutor(new Error('Command not found')); - - const mockFileSystemExecutor = createMockFileSystemExecutor({ - mkdir: async (path: string) => { - mkdirCalls.push(path); - }, - writeFile: async (path: string, content: string) => { - writeFileCalls.push({ path, content }); - }, - }); - - const result = await start_device_log_capLogic( - { - deviceId: '00008110-001A2C3D4E5F', - bundleId: 'io.sentry.MyApp', - }, - mockExecutor, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to start device log capture: Command not found', - }, - ], - isError: true, - }); - }); - - it('should handle string error objects', async () => { - // Mock mkdir to fail with string error - const mockExecutor = createMockExecutor('String error message'); - - const mockFileSystemExecutor = createMockFileSystemExecutor({ - mkdir: async (path: string) => { - mkdirCalls.push(path); - }, - writeFile: async (path: string, content: string) => { - writeFileCalls.push({ path, content }); - }, - }); - - const result = await start_device_log_capLogic( - { - deviceId: '00008110-001A2C3D4E5F', - bundleId: 'io.sentry.MyApp', - }, - mockExecutor, - mockFileSystemExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to start device log capture: String error message', - }, - ], - isError: true, - }); - }); - }); -}); diff --git a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts deleted file mode 100644 index 2a3fcc92..00000000 --- a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * Tests for start_sim_log_cap plugin - */ -import { describe, it, expect } from 'vitest'; -import * as z from 'zod'; -import { schema, handler, start_sim_log_capLogic } from '../start_sim_log_cap.ts'; -import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; - -describe('start_sim_log_cap plugin', () => { - // Reset any test state if needed - - describe('Export Field Validation (Literal)', () => { - it('should export schema and handler', () => { - expect(schema).toBeDefined(); - expect(handler).toBeDefined(); - }); - - it('should have handler as a function', () => { - expect(typeof handler).toBe('function'); - }); - - it('should validate schema with valid parameters', () => { - const schemaObj = z.object(schema); - expect(schemaObj.safeParse({}).success).toBe(true); - expect(schemaObj.safeParse({ captureConsole: true }).success).toBe(true); - expect(schemaObj.safeParse({ captureConsole: false }).success).toBe(true); - }); - - it('should validate schema with subsystemFilter parameter', () => { - const schemaObj = z.object(schema); - // Valid enum values - expect(schemaObj.safeParse({ subsystemFilter: 'app' }).success).toBe(true); - expect(schemaObj.safeParse({ subsystemFilter: 'all' }).success).toBe(true); - expect(schemaObj.safeParse({ subsystemFilter: 'swiftui' }).success).toBe(true); - // Valid array of subsystems - expect(schemaObj.safeParse({ subsystemFilter: ['com.apple.UIKit'] }).success).toBe(true); - expect( - schemaObj.safeParse({ subsystemFilter: ['com.apple.UIKit', 'com.apple.CoreData'] }).success, - ).toBe(true); - // Invalid values - expect(schemaObj.safeParse({ subsystemFilter: [] }).success).toBe(false); - expect(schemaObj.safeParse({ subsystemFilter: 'invalid' }).success).toBe(false); - expect(schemaObj.safeParse({ subsystemFilter: 123 }).success).toBe(false); - }); - - it('should reject invalid schema parameters', () => { - const schemaObj = z.object(schema); - expect(schemaObj.safeParse({ captureConsole: 'yes' }).success).toBe(false); - expect(schemaObj.safeParse({ captureConsole: 123 }).success).toBe(false); - - const withSimId = schemaObj.safeParse({ simulatorId: 'test-uuid' }); - expect(withSimId.success).toBe(true); - expect('simulatorId' in (withSimId.data as any)).toBe(false); - }); - }); - - describe('Handler Behavior (Complete Literal Returns)', () => { - // Note: Parameter validation is now handled by createTypedTool wrapper - // Invalid parameters will not reach the logic function, so we test valid scenarios - - it('should return error when log capture fails', async () => { - const mockExecutor = createMockExecutor({ success: true, output: '' }); - const logCaptureStub = (params: any, executor: any) => { - return Promise.resolve({ - sessionId: '', - logFilePath: '', - processes: [], - error: 'Permission denied', - }); - }; - - const result = await start_sim_log_capLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.app', - subsystemFilter: 'app', - }, - mockExecutor, - logCaptureStub, - ); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toBe('Error starting log capture: Permission denied'); - }); - - it('should return success with session ID when log capture starts successfully', async () => { - const mockExecutor = createMockExecutor({ success: true, output: '' }); - const logCaptureStub = (params: any, executor: any) => { - return Promise.resolve({ - sessionId: 'test-uuid-123', - logFilePath: '/tmp/test.log', - processes: [], - error: undefined, - }); - }; - - const result = await start_sim_log_capLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.app', - subsystemFilter: 'app', - }, - mockExecutor, - logCaptureStub, - ); - - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture started successfully. Session ID: test-uuid-123.\n\nOnly structured logs from the app subsystem are being captured.\n\nInteract with your simulator and app, then stop capture to retrieve logs.', - ); - expect(result.nextStepParams?.stop_sim_log_cap).toBeDefined(); - expect(result.nextStepParams?.stop_sim_log_cap).toMatchObject({ - logSessionId: 'test-uuid-123', - }); - }); - - it('should indicate swiftui capture when subsystemFilter is swiftui', async () => { - const mockExecutor = createMockExecutor({ success: true, output: '' }); - const logCaptureStub = (params: any, executor: any) => { - return Promise.resolve({ - sessionId: 'test-uuid-123', - logFilePath: '/tmp/test.log', - processes: [], - error: undefined, - }); - }; - - const result = await start_sim_log_capLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.app', - subsystemFilter: 'swiftui', - }, - mockExecutor, - logCaptureStub, - ); - - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toContain('SwiftUI logs'); - expect(result.content[0].text).toContain('Self._printChanges()'); - }); - - it('should indicate all logs capture when subsystemFilter is all', async () => { - const mockExecutor = createMockExecutor({ success: true, output: '' }); - const logCaptureStub = (params: any, executor: any) => { - return Promise.resolve({ - sessionId: 'test-uuid-123', - logFilePath: '/tmp/test.log', - processes: [], - error: undefined, - }); - }; - - const result = await start_sim_log_capLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.app', - subsystemFilter: 'all', - }, - mockExecutor, - logCaptureStub, - ); - - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toContain('all system logs'); - }); - - it('should indicate custom subsystems when array is provided', async () => { - const mockExecutor = createMockExecutor({ success: true, output: '' }); - const logCaptureStub = (params: any, executor: any) => { - return Promise.resolve({ - sessionId: 'test-uuid-123', - logFilePath: '/tmp/test.log', - processes: [], - error: undefined, - }); - }; - - const result = await start_sim_log_capLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.app', - subsystemFilter: ['com.apple.UIKit', 'com.apple.CoreData'], - }, - mockExecutor, - logCaptureStub, - ); - - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toContain('com.apple.UIKit'); - expect(result.content[0].text).toContain('com.apple.CoreData'); - }); - - it('should indicate console capture when captureConsole is true', async () => { - const mockExecutor = createMockExecutor({ success: true, output: '' }); - const logCaptureStub = (params: any, executor: any) => { - return Promise.resolve({ - sessionId: 'test-uuid-123', - logFilePath: '/tmp/test.log', - processes: [], - error: undefined, - }); - }; - - const result = await start_sim_log_capLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.app', - captureConsole: true, - subsystemFilter: 'app', - }, - mockExecutor, - logCaptureStub, - ); - - expect(result.content[0].text).toContain('Your app was relaunched to capture console output'); - expect(result.content[0].text).toContain('test-uuid-123'); - }); - - it('should create correct spawn commands for console capture', async () => { - const mockExecutor = createMockExecutor({ success: true, output: '' }); - const spawnCalls: Array<{ - command: string; - args: string[]; - }> = []; - - const logCaptureStub = (params: any, executor: any) => { - if (params.captureConsole) { - // Record the console capture spawn call - spawnCalls.push({ - command: 'xcrun', - args: [ - 'simctl', - 'launch', - '--console-pty', - '--terminate-running-process', - params.simulatorUuid, - params.bundleId, - ], - }); - } - // Record the structured log capture spawn call - spawnCalls.push({ - command: 'xcrun', - args: [ - 'simctl', - 'spawn', - params.simulatorUuid, - 'log', - 'stream', - '--level=debug', - '--predicate', - `subsystem == "${params.bundleId}"`, - ], - }); - - return Promise.resolve({ - sessionId: 'test-uuid-123', - logFilePath: '/tmp/test.log', - processes: [], - error: undefined, - }); - }; - - await start_sim_log_capLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.app', - captureConsole: true, - subsystemFilter: 'app', - }, - mockExecutor, - logCaptureStub, - ); - - // Should spawn both console capture and structured log capture - expect(spawnCalls).toHaveLength(2); - expect(spawnCalls[0]).toEqual({ - command: 'xcrun', - args: [ - 'simctl', - 'launch', - '--console-pty', - '--terminate-running-process', - 'test-uuid', - 'io.sentry.app', - ], - }); - expect(spawnCalls[1]).toEqual({ - command: 'xcrun', - args: [ - 'simctl', - 'spawn', - 'test-uuid', - 'log', - 'stream', - '--level=debug', - '--predicate', - 'subsystem == "io.sentry.app"', - ], - }); - }); - - it('should create correct spawn commands for structured logs only', async () => { - const mockExecutor = createMockExecutor({ success: true, output: '' }); - const spawnCalls: Array<{ - command: string; - args: string[]; - }> = []; - - const logCaptureStub = (params: any, executor: any) => { - // Record the structured log capture spawn call only - spawnCalls.push({ - command: 'xcrun', - args: [ - 'simctl', - 'spawn', - params.simulatorUuid, - 'log', - 'stream', - '--level=debug', - '--predicate', - `subsystem == "${params.bundleId}"`, - ], - }); - - return Promise.resolve({ - sessionId: 'test-uuid-123', - logFilePath: '/tmp/test.log', - processes: [], - error: undefined, - }); - }; - - await start_sim_log_capLogic( - { - simulatorId: 'test-uuid', - bundleId: 'io.sentry.app', - captureConsole: false, - subsystemFilter: 'app', - }, - mockExecutor, - logCaptureStub, - ); - - // Should only spawn structured log capture - expect(spawnCalls).toHaveLength(1); - expect(spawnCalls[0]).toEqual({ - command: 'xcrun', - args: [ - 'simctl', - 'spawn', - 'test-uuid', - 'log', - 'stream', - '--level=debug', - '--predicate', - 'subsystem == "io.sentry.app"', - ], - }); - }); - }); -}); diff --git a/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts deleted file mode 100644 index 2ec1888f..00000000 --- a/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * Tests for stop_device_log_cap plugin - */ -import { describe, it, expect, beforeEach } from 'vitest'; -import { EventEmitter } from 'events'; -import * as z from 'zod'; -import { schema, handler, stop_device_log_capLogic } from '../stop_device_log_cap.ts'; -import { - activeDeviceLogSessions, - type DeviceLogSession, -} from '../../../../utils/log-capture/device-log-sessions.ts'; -import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; - -// Note: Logger is allowed to execute normally (integration testing pattern) - -describe('stop_device_log_cap plugin', () => { - beforeEach(() => { - // Clear actual active sessions before each test - activeDeviceLogSessions.clear(); - }); - - describe('Plugin Structure', () => { - it('should export schema and handler', () => { - expect(schema).toBeDefined(); - expect(handler).toBeDefined(); - }); - - it('should have correct schema structure', () => { - // Schema should be a plain object for MCP protocol compliance - expect(typeof schema).toBe('object'); - expect(schema).toHaveProperty('logSessionId'); - - // Validate that schema fields are Zod types that can be used for validation - const schemaObj = z.object(schema); - expect(schemaObj.safeParse({ logSessionId: 'test-session-id' }).success).toBe(true); - expect(schemaObj.safeParse({ logSessionId: 123 }).success).toBe(false); - }); - - it('should have handler as a function', () => { - expect(typeof handler).toBe('function'); - }); - }); - - describe('Handler Functionality', () => { - // Helper function to create a test process - function createTestProcess( - options: { - killed?: boolean; - exitCode?: number | null; - } = {}, - ) { - const emitter = new EventEmitter(); - const processState = { - killed: options.killed ?? false, - exitCode: options.exitCode ?? (options.killed ? 0 : null), - killCalls: [] as string[], - kill(signal?: string) { - if (this.killed) { - return false; - } - this.killCalls.push(signal ?? 'SIGTERM'); - this.killed = true; - this.exitCode = 0; - emitter.emit('close', 0); - return true; - }, - }; - - const testProcess = Object.assign(emitter, processState); - return testProcess as typeof testProcess; - } - - it('should handle stop log capture when session not found', async () => { - const mockFileSystem = createMockFileSystemExecutor(); - - const result = await stop_device_log_capLogic( - { - logSessionId: 'device-log-00008110-001A2C3D4E5F-io.sentry.MyApp', - }, - mockFileSystem, - ); - - expect(result.content[0].text).toBe( - 'Failed to stop device log capture session device-log-00008110-001A2C3D4E5F-io.sentry.MyApp: Device log capture session not found: device-log-00008110-001A2C3D4E5F-io.sentry.MyApp', - ); - expect(result.isError).toBe(true); - }); - - it('should handle successful log capture stop', async () => { - const testSessionId = 'test-session-123'; - const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-123.log'; - const testLogContent = 'Device log content here...'; - - // Test active session - const testProcess = createTestProcess({ - killed: false, - exitCode: null, - }); - - activeDeviceLogSessions.set(testSessionId, { - process: testProcess as unknown as DeviceLogSession['process'], - logFilePath: testLogFilePath, - deviceUuid: '00008110-001A2C3D4E5F', - bundleId: 'io.sentry.MyApp', - hasEnded: false, - }); - - // Configure test file system for successful operation - const mockFileSystem = createMockFileSystemExecutor({ - existsSync: () => true, - readFile: async () => testLogContent, - }); - - const result = await stop_device_log_capLogic( - { - logSessionId: testSessionId, - }, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `✅ Device log capture session stopped successfully\n\nSession ID: ${testSessionId}\n\n--- Captured Logs ---\n${testLogContent}`, - }, - ], - }); - expect(result.isError).toBeUndefined(); - expect(testProcess.killCalls).toEqual(['SIGTERM']); - expect(activeDeviceLogSessions.has(testSessionId)).toBe(false); - }); - - it('should handle already killed process', async () => { - const testSessionId = 'test-session-456'; - const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-456.log'; - const testLogContent = 'Device log content...'; - - // Test active session with already killed process - const testProcess = createTestProcess({ - killed: true, - exitCode: 0, - }); - - activeDeviceLogSessions.set(testSessionId, { - process: testProcess as unknown as DeviceLogSession['process'], - logFilePath: testLogFilePath, - deviceUuid: '00008110-001A2C3D4E5F', - bundleId: 'io.sentry.MyApp', - hasEnded: false, - }); - - // Configure test file system for successful operation - const mockFileSystem = createMockFileSystemExecutor({ - existsSync: () => true, - readFile: async () => testLogContent, - }); - - const result = await stop_device_log_capLogic( - { - logSessionId: testSessionId, - }, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `✅ Device log capture session stopped successfully\n\nSession ID: ${testSessionId}\n\n--- Captured Logs ---\n${testLogContent}`, - }, - ], - }); - expect(testProcess.killCalls).toEqual([]); // Should not kill already killed process - }); - - it('should handle file access failure', async () => { - const testSessionId = 'test-session-789'; - const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-789.log'; - - // Test active session - const testProcess = createTestProcess({ - killed: false, - exitCode: null, - }); - - activeDeviceLogSessions.set(testSessionId, { - process: testProcess as unknown as DeviceLogSession['process'], - logFilePath: testLogFilePath, - deviceUuid: '00008110-001A2C3D4E5F', - bundleId: 'io.sentry.MyApp', - hasEnded: false, - }); - - // Configure test file system for access failure (file doesn't exist) - const mockFileSystem = createMockFileSystemExecutor({ - existsSync: () => false, - }); - - const result = await stop_device_log_capLogic( - { - logSessionId: testSessionId, - }, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `Failed to stop device log capture session ${testSessionId}: Log file not found: ${testLogFilePath}`, - }, - ], - isError: true, - }); - expect(activeDeviceLogSessions.has(testSessionId)).toBe(false); // Session still removed - }); - - it('should handle file read failure', async () => { - const testSessionId = 'test-session-abc'; - const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-abc.log'; - - // Test active session - const testProcess = createTestProcess({ - killed: false, - exitCode: null, - }); - - activeDeviceLogSessions.set(testSessionId, { - process: testProcess as unknown as DeviceLogSession['process'], - logFilePath: testLogFilePath, - deviceUuid: '00008110-001A2C3D4E5F', - bundleId: 'io.sentry.MyApp', - hasEnded: false, - }); - - // Configure test file system for successful access but failed read - const mockFileSystem = createMockFileSystemExecutor({ - existsSync: () => true, - readFile: async () => { - throw new Error('Read permission denied'); - }, - }); - - const result = await stop_device_log_capLogic( - { - logSessionId: testSessionId, - }, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `Failed to stop device log capture session ${testSessionId}: Read permission denied`, - }, - ], - isError: true, - }); - }); - - it('should handle string error objects', async () => { - const testSessionId = 'test-session-def'; - const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-def.log'; - - // Test active session - const testProcess = createTestProcess({ - killed: false, - exitCode: null, - }); - - activeDeviceLogSessions.set(testSessionId, { - process: testProcess as unknown as DeviceLogSession['process'], - logFilePath: testLogFilePath, - deviceUuid: '00008110-001A2C3D4E5F', - bundleId: 'io.sentry.MyApp', - hasEnded: false, - }); - - // Configure test file system for access failure with string error - const mockFileSystem = createMockFileSystemExecutor({ - existsSync: () => true, - readFile: async () => { - throw 'String error message'; - }, - }); - - const result = await stop_device_log_capLogic( - { - logSessionId: testSessionId, - }, - mockFileSystem, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: `Failed to stop device log capture session ${testSessionId}: String error message`, - }, - ], - isError: true, - }); - }); - }); -}); diff --git a/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts deleted file mode 100644 index ceefa5b2..00000000 --- a/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** - * stop_sim_log_cap Plugin Tests - Test coverage for stop_sim_log_cap plugin - * - * This test file provides complete coverage for the stop_sim_log_cap plugin: - * - Plugin structure validation - * - Handler functionality (stop log capture session and retrieve captured logs) - * - Error handling for validation and log capture failures - * - * Tests follow the canonical testing patterns from CLAUDE.md with deterministic - * response validation and comprehensive parameter testing. - * Converted to pure dependency injection without vitest mocking. - */ - -import { describe, it, expect } from 'vitest'; -import * as z from 'zod'; -import { schema, handler, stop_sim_log_capLogic } from '../stop_sim_log_cap.ts'; -import { - createMockExecutor, - createMockFileSystemExecutor, -} from '../../../../test-utils/mock-executors.ts'; - -describe('stop_sim_log_cap plugin', () => { - const mockExecutor = createMockExecutor({ success: true, output: '' }); - const mockFileSystem = createMockFileSystemExecutor(); - - describe('Export Field Validation (Literal)', () => { - it('should export schema and handler', () => { - expect(schema).toBeDefined(); - expect(handler).toBeDefined(); - expect(typeof handler).toBe('function'); - expect(typeof schema).toBe('object'); - }); - - it('should have correct schema structure', () => { - // Schema should be a plain object for MCP protocol compliance - expect(typeof schema).toBe('object'); - expect(schema).toHaveProperty('logSessionId'); - - // Validate that schema fields are Zod types that can be used for validation - const schemaObj = z.object(schema); - expect(schemaObj.safeParse({ logSessionId: 'test-session-id' }).success).toBe(true); - expect(schemaObj.safeParse({ logSessionId: 123 }).success).toBe(false); - }); - - it('should validate schema with valid parameters', () => { - expect(schema.logSessionId.safeParse('test-session-id').success).toBe(true); - }); - - it('should reject invalid schema parameters', () => { - expect(schema.logSessionId.safeParse(null).success).toBe(false); - expect(schema.logSessionId.safeParse(undefined).success).toBe(false); - expect(schema.logSessionId.safeParse(123).success).toBe(false); - expect(schema.logSessionId.safeParse(true).success).toBe(false); - }); - }); - - describe('Input Validation', () => { - it('should handle null logSessionId (validation handled by framework)', async () => { - // With typed tool factory, invalid params won't reach the logic function - // This test now validates that the logic function works with valid empty strings - const stopLogCaptureStub = async () => ({ - logContent: 'Log content for empty session', - error: undefined, - }); - - const result = await stop_sim_log_capLogic( - { - logSessionId: '', - }, - mockExecutor, - stopLogCaptureStub, - mockFileSystem, - ); - - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session stopped successfully. Log content follows:\n\nLog content for empty session', - ); - }); - - it('should handle undefined logSessionId (validation handled by framework)', async () => { - // With typed tool factory, invalid params won't reach the logic function - // This test now validates that the logic function works with valid empty strings - const stopLogCaptureStub = async () => ({ - logContent: 'Log content for empty session', - error: undefined, - }); - - const result = await stop_sim_log_capLogic( - { - logSessionId: '', - }, - mockExecutor, - stopLogCaptureStub, - mockFileSystem, - ); - - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session stopped successfully. Log content follows:\n\nLog content for empty session', - ); - }); - - it('should handle empty string logSessionId', async () => { - const stopLogCaptureStub = async () => ({ - logContent: 'Log content for empty session', - error: undefined, - }); - - const result = await stop_sim_log_capLogic( - { - logSessionId: '', - }, - mockExecutor, - stopLogCaptureStub, - mockFileSystem, - ); - - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session stopped successfully. Log content follows:\n\nLog content for empty session', - ); - }); - }); - - describe('Function Call Generation', () => { - it('should call stopLogCapture with correct parameters', async () => { - let capturedSessionId = ''; - const stopLogCaptureStub = async (logSessionId: string) => { - capturedSessionId = logSessionId; - return { logContent: 'Mock log content from file', error: undefined }; - }; - - const result = await stop_sim_log_capLogic( - { - logSessionId: 'test-session-id', - }, - mockExecutor, - stopLogCaptureStub, - mockFileSystem, - ); - - expect(capturedSessionId).toBe('test-session-id'); - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session test-session-id stopped successfully. Log content follows:\n\nMock log content from file', - ); - }); - - it('should call stopLogCapture with different session ID', async () => { - let capturedSessionId = ''; - const stopLogCaptureStub = async (logSessionId: string) => { - capturedSessionId = logSessionId; - return { logContent: 'Different log content', error: undefined }; - }; - - const result = await stop_sim_log_capLogic( - { - logSessionId: 'different-session-id', - }, - mockExecutor, - stopLogCaptureStub, - mockFileSystem, - ); - - expect(capturedSessionId).toBe('different-session-id'); - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session different-session-id stopped successfully. Log content follows:\n\nDifferent log content', - ); - }); - }); - - describe('Response Processing', () => { - it('should handle successful log capture stop', async () => { - const stopLogCaptureStub = async () => ({ - logContent: 'Mock log content from file', - error: undefined, - }); - - const result = await stop_sim_log_capLogic( - { - logSessionId: 'test-session-id', - }, - mockExecutor, - stopLogCaptureStub, - mockFileSystem, - ); - - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session test-session-id stopped successfully. Log content follows:\n\nMock log content from file', - ); - }); - - it('should handle empty log content', async () => { - const stopLogCaptureStub = async () => ({ - logContent: '', - error: undefined, - }); - - const result = await stop_sim_log_capLogic( - { - logSessionId: 'test-session-id', - }, - mockExecutor, - stopLogCaptureStub, - mockFileSystem, - ); - - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session test-session-id stopped successfully. Log content follows:\n\n', - ); - }); - - it('should handle multiline log content', async () => { - const stopLogCaptureStub = async () => ({ - logContent: 'Line 1\nLine 2\nLine 3', - error: undefined, - }); - - const result = await stop_sim_log_capLogic( - { - logSessionId: 'test-session-id', - }, - mockExecutor, - stopLogCaptureStub, - mockFileSystem, - ); - - expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toBe( - 'Log capture session test-session-id stopped successfully. Log content follows:\n\nLine 1\nLine 2\nLine 3', - ); - }); - - it('should handle log capture stop errors for non-existent session', async () => { - const stopLogCaptureStub = async () => ({ - logContent: '', - error: 'Log capture session not found: non-existent-session', - }); - - const result = await stop_sim_log_capLogic( - { - logSessionId: 'non-existent-session', - }, - mockExecutor, - stopLogCaptureStub, - mockFileSystem, - ); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toBe( - 'Error stopping log capture session non-existent-session: Log capture session not found: non-existent-session', - ); - }); - - it('should handle file read errors', async () => { - const stopLogCaptureStub = async () => ({ - logContent: '', - error: 'ENOENT: no such file or directory', - }); - - const result = await stop_sim_log_capLogic( - { - logSessionId: 'test-session-id', - }, - mockExecutor, - stopLogCaptureStub, - mockFileSystem, - ); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( - 'Error stopping log capture session test-session-id:', - ); - }); - - it('should handle permission errors', async () => { - const stopLogCaptureStub = async () => ({ - logContent: '', - error: 'EACCES: permission denied', - }); - - const result = await stop_sim_log_capLogic( - { - logSessionId: 'test-session-id', - }, - mockExecutor, - stopLogCaptureStub, - mockFileSystem, - ); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( - 'Error stopping log capture session test-session-id:', - ); - }); - - it('should handle various error types', async () => { - const stopLogCaptureStub = async () => ({ - logContent: '', - error: 'Unexpected error', - }); - - const result = await stop_sim_log_capLogic( - { - logSessionId: 'test-session-id', - }, - mockExecutor, - stopLogCaptureStub, - mockFileSystem, - ); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( - 'Error stopping log capture session test-session-id:', - ); - }); - }); -}); diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts deleted file mode 100644 index cf2fa0d5..00000000 --- a/src/mcp/tools/logging/start_device_log_cap.ts +++ /dev/null @@ -1,671 +0,0 @@ -/** - * Logging Plugin: Start Device Log Capture - * - * Starts capturing logs from a specified Apple device by launching the app with console output. - */ - -import * as path from 'path'; -import type { ChildProcess } from 'child_process'; -import { v4 as uuidv4 } from 'uuid'; -import * as z from 'zod'; -import { log } from '../../../utils/logging/index.ts'; -import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; -import { - getDefaultCommandExecutor, - getDefaultFileSystemExecutor, -} from '../../../utils/execution/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; -import { - createSessionAwareTool, - getSessionAwareToolSchemaShape, -} from '../../../utils/typed-tool-factory.ts'; -import { - activeDeviceLogSessions, - type DeviceLogSession, -} from '../../../utils/log-capture/device-log-sessions.ts'; -import type { WriteStream } from 'fs'; -import { getConfig } from '../../../utils/config-store.ts'; -import { acquireDaemonActivity } from '../../../daemon/activity-registry.ts'; - -/** - * Log file retention policy for device logs: - * - Old log files (older than LOG_RETENTION_DAYS) are automatically deleted from the temp directory - * - Cleanup runs on every new log capture start - */ -const LOG_RETENTION_DAYS = 3; -const DEVICE_LOG_FILE_PREFIX = 'xcodemcp_device_log_'; - -// Note: Device and simulator logging use different approaches due to platform constraints: -// - Simulators use 'xcrun simctl' with console-pty and OSLog stream capabilities -// - Devices use 'xcrun devicectl' with console output only (no OSLog streaming) -// The different command structures and output formats make sharing infrastructure complex. -// However, both follow similar patterns for session management and log retention. -const EARLY_FAILURE_WINDOW_MS = 5000; -const INITIAL_OUTPUT_LIMIT = 8_192; -const DEFAULT_JSON_RESULT_WAIT_MS = 8000; - -const FAILURE_PATTERNS = [ - /The application failed to launch/i, - /Provide a valid bundle identifier/i, - /The requested application .* is not installed/i, - /NSOSStatusErrorDomain/i, - /NSLocalizedFailureReason/i, - /ERROR:/i, -]; - -type JsonOutcome = { - errorMessage?: string; - pid?: number; -}; - -type DevicectlLaunchJson = { - result?: { - process?: { - processIdentifier?: unknown; - }; - }; - error?: { - code?: unknown; - domain?: unknown; - localizedDescription?: unknown; - userInfo?: Record | undefined; - }; -}; - -function getJsonResultWaitMs(): number { - const configured = getConfig().launchJsonWaitMs; - if (!Number.isFinite(configured) || configured < 0) { - return DEFAULT_JSON_RESULT_WAIT_MS; - } - return configured; -} - -function safeParseJson(text: string): DevicectlLaunchJson | null { - try { - const parsed = JSON.parse(text) as unknown; - if (!parsed || typeof parsed !== 'object') { - return null; - } - return parsed as DevicectlLaunchJson; - } catch { - return null; - } -} - -function extractJsonOutcome(json: DevicectlLaunchJson | null): JsonOutcome | null { - if (!json) { - return null; - } - - const resultProcess = json.result?.process; - const pidValue = resultProcess?.processIdentifier; - if (typeof pidValue === 'number' && Number.isFinite(pidValue)) { - return { pid: pidValue }; - } - - const error = json.error; - if (!error) { - return null; - } - - const parts: string[] = []; - - if (typeof error.localizedDescription === 'string' && error.localizedDescription.length > 0) { - parts.push(error.localizedDescription); - } - - const userInfo = error.userInfo ?? {}; - const recovery = userInfo?.NSLocalizedRecoverySuggestion; - const failureReason = userInfo?.NSLocalizedFailureReason; - const bundleIdentifier = userInfo?.BundleIdentifier; - - if (typeof failureReason === 'string' && failureReason.length > 0) { - parts.push(failureReason); - } - - if (typeof recovery === 'string' && recovery.length > 0) { - parts.push(recovery); - } - - if (typeof bundleIdentifier === 'string' && bundleIdentifier.length > 0) { - parts.push(`BundleIdentifier = ${bundleIdentifier}`); - } - - const domain = error.domain; - const code = error.code; - const domainPart = typeof domain === 'string' && domain.length > 0 ? domain : undefined; - const codePart = typeof code === 'number' && Number.isFinite(code) ? code : undefined; - - if (domainPart || codePart !== undefined) { - parts.push(`(${domainPart ?? 'UnknownDomain'} code ${codePart ?? 'unknown'})`); - } - - if (parts.length === 0) { - return { errorMessage: 'Launch failed' }; - } - - return { errorMessage: parts.join('\n') }; -} - -async function removeFileIfExists( - targetPath: string, - fileExecutor: FileSystemExecutor, -): Promise { - try { - if (fileExecutor.existsSync(targetPath)) { - await fileExecutor.rm(targetPath, { force: true }); - } - } catch { - // Best-effort cleanup only - } -} - -async function pollJsonOutcome( - jsonPath: string, - fileExecutor: FileSystemExecutor, - timeoutMs: number, -): Promise { - const start = Date.now(); - - const readOnce = async (): Promise => { - try { - const exists = fileExecutor.existsSync(jsonPath); - - if (!exists) { - return null; - } - - const content = await fileExecutor.readFile(jsonPath, 'utf8'); - - const outcome = extractJsonOutcome(safeParseJson(content)); - if (outcome) { - await removeFileIfExists(jsonPath, fileExecutor); - return outcome; - } - } catch { - // File may still be written; try again later - } - - return null; - }; - - const immediate = await readOnce(); - if (immediate) { - return immediate; - } - - if (timeoutMs <= 0) { - return null; - } - - let delay = Math.min(100, Math.max(10, Math.floor(timeoutMs / 4) || 10)); - - while (Date.now() - start < timeoutMs) { - await new Promise((resolve) => setTimeout(resolve, delay)); - const result = await readOnce(); - if (result) { - return result; - } - delay = Math.min(400, delay + 50); - } - - return null; -} - -type WriteStreamWithClosed = WriteStream & { closed?: boolean }; - -/** - * Start a log capture session for an iOS device by launching the app with console output. - * Uses the devicectl command to launch the app and capture console logs. - * Returns { sessionId, error? } - */ -export async function startDeviceLogCapture( - params: { - deviceUuid: string; - bundleId: string; - }, - executor: CommandExecutor = getDefaultCommandExecutor(), - fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), -): Promise<{ sessionId: string; error?: string }> { - // Clean up old logs before starting a new session - await cleanOldDeviceLogs(fileSystemExecutor); - - const { deviceUuid, bundleId } = params; - const logSessionId = uuidv4(); - const logFileName = `${DEVICE_LOG_FILE_PREFIX}${logSessionId}.log`; - const tempDir = fileSystemExecutor.tmpdir(); - const logFilePath = path.join(tempDir, logFileName); - const launchJsonPath = path.join(tempDir, `devicectl-launch-${logSessionId}.json`); - - let logStream: WriteStream | undefined; - - try { - await fileSystemExecutor.mkdir(tempDir, { recursive: true }); - await fileSystemExecutor.writeFile(logFilePath, ''); - - logStream = fileSystemExecutor.createWriteStream(logFilePath, { flags: 'a' }); - - logStream.write( - `\n--- Device log capture for bundle ID: ${bundleId} on device: ${deviceUuid} ---\n`, - ); - - // Use executor with dependency injection instead of spawn directly - const result = await executor( - [ - 'xcrun', - 'devicectl', - 'device', - 'process', - 'launch', - '--console', - '--terminate-existing', - '--device', - deviceUuid, - '--json-output', - launchJsonPath, - bundleId, - ], - 'Device Log Capture', - true, - undefined, - true, - ); - - if (!result.success) { - log( - 'error', - `Device log capture process reported failure: ${result.error ?? 'unknown error'}`, - ); - if (logStream && !logStream.destroyed) { - logStream.write( - `\n--- Device log capture failed to start ---\n${result.error ?? 'Unknown error'}\n`, - ); - logStream.end(); - } - return { - sessionId: '', - error: result.error ?? 'Failed to start device log capture', - }; - } - - const childProcess = result.process; - if (!childProcess) { - throw new Error('Device log capture process handle was not returned'); - } - - const session: DeviceLogSession = { - process: childProcess, - logFilePath, - deviceUuid, - bundleId, - logStream, - hasEnded: false, - }; - - let bufferedOutput = ''; - const appendBufferedOutput = (text: string): void => { - bufferedOutput += text; - if (bufferedOutput.length > INITIAL_OUTPUT_LIMIT) { - bufferedOutput = bufferedOutput.slice(bufferedOutput.length - INITIAL_OUTPUT_LIMIT); - } - }; - - let triggerImmediateFailure: ((message: string) => void) | undefined; - - const handleOutput = (chunk: unknown): void => { - if (!logStream || logStream.destroyed) return; - const text = - typeof chunk === 'string' - ? chunk - : chunk instanceof Buffer - ? chunk.toString('utf8') - : String(chunk ?? ''); - if (text.length > 0) { - appendBufferedOutput(text); - const extracted = extractFailureMessage(bufferedOutput); - if (extracted) { - triggerImmediateFailure?.(extracted); - } - logStream.write(text); - } - }; - - childProcess.stdout?.setEncoding?.('utf8'); - childProcess.stdout?.on?.('data', handleOutput); - childProcess.stderr?.setEncoding?.('utf8'); - childProcess.stderr?.on?.('data', handleOutput); - - const cleanupStreams = (): void => { - childProcess.stdout?.off?.('data', handleOutput); - childProcess.stderr?.off?.('data', handleOutput); - }; - - const earlyFailure = await detectEarlyLaunchFailure( - childProcess, - EARLY_FAILURE_WINDOW_MS, - () => bufferedOutput, - (handler) => { - triggerImmediateFailure = handler; - }, - ); - - if (earlyFailure) { - cleanupStreams(); - session.hasEnded = true; - - const failureMessage = - earlyFailure.errorMessage && earlyFailure.errorMessage.length > 0 - ? earlyFailure.errorMessage - : `Device log capture process exited immediately (exit code: ${ - earlyFailure.exitCode ?? 'unknown' - })`; - - log('error', `Device log capture failed to start: ${failureMessage}`); - if (logStream && !logStream.destroyed) { - try { - logStream.write(`\n--- Device log capture failed to start ---\n${failureMessage}\n`); - } catch { - // best-effort logging - } - logStream.end(); - } - - await removeFileIfExists(launchJsonPath, fileSystemExecutor); - - childProcess.kill?.('SIGTERM'); - return { sessionId: '', error: failureMessage }; - } - - const jsonOutcome = await pollJsonOutcome( - launchJsonPath, - fileSystemExecutor, - getJsonResultWaitMs(), - ); - - if (jsonOutcome?.errorMessage) { - cleanupStreams(); - session.hasEnded = true; - - const failureMessage = jsonOutcome.errorMessage; - - log('error', `Device log capture failed to start (JSON): ${failureMessage}`); - - if (logStream && !logStream.destroyed) { - try { - logStream.write(`\n--- Device log capture failed to start ---\n${failureMessage}\n`); - } catch { - // ignore secondary logging failures - } - logStream.end(); - } - - childProcess.kill?.('SIGTERM'); - return { sessionId: '', error: failureMessage }; - } - - if (jsonOutcome?.pid && logStream && !logStream.destroyed) { - try { - logStream.write(`Process ID: ${jsonOutcome.pid}\n`); - } catch { - // best-effort logging only - } - } - - childProcess.once?.('error', (err) => { - log( - 'error', - `Device log capture process error (session ${logSessionId}): ${ - err instanceof Error ? err.message : String(err) - }`, - ); - }); - - childProcess.once?.('close', (code) => { - cleanupStreams(); - session.hasEnded = true; - if (logStream && !logStream.destroyed && !(logStream as WriteStreamWithClosed).closed) { - logStream.write(`\n--- Device log capture ended (exit code: ${code ?? 'unknown'}) ---\n`); - logStream.end(); - } - void removeFileIfExists(launchJsonPath, fileSystemExecutor); - }); - - // For testing purposes, we'll simulate process management - // In actual usage, the process would be managed by the executor result - session.releaseActivity = acquireDaemonActivity('logging.device'); - activeDeviceLogSessions.set(logSessionId, session); - - log('info', `Device log capture started with session ID: ${logSessionId}`); - return { sessionId: logSessionId }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log('error', `Failed to start device log capture: ${message}`); - if (logStream && !logStream.destroyed && !(logStream as WriteStreamWithClosed).closed) { - try { - logStream.write(`\n--- Device log capture failed: ${message} ---\n`); - } catch { - // ignore secondary stream write failures - } - logStream.end(); - } - await removeFileIfExists(launchJsonPath, fileSystemExecutor); - return { sessionId: '', error: message }; - } -} - -type EarlyFailureResult = { - exitCode: number | null; - errorMessage?: string; -}; - -function detectEarlyLaunchFailure( - process: ChildProcess, - timeoutMs: number, - getBufferedOutput?: () => string, - registerImmediateFailure?: (handler: (message: string) => void) => void, -): Promise { - if (process.exitCode != null) { - const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); - if (process.exitCode === 0 && !failureFromOutput) { - return Promise.resolve(null); - } - return Promise.resolve({ exitCode: process.exitCode, errorMessage: failureFromOutput }); - } - - return new Promise((resolve) => { - let settled = false; - - const finalize = (result: EarlyFailureResult | null): void => { - if (settled) return; - settled = true; - process.removeListener('close', onClose); - process.removeListener('error', onError); - clearTimeout(timer); - resolve(result); - }; - - registerImmediateFailure?.((message) => { - finalize({ exitCode: process.exitCode ?? null, errorMessage: message }); - }); - - const onClose = (code: number | null): void => { - const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); - if (code === 0 && !failureFromOutput) { - finalize(null); - } else { - finalize({ exitCode: code, errorMessage: failureFromOutput }); - } - }; - - const onError = (error: Error): void => { - finalize({ exitCode: null, errorMessage: error.message }); - }; - - const timer = setTimeout(() => { - const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); - if (failureFromOutput) { - process.kill?.('SIGTERM'); - finalize({ exitCode: process.exitCode ?? null, errorMessage: failureFromOutput }); - return; - } - finalize(null); - }, timeoutMs); - - process.once('close', onClose); - process.once('error', onError); - }); -} - -function extractFailureMessage(output?: string): string | undefined { - if (!output) { - return undefined; - } - const normalized = output.replace(/\r/g, ''); - const lines = normalized - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); - - const shouldInclude = (line?: string): boolean => { - if (!line) return false; - return ( - line.startsWith('NS') || - line.startsWith('BundleIdentifier') || - line.startsWith('Provide ') || - line.startsWith('The application') || - line.startsWith('ERROR:') - ); - }; - - for (const pattern of FAILURE_PATTERNS) { - const matchIndex = lines.findIndex((line) => pattern.test(line)); - if (matchIndex === -1) { - continue; - } - - const snippet: string[] = [lines[matchIndex]]; - const nextLine = lines[matchIndex + 1]; - const thirdLine = lines[matchIndex + 2]; - if (shouldInclude(nextLine)) snippet.push(nextLine); - if (shouldInclude(thirdLine)) snippet.push(thirdLine); - const message = snippet.join('\n').trim(); - if (message.length > 0) { - return message; - } - return lines[matchIndex]; - } - - return undefined; -} - -/** - * Deletes device log files older than LOG_RETENTION_DAYS from the temp directory. - * Runs quietly; errors are logged but do not throw. - */ -// Device logs follow the same retention policy as simulator logs but use a different prefix -// to avoid conflicts. Both clean up logs older than LOG_RETENTION_DAYS automatically. -async function cleanOldDeviceLogs(fileSystemExecutor: FileSystemExecutor): Promise { - const tempDir = fileSystemExecutor.tmpdir(); - let files: unknown[]; - try { - files = await fileSystemExecutor.readdir(tempDir); - } catch (err) { - log( - 'warn', - `Could not read temp dir for device log cleanup: ${err instanceof Error ? err.message : String(err)}`, - ); - return; - } - const now = Date.now(); - const retentionMs = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; - const fileNames = files.filter((file): file is string => typeof file === 'string'); - - await Promise.all( - fileNames - .filter((f) => f.startsWith(DEVICE_LOG_FILE_PREFIX) && f.endsWith('.log')) - .map(async (f) => { - const filePath = path.join(tempDir, f); - try { - const stat = await fileSystemExecutor.stat(filePath); - if (now - stat.mtimeMs > retentionMs) { - await fileSystemExecutor.rm(filePath, { force: true }); - log('info', `Deleted old device log file: ${filePath}`); - } - } catch (err) { - log( - 'warn', - `Error during device log cleanup for ${filePath}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - }), - ); -} - -// Define schema as ZodObject -const startDeviceLogCapSchema = z.object({ - deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), - bundleId: z.string(), -}); - -const publicSchemaObject = startDeviceLogCapSchema.omit({ - deviceId: true, - bundleId: true, -} as const); - -// Use z.infer for type safety -type StartDeviceLogCapParams = z.infer; - -/** - * Core business logic for starting device log capture. - */ -export async function start_device_log_capLogic( - params: StartDeviceLogCapParams, - executor: CommandExecutor, - fileSystemExecutor?: FileSystemExecutor, -): Promise { - const { deviceId, bundleId } = params; - - const resolvedFileSystemExecutor = fileSystemExecutor ?? getDefaultFileSystemExecutor(); - - const { sessionId, error } = await startDeviceLogCapture( - { deviceUuid: deviceId, bundleId }, - executor, - resolvedFileSystemExecutor, - ); - - if (error) { - return { - content: [ - { - type: 'text', - text: `Failed to start device log capture: ${error}`, - }, - ], - isError: true, - }; - } - - return { - content: [ - { - type: 'text', - text: `✅ Device log capture started successfully\n\nSession ID: ${sessionId}\n\nNote: The app has been launched on the device with console output capture enabled.\nDo not call launch_app_device during this capture session; relaunching can interrupt captured output.\n\nInteract with your app on the device, then stop capture to retrieve logs.`, - }, - ], - nextStepParams: { - stop_device_log_cap: { logSessionId: sessionId }, - }, - }; -} - -export const schema = getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: startDeviceLogCapSchema, -}); - -export const handler = createSessionAwareTool({ - internalSchema: startDeviceLogCapSchema as unknown as z.ZodType, - logicFunction: start_device_log_capLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['deviceId', 'bundleId'], message: 'Provide deviceId and bundleId' }], -}); diff --git a/src/mcp/tools/logging/start_sim_log_cap.ts b/src/mcp/tools/logging/start_sim_log_cap.ts deleted file mode 100644 index 3ea427bd..00000000 --- a/src/mcp/tools/logging/start_sim_log_cap.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Logging Plugin: Start Simulator Log Capture - * - * Starts capturing logs from a specified simulator. - */ - -import * as z from 'zod'; -import { startLogCapture } from '../../../utils/log-capture/index.ts'; -import type { CommandExecutor } from '../../../utils/command.ts'; -import { getDefaultCommandExecutor } from '../../../utils/command.ts'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createTextContent } from '../../../types/common.ts'; -import type { SubsystemFilter } from '../../../utils/log_capture.ts'; -import { - createSessionAwareTool, - getSessionAwareToolSchemaShape, -} from '../../../utils/typed-tool-factory.ts'; - -// Define schema as ZodObject -const startSimLogCapSchema = z.object({ - simulatorId: z - .uuid() - .describe('UUID of the simulator to capture logs from (obtained from list_simulators).'), - bundleId: z.string(), - captureConsole: z.boolean().optional(), - subsystemFilter: z - .union([z.enum(['app', 'all', 'swiftui']), z.array(z.string()).min(1)]) - .default('app') - .describe('app|all|swiftui|[subsystem]'), -}); - -// Use z.infer for type safety -type StartSimLogCapParams = z.infer; - -function buildSubsystemFilterDescription(subsystemFilter: SubsystemFilter): string { - if (subsystemFilter === 'all') { - return 'Capturing all system logs (no subsystem filtering).'; - } - if (subsystemFilter === 'swiftui') { - return 'Capturing app logs + SwiftUI logs (includes Self._printChanges()).'; - } - if (Array.isArray(subsystemFilter)) { - if (subsystemFilter.length === 0) { - return 'Only structured logs from the app subsystem are being captured.'; - } - return `Capturing logs from subsystems: ${subsystemFilter.join(', ')} (plus app bundle ID).`; - } - - return 'Only structured logs from the app subsystem are being captured.'; -} - -export async function start_sim_log_capLogic( - params: StartSimLogCapParams, - _executor: CommandExecutor = getDefaultCommandExecutor(), - logCaptureFunction: typeof startLogCapture = startLogCapture, -): Promise { - const { bundleId, simulatorId, subsystemFilter } = params; - const captureConsole = params.captureConsole ?? false; - const logCaptureParams: Parameters[0] = { - simulatorUuid: simulatorId, - bundleId, - captureConsole, - subsystemFilter, - }; - const { sessionId, error } = await logCaptureFunction(logCaptureParams, _executor); - if (error) { - return { - content: [createTextContent(`Error starting log capture: ${error}`)], - isError: true, - }; - } - - const filterDescription = buildSubsystemFilterDescription(subsystemFilter); - - return { - content: [ - createTextContent( - `Log capture started successfully. Session ID: ${sessionId}.\n\n${captureConsole ? 'Note: Your app was relaunched to capture console output.\n' : ''}${filterDescription}\n\nInteract with your simulator and app, then stop capture to retrieve logs.`, - ), - ], - nextStepParams: { - stop_sim_log_cap: { logSessionId: sessionId }, - }, - }; -} - -const publicSchemaObject = z.strictObject( - startSimLogCapSchema.omit({ simulatorId: true, bundleId: true } as const).shape, -); - -export const schema = getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: startSimLogCapSchema, -}); - -export const handler = createSessionAwareTool({ - internalSchema: startSimLogCapSchema as unknown as z.ZodType, - logicFunction: start_sim_log_capLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['simulatorId', 'bundleId'], message: 'Provide simulatorId and bundleId' }, - ], -}); diff --git a/src/mcp/tools/logging/stop_device_log_cap.ts b/src/mcp/tools/logging/stop_device_log_cap.ts deleted file mode 100644 index dc96a825..00000000 --- a/src/mcp/tools/logging/stop_device_log_cap.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Logging Plugin: Stop Device Log Capture - * - * Stops an active Apple device log capture session and returns the captured logs. - */ - -import * as fs from 'fs'; -import * as z from 'zod'; -import { log } from '../../../utils/logging/index.ts'; -import { - stopDeviceLogSessionById, - stopAllDeviceLogCaptures, -} from '../../../utils/log-capture/device-log-sessions.ts'; -import type { ToolResponse } from '../../../types/common.ts'; -import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; -import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; - -const stopDeviceLogCapSchema = z.object({ - logSessionId: z.string(), -}); - -type StopDeviceLogCapParams = z.infer; - -export async function stop_device_log_capLogic( - params: StopDeviceLogCapParams, - fileSystemExecutor: FileSystemExecutor, -): Promise { - const { logSessionId } = params; - - try { - log('info', `Attempting to stop device log capture session: ${logSessionId}`); - - const result = await stopDeviceLogSessionById(logSessionId, fileSystemExecutor, { - timeoutMs: 1000, - readLogContent: true, - }); - - if (result.error) { - log('error', `Failed to stop device log capture session ${logSessionId}: ${result.error}`); - return { - content: [ - { - type: 'text', - text: `Failed to stop device log capture session ${logSessionId}: ${result.error}`, - }, - ], - isError: true, - }; - } - - return { - content: [ - { - type: 'text', - text: `✅ Device log capture session stopped successfully\n\nSession ID: ${logSessionId}\n\n--- Captured Logs ---\n${result.logContent}`, - }, - ], - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log('error', `Failed to stop device log capture session ${logSessionId}: ${message}`); - return { - content: [ - { - type: 'text', - text: `Failed to stop device log capture session ${logSessionId}: ${message}`, - }, - ], - isError: true, - }; - } -} - -function hasPromisesInterface(obj: unknown): obj is { promises: typeof fs.promises } { - return typeof obj === 'object' && obj !== null && 'promises' in obj; -} - -function hasExistsSyncMethod(obj: unknown): obj is { existsSync: typeof fs.existsSync } { - return typeof obj === 'object' && obj !== null && 'existsSync' in obj; -} - -function hasCreateWriteStreamMethod( - obj: unknown, -): obj is { createWriteStream: typeof fs.createWriteStream } { - return typeof obj === 'object' && obj !== null && 'createWriteStream' in obj; -} - -export async function stopDeviceLogCapture( - logSessionId: string, - fileSystem?: unknown, -): Promise<{ logContent: string; error?: string }> { - const fsToUse = fileSystem ?? fs; - const mockFileSystemExecutor: FileSystemExecutor = { - async mkdir(path: string, options?: { recursive?: boolean }): Promise { - if (hasPromisesInterface(fsToUse)) { - await fsToUse.promises.mkdir(path, options); - } else { - await fs.promises.mkdir(path, options); - } - }, - async readFile(path: string, encoding: BufferEncoding = 'utf8'): Promise { - if (hasPromisesInterface(fsToUse)) { - const result = await fsToUse.promises.readFile(path, encoding); - return typeof result === 'string' ? result : (result as Buffer).toString(); - } else { - const result = await fs.promises.readFile(path, encoding); - return typeof result === 'string' ? result : (result as Buffer).toString(); - } - }, - async writeFile( - path: string, - content: string, - encoding: BufferEncoding = 'utf8', - ): Promise { - if (hasPromisesInterface(fsToUse)) { - await fsToUse.promises.writeFile(path, content, encoding); - } else { - await fs.promises.writeFile(path, content, encoding); - } - }, - createWriteStream(path: string, options?: { flags?: string }) { - if (hasCreateWriteStreamMethod(fsToUse)) { - return fsToUse.createWriteStream(path, options); - } - return fs.createWriteStream(path, options); - }, - async cp( - source: string, - destination: string, - options?: { recursive?: boolean }, - ): Promise { - if (hasPromisesInterface(fsToUse)) { - await fsToUse.promises.cp(source, destination, options); - } else { - await fs.promises.cp(source, destination, options); - } - }, - async readdir(path: string, options?: { withFileTypes?: boolean }): Promise { - if (hasPromisesInterface(fsToUse)) { - if (options?.withFileTypes === true) { - const result = await fsToUse.promises.readdir(path, { withFileTypes: true }); - return Array.isArray(result) ? result : []; - } - const result = await fsToUse.promises.readdir(path); - return Array.isArray(result) ? result : []; - } - - if (options?.withFileTypes === true) { - const result = await fs.promises.readdir(path, { withFileTypes: true }); - return Array.isArray(result) ? result : []; - } - const result = await fs.promises.readdir(path); - return Array.isArray(result) ? result : []; - }, - async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { - if (hasPromisesInterface(fsToUse)) { - await fsToUse.promises.rm(path, options); - } else { - await fs.promises.rm(path, options); - } - }, - existsSync(path: string): boolean { - if (hasExistsSyncMethod(fsToUse)) { - return fsToUse.existsSync(path); - } - return fs.existsSync(path); - }, - async stat(path: string): Promise<{ isDirectory(): boolean; mtimeMs: number }> { - if (hasPromisesInterface(fsToUse)) { - const result = await fsToUse.promises.stat(path); - return result as { isDirectory(): boolean; mtimeMs: number }; - } - const result = await fs.promises.stat(path); - return result as { isDirectory(): boolean; mtimeMs: number }; - }, - async mkdtemp(prefix: string): Promise { - if (hasPromisesInterface(fsToUse)) { - return fsToUse.promises.mkdtemp(prefix); - } - return fs.promises.mkdtemp(prefix); - }, - tmpdir(): string { - return '/tmp'; - }, - }; - - const result = await stopDeviceLogSessionById(logSessionId, mockFileSystemExecutor, { - timeoutMs: 1000, - readLogContent: true, - }); - - if (result.error) { - return { logContent: '', error: result.error }; - } - - return { logContent: result.logContent }; -} - -export { stopAllDeviceLogCaptures }; - -export const schema = stopDeviceLogCapSchema.shape; - -export const handler = createTypedTool( - stopDeviceLogCapSchema, - (params: StopDeviceLogCapParams) => { - return stop_device_log_capLogic(params, getDefaultFileSystemExecutor()); - }, - getDefaultCommandExecutor, -); diff --git a/src/mcp/tools/logging/stop_sim_log_cap.ts b/src/mcp/tools/logging/stop_sim_log_cap.ts deleted file mode 100644 index c6995b1d..00000000 --- a/src/mcp/tools/logging/stop_sim_log_cap.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Logging Plugin: Stop Simulator Log Capture - * - * Stops an active simulator log capture session and returns the captured logs. - */ - -import * as z from 'zod'; -import { stopLogCapture as _stopLogCapture } from '../../../utils/log-capture/index.ts'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createTextContent } from '../../../types/common.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; -import type { CommandExecutor } from '../../../utils/command.ts'; -import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/command.ts'; -import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; - -// Define schema as ZodObject -const stopSimLogCapSchema = z.object({ - logSessionId: z.string(), -}); - -// Use z.infer for type safety -type StopSimLogCapParams = z.infer; - -/** - * Business logic for stopping simulator log capture session - */ -export type StopLogCaptureFunction = ( - logSessionId: string, - fileSystem?: FileSystemExecutor, -) => Promise<{ logContent: string; error?: string }>; - -export async function stop_sim_log_capLogic( - params: StopSimLogCapParams, - neverExecutor: CommandExecutor = getDefaultCommandExecutor(), - stopLogCaptureFunction: StopLogCaptureFunction = _stopLogCapture, - fileSystem: FileSystemExecutor = getDefaultFileSystemExecutor(), -): Promise { - const { logContent, error } = await stopLogCaptureFunction(params.logSessionId, fileSystem); - if (error) { - return { - content: [ - createTextContent(`Error stopping log capture session ${params.logSessionId}: ${error}`), - ], - isError: true, - }; - } - return { - content: [ - createTextContent( - `Log capture session ${params.logSessionId} stopped successfully. Log content follows:\n\n${logContent}`, - ), - ], - }; -} - -export const schema = stopSimLogCapSchema.shape; // MCP SDK compatibility - -export const handler = createTypedTool( - stopSimLogCapSchema, - (params: StopSimLogCapParams, executor: CommandExecutor) => - stop_sim_log_capLogic(params, executor), - getDefaultCommandExecutor, -); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts deleted file mode 100644 index 32c9177a..00000000 --- a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * Tests for launch_app_logs_sim plugin (session-aware version) - * Follows CLAUDE.md guidance with literal validation and DI. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import * as z from 'zod'; -import { - schema, - handler, - launch_app_logs_simLogic, - type LogCaptureFunction, -} from '../launch_app_logs_sim.ts'; -import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; -import { sessionStore } from '../../../../utils/session-store.ts'; - -describe('launch_app_logs_sim tool', () => { - beforeEach(() => { - sessionStore.clear(); - }); - - describe('Export Field Validation (Literal)', () => { - it('should expose only non-session fields in public schema', () => { - const schemaObj = z.strictObject(schema); - - expect(schemaObj.safeParse({}).success).toBe(true); - expect(schemaObj.safeParse({ args: ['--debug'] }).success).toBe(true); - expect(schemaObj.safeParse({ bundleId: 'io.sentry.app' }).success).toBe(false); - expect(schemaObj.safeParse({ bundleId: 42 }).success).toBe(false); - - expect(Object.keys(schema).sort()).toEqual(['args', 'env']); - - const withSimId = schemaObj.safeParse({ - simulatorId: 'abc123', - }); - expect(withSimId.success).toBe(false); - }); - }); - - describe('Handler Requirements', () => { - it('should require simulatorId when not provided', async () => { - const result = await handler({}); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Missing required session defaults'); - expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); - expect(result.content[0].text).toContain('session-set-defaults'); - }); - - it('should require bundleId when simulatorId default exists', async () => { - sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); - - const result = await handler({}); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Missing required session defaults'); - expect(result.content[0].text).toContain('bundleId is required'); - }); - }); - - describe('Logic Behavior (Literal Returns)', () => { - it('should handle successful app launch with log capture', async () => { - let capturedParams: unknown = null; - const logCaptureStub: LogCaptureFunction = async (params) => { - capturedParams = params; - return { - sessionId: 'test-session-123', - logFilePath: '/tmp/xcodemcp_sim_log_test-session-123.log', - processes: [], - error: undefined, - }; - }; - - const mockExecutor = createMockExecutor({ success: true, output: '' }); - - const result = await launch_app_logs_simLogic( - { - simulatorId: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - }, - mockExecutor, - logCaptureStub, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'App launched successfully in simulator test-uuid-123 with log capture enabled.\n\nLog capture session ID: test-session-123\n\nInteract with your app in the simulator, then stop capture to retrieve logs.', - }, - ], - nextStepParams: { - stop_sim_log_cap: { logSessionId: 'test-session-123' }, - }, - isError: false, - }); - - expect(capturedParams).toEqual({ - simulatorUuid: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - captureConsole: true, - }); - }); - - it('should include passthrough args in log capture setup', async () => { - let capturedParams: unknown = null; - const logCaptureStub: LogCaptureFunction = async (params) => { - capturedParams = params; - return { - sessionId: 'test-session-456', - logFilePath: '/tmp/xcodemcp_sim_log_test-session-456.log', - processes: [], - error: undefined, - }; - }; - - const mockExecutor = createMockExecutor({ success: true, output: '' }); - - await launch_app_logs_simLogic( - { - simulatorId: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - args: ['--debug'], - }, - mockExecutor, - logCaptureStub, - ); - - expect(capturedParams).toEqual({ - simulatorUuid: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - captureConsole: true, - args: ['--debug'], - }); - }); - - it('should pass env vars through to log capture function', async () => { - let capturedParams: unknown = null; - const logCaptureStub: LogCaptureFunction = async (params) => { - capturedParams = params; - return { - sessionId: 'test-session-789', - logFilePath: '/tmp/xcodemcp_sim_log_test-session-789.log', - processes: [], - error: undefined, - }; - }; - - const mockExecutor = createMockExecutor({ success: true, output: '' }); - - await launch_app_logs_simLogic( - { - simulatorId: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - env: { STAGING_ENABLED: '1' }, - }, - mockExecutor, - logCaptureStub, - ); - - expect(capturedParams).toEqual({ - simulatorUuid: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - captureConsole: true, - env: { STAGING_ENABLED: '1' }, - }); - }); - - it('should not include env in capture params when env is undefined', async () => { - let capturedParams: unknown = null; - const logCaptureStub: LogCaptureFunction = async (params) => { - capturedParams = params; - return { - sessionId: 'test-session-101', - logFilePath: '/tmp/xcodemcp_sim_log_test-session-101.log', - processes: [], - error: undefined, - }; - }; - - const mockExecutor = createMockExecutor({ success: true, output: '' }); - - await launch_app_logs_simLogic( - { - simulatorId: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - }, - mockExecutor, - logCaptureStub, - ); - - expect(capturedParams).toEqual({ - simulatorUuid: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - captureConsole: true, - }); - }); - - it('should surface log capture failure', async () => { - const logCaptureStub: LogCaptureFunction = async () => ({ - sessionId: '', - logFilePath: '', - processes: [], - error: 'Failed to start log capture', - }); - - const mockExecutor = createMockExecutor({ success: true, output: '' }); - - const result = await launch_app_logs_simLogic( - { - simulatorId: 'test-uuid-123', - bundleId: 'io.sentry.testapp', - }, - mockExecutor, - logCaptureStub, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to launch app with log capture: Failed to start log capture', - }, - ], - isError: true, - }); - }); - }); -}); diff --git a/src/mcp/tools/simulator/launch_app_logs_sim.ts b/src/mcp/tools/simulator/launch_app_logs_sim.ts deleted file mode 100644 index 69aac0f0..00000000 --- a/src/mcp/tools/simulator/launch_app_logs_sim.ts +++ /dev/null @@ -1,121 +0,0 @@ -import * as z from 'zod'; -import type { ToolResponse } from '../../../types/common.ts'; -import { createTextContent } from '../../../types/common.ts'; -import { log } from '../../../utils/logging/index.ts'; -import { startLogCapture } from '../../../utils/log-capture/index.ts'; -import type { CommandExecutor } from '../../../utils/execution/index.ts'; -import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { - createSessionAwareTool, - getSessionAwareToolSchemaShape, -} from '../../../utils/typed-tool-factory.ts'; - -export type LogCaptureFunction = ( - params: { - simulatorUuid: string; - bundleId: string; - captureConsole?: boolean; - args?: string[]; - env?: Record; - }, - executor: CommandExecutor, -) => Promise<{ sessionId: string; logFilePath: string; processes: unknown[]; error?: string }>; - -const baseSchemaObject = z.object({ - simulatorId: z - .string() - .optional() - .describe( - 'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both', - ), - simulatorName: z - .string() - .optional() - .describe( - "Name of the simulator (e.g., 'iPhone 17'). Provide EITHER this OR simulatorId, not both", - ), - bundleId: z.string().describe('Bundle identifier of the app to launch'), - args: z.array(z.string()).optional().describe('Optional arguments to pass to the app'), - env: z - .record(z.string(), z.string()) - .optional() - .describe( - 'Environment variables to pass to the launched app (SIMCTL_CHILD_ prefix added automatically)', - ), -}); - -// Internal schema requires simulatorId (factory resolves simulatorName → simulatorId) -const internalSchemaObject = z.object({ - simulatorId: z.string(), - simulatorName: z.string().optional(), - bundleId: z.string(), - args: z.array(z.string()).optional(), - env: z - .record(z.string(), z.string()) - .optional() - .describe( - 'Environment variables to pass to the launched app (SIMCTL_CHILD_ prefix added automatically)', - ), -}); - -type LaunchAppLogsSimParams = z.infer; - -const publicSchemaObject = z.strictObject( - baseSchemaObject.omit({ - simulatorId: true, - simulatorName: true, - bundleId: true, - } as const).shape, -); - -export async function launch_app_logs_simLogic( - params: LaunchAppLogsSimParams, - executor: CommandExecutor = getDefaultCommandExecutor(), - logCaptureFunction: LogCaptureFunction = startLogCapture, -): Promise { - log('info', `Starting app launch with logs for simulator ${params.simulatorId}`); - - const captureParams = { - simulatorUuid: params.simulatorId, - bundleId: params.bundleId, - captureConsole: true, - args: params.args?.length ? params.args : undefined, - env: params.env, - }; - - const { sessionId, error } = await logCaptureFunction(captureParams, executor); - if (error) { - return { - content: [createTextContent(`Failed to launch app with log capture: ${error}`)], - isError: true, - }; - } - - return { - content: [ - createTextContent( - `App launched successfully in simulator ${params.simulatorId} with log capture enabled.\n\nLog capture session ID: ${sessionId}\n\nInteract with your app in the simulator, then stop capture to retrieve logs.`, - ), - ], - nextStepParams: { - stop_sim_log_cap: { logSessionId: sessionId }, - }, - isError: false, - }; -} - -export const schema = getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, -}); - -export const handler = createSessionAwareTool({ - internalSchema: internalSchemaObject as unknown as z.ZodType, - logicFunction: launch_app_logs_simLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, - { allOf: ['bundleId'], message: 'bundleId is required' }, - ], - exclusivePairs: [['simulatorId', 'simulatorName']], -}); diff --git a/src/smoke-tests/__tests__/e2e-mcp-logging.test.ts b/src/smoke-tests/__tests__/e2e-mcp-logging.test.ts deleted file mode 100644 index 3f967af6..00000000 --- a/src/smoke-tests/__tests__/e2e-mcp-logging.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { createMcpTestHarness, type McpTestHarness } from '../mcp-test-harness.ts'; -import { isErrorResponse, expectContent } from '../test-helpers.ts'; - -let harness: McpTestHarness; - -beforeAll(async () => { - harness = await createMcpTestHarness({ - commandResponses: { - 'simctl spawn': { success: true, output: '' }, - 'log collect': { success: true, output: 'Log captured' }, - devicectl: { success: true, output: '{}' }, - xcrun: { success: true, output: '' }, - }, - }); -}, 30_000); - -afterAll(async () => { - await harness.cleanup(); -}); - -describe('MCP Logging Tools (e2e)', () => { - it('start_sim_log_cap requires simulatorId and bundleId via session', async () => { - await harness.client.callTool({ - name: 'session_set_defaults', - arguments: { - simulatorId: 'AAAAAAAA-1111-2222-3333-444444444444', - bundleId: 'io.sentry.TestApp', - }, - }); - - harness.resetCapturedCommands(); - const result = await harness.client.callTool({ - name: 'start_sim_log_cap', - arguments: {}, - }); - - expectContent(result); - }); - - it('stop_sim_log_cap returns error for unknown session', async () => { - harness.resetCapturedCommands(); - const result = await harness.client.callTool({ - name: 'stop_sim_log_cap', - arguments: { - logSessionId: 'nonexistent-session-id', - }, - }); - - expectContent(result); - expect(isErrorResponse(result)).toBe(true); - }); - - it('start_device_log_cap requires deviceId and bundleId via session', async () => { - await harness.client.callTool({ - name: 'session_set_defaults', - arguments: { - deviceId: 'BBBBBBBB-1111-2222-3333-444444444444', - bundleId: 'io.sentry.TestApp', - }, - }); - - harness.resetCapturedCommands(); - const result = await harness.client.callTool({ - name: 'start_device_log_cap', - arguments: {}, - }); - - expectContent(result); - }); - - it('stop_device_log_cap returns error for unknown session', async () => { - harness.resetCapturedCommands(); - const result = await harness.client.callTool({ - name: 'stop_device_log_cap', - arguments: { - logSessionId: 'nonexistent-device-session-id', - }, - }); - - expectContent(result); - expect(isErrorResponse(result)).toBe(true); - }); -});