diff --git a/.vscode/settings.json b/.vscode/settings.json index 93934668..dada907e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,6 @@ "javascript", "typescript" ], - "eslint.runtime": "/opt/homebrew/bin/node", "eslint.format.enable": true, "eslint.nodePath": "${workspaceFolder}/node_modules", "eslint.workingDirectories": [ @@ -29,19 +28,15 @@ "editor.defaultFormatter": "vscode.typescript-language-features" }, "terminal.integrated.shellIntegration.decorationsEnabled": "never", - "vitest.nodeExecutable": "/opt/homebrew/bin/node", "[json]": { "editor.defaultFormatter": "vscode.json-language-features" }, "[jsonc]": { "editor.defaultFormatter": "vscode.json-language-features" }, - "chat.mcp.serverSampling": { - "XcodeBuildMCP/.vscode/mcp.json: XcodeBuildMCP-Dev": { - "allowedDuringChat": true, - "allowedModels": [ - "copilot/gpt-5.2" - ] - } - }, + "vitest.shellType": "child_process", + "vitest.nodeExecutable": "/usr/bin/env", + "vitest.nodeExecArgs": [ + "node" + ] } \ No newline at end of file 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 index 874a4f4e..3feff08c 100644 --- a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts @@ -23,7 +23,7 @@ describe('start_sim_log_cap plugin', () => { it('should have correct description', () => { expect(plugin.description).toBe( - 'Starts capturing logs from a specified simulator. Returns a session ID. By default, captures only structured logs.', + "Starts capturing logs from a specified simulator. Returns a session ID. Use subsystemFilter to control what logs are captured: 'app' (default), 'all' (everything), 'swiftui' (includes Self._printChanges()), or custom subsystems.", ); }); @@ -42,6 +42,41 @@ describe('start_sim_log_cap plugin', () => { ); }); + it('should validate schema with subsystemFilter parameter', () => { + const schema = z.object(plugin.schema); + // Valid enum values + expect( + schema.safeParse({ bundleId: 'com.example.app', subsystemFilter: 'app' }).success, + ).toBe(true); + expect( + schema.safeParse({ bundleId: 'com.example.app', subsystemFilter: 'all' }).success, + ).toBe(true); + expect( + schema.safeParse({ bundleId: 'com.example.app', subsystemFilter: 'swiftui' }).success, + ).toBe(true); + // Valid array of subsystems + expect( + schema.safeParse({ bundleId: 'com.example.app', subsystemFilter: ['com.apple.UIKit'] }) + .success, + ).toBe(true); + expect( + schema.safeParse({ + bundleId: 'com.example.app', + subsystemFilter: ['com.apple.UIKit', 'com.apple.CoreData'], + }).success, + ).toBe(true); + // Invalid values + expect(schema.safeParse({ bundleId: 'com.example.app', subsystemFilter: [] }).success).toBe( + false, + ); + expect( + schema.safeParse({ bundleId: 'com.example.app', subsystemFilter: 'invalid' }).success, + ).toBe(false); + expect(schema.safeParse({ bundleId: 'com.example.app', subsystemFilter: 123 }).success).toBe( + false, + ); + }); + it('should reject invalid schema parameters', () => { const schema = z.object(plugin.schema); expect(schema.safeParse({ bundleId: null }).success).toBe(false); @@ -79,6 +114,7 @@ describe('start_sim_log_cap plugin', () => { { simulatorId: 'test-uuid', bundleId: 'com.example.app', + subsystemFilter: 'app', }, mockExecutor, logCaptureStub, @@ -103,6 +139,7 @@ describe('start_sim_log_cap plugin', () => { { simulatorId: 'test-uuid', bundleId: 'com.example.app', + subsystemFilter: 'app', }, mockExecutor, logCaptureStub, @@ -110,8 +147,85 @@ describe('start_sim_log_cap plugin', () => { expect(result.isError).toBeUndefined(); expect(result.content[0].text).toBe( - "Log capture started successfully. Session ID: test-uuid-123.\n\nNote: Only structured logs are being captured.\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID 'test-uuid-123' to stop capture and retrieve logs.", + "Log capture started successfully. Session ID: test-uuid-123.\n\nOnly structured logs from the app subsystem are being captured.\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID 'test-uuid-123' to stop capture and retrieve logs.", + ); + }); + + 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: 'com.example.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: 'com.example.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: 'com.example.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 () => { @@ -130,14 +244,14 @@ describe('start_sim_log_cap plugin', () => { simulatorId: 'test-uuid', bundleId: 'com.example.app', captureConsole: true, + subsystemFilter: 'app', }, mockExecutor, logCaptureStub, ); - expect(result.content[0].text).toBe( - "Log capture started successfully. Session ID: test-uuid-123.\n\nNote: Your app was relaunched to capture console output.\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID 'test-uuid-123' to stop capture and retrieve logs.", - ); + 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 () => { @@ -190,6 +304,7 @@ describe('start_sim_log_cap plugin', () => { simulatorId: 'test-uuid', bundleId: 'com.example.app', captureConsole: true, + subsystemFilter: 'app', }, mockExecutor, logCaptureStub, @@ -259,6 +374,7 @@ describe('start_sim_log_cap plugin', () => { simulatorId: 'test-uuid', bundleId: 'com.example.app', captureConsole: false, + subsystemFilter: 'app', }, mockExecutor, logCaptureStub, 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 index 252d1410..34f925f3 100644 --- a/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts @@ -11,47 +11,17 @@ * Converted to pure dependency injection without vitest mocking. */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import stopSimLogCap, { stop_sim_log_capLogic } from '../stop_sim_log_cap.ts'; -import { activeLogSessions } from '../../../../utils/log_capture.ts'; -import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; +import { + createMockExecutor, + createMockFileSystemExecutor, +} from '../../../../test-utils/mock-executors.ts'; describe('stop_sim_log_cap plugin', () => { - beforeEach(() => { - // Clear any active sessions before each test - activeLogSessions.clear(); - }); - - // Helper function to create a test log session - async function createTestLogSession(sessionId: string, logContent: string = '') { - const mockProcess = { - pid: 12345, - killed: false, - exitCode: null, - kill: () => {}, - }; - - const logFilePath = `/tmp/xcodemcp_sim_log_test_${sessionId}.log`; - const fileSystem = createMockFileSystemExecutor({ - existsSync: (path) => path === logFilePath, - readFile: async (path, _encoding) => { - if (path !== logFilePath) { - throw new Error(`ENOENT: no such file or directory, open '${path}'`); - } - return logContent; - }, - }); - - activeLogSessions.set(sessionId, { - processes: [mockProcess as any], - logFilePath: logFilePath, - simulatorUuid: 'test-simulator-uuid', - bundleId: 'com.example.TestApp', - }); - - return { fileSystem, logFilePath }; - } + const mockExecutor = createMockExecutor({ success: true, output: '' }); + const mockFileSystem = createMockFileSystemExecutor(); describe('Export Field Validation (Literal)', () => { it('should have correct plugin structure', () => { @@ -95,13 +65,18 @@ describe('stop_sim_log_cap plugin', () => { 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 { fileSystem } = await createTestLogSession('', 'Log content for empty session'); + const stopLogCaptureStub = async () => ({ + logContent: 'Log content for empty session', + error: undefined, + }); const result = await stop_sim_log_capLogic( { logSessionId: '', }, - fileSystem, + mockExecutor, + stopLogCaptureStub, + mockFileSystem, ); expect(result.isError).toBeUndefined(); @@ -113,13 +88,18 @@ describe('stop_sim_log_cap plugin', () => { 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 { fileSystem } = await createTestLogSession('', 'Log content for empty session'); + const stopLogCaptureStub = async () => ({ + logContent: 'Log content for empty session', + error: undefined, + }); const result = await stop_sim_log_capLogic( { logSessionId: '', }, - fileSystem, + mockExecutor, + stopLogCaptureStub, + mockFileSystem, ); expect(result.isError).toBeUndefined(); @@ -129,13 +109,18 @@ describe('stop_sim_log_cap plugin', () => { }); it('should handle empty string logSessionId', async () => { - const { fileSystem } = await createTestLogSession('', 'Log content for empty session'); + const stopLogCaptureStub = async () => ({ + logContent: 'Log content for empty session', + error: undefined, + }); const result = await stop_sim_log_capLogic( { logSessionId: '', }, - fileSystem, + mockExecutor, + stopLogCaptureStub, + mockFileSystem, ); expect(result.isError).toBeUndefined(); @@ -147,18 +132,22 @@ describe('stop_sim_log_cap plugin', () => { describe('Function Call Generation', () => { it('should call stopLogCapture with correct parameters', async () => { - const { fileSystem } = await createTestLogSession( - 'test-session-id', - 'Mock log content from file', - ); + 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', }, - fileSystem, + 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', @@ -166,18 +155,22 @@ describe('stop_sim_log_cap plugin', () => { }); it('should call stopLogCapture with different session ID', async () => { - const { fileSystem } = await createTestLogSession( - 'different-session-id', - 'Different log content', - ); + 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', }, - fileSystem, + 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', @@ -187,16 +180,18 @@ describe('stop_sim_log_cap plugin', () => { describe('Response Processing', () => { it('should handle successful log capture stop', async () => { - const { fileSystem } = await createTestLogSession( - 'test-session-id', - 'Mock log content from file', - ); + const stopLogCaptureStub = async () => ({ + logContent: 'Mock log content from file', + error: undefined, + }); const result = await stop_sim_log_capLogic( { logSessionId: 'test-session-id', }, - fileSystem, + mockExecutor, + stopLogCaptureStub, + mockFileSystem, ); expect(result.isError).toBeUndefined(); @@ -206,13 +201,18 @@ describe('stop_sim_log_cap plugin', () => { }); it('should handle empty log content', async () => { - const { fileSystem } = await createTestLogSession('test-session-id', ''); + const stopLogCaptureStub = async () => ({ + logContent: '', + error: undefined, + }); const result = await stop_sim_log_capLogic( { logSessionId: 'test-session-id', }, - fileSystem, + mockExecutor, + stopLogCaptureStub, + mockFileSystem, ); expect(result.isError).toBeUndefined(); @@ -222,16 +222,18 @@ describe('stop_sim_log_cap plugin', () => { }); it('should handle multiline log content', async () => { - const { fileSystem } = await createTestLogSession( - 'test-session-id', - 'Line 1\nLine 2\nLine 3', - ); + const stopLogCaptureStub = async () => ({ + logContent: 'Line 1\nLine 2\nLine 3', + error: undefined, + }); const result = await stop_sim_log_capLogic( { logSessionId: 'test-session-id', }, - fileSystem, + mockExecutor, + stopLogCaptureStub, + mockFileSystem, ); expect(result.isError).toBeUndefined(); @@ -241,11 +243,18 @@ describe('stop_sim_log_cap plugin', () => { }); 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', }, - createMockFileSystemExecutor(), + mockExecutor, + stopLogCaptureStub, + mockFileSystem, ); expect(result.isError).toBe(true); @@ -255,26 +264,18 @@ describe('stop_sim_log_cap plugin', () => { }); it('should handle file read errors', async () => { - // Create session but make file reading fail in the log_capture utility - const mockProcess = { - pid: 12345, - killed: false, - exitCode: null, - kill: () => {}, - }; - - activeLogSessions.set('test-session-id', { - processes: [mockProcess as any], - logFilePath: `/tmp/test_file_not_found.log`, - simulatorUuid: 'test-simulator-uuid', - bundleId: 'com.example.TestApp', + const stopLogCaptureStub = async () => ({ + logContent: '', + error: 'ENOENT: no such file or directory', }); const result = await stop_sim_log_capLogic( - { logSessionId: 'test-session-id' }, - createMockFileSystemExecutor({ - existsSync: () => false, - }), + { + logSessionId: 'test-session-id', + }, + mockExecutor, + stopLogCaptureStub, + mockFileSystem, ); expect(result.isError).toBe(true); @@ -284,29 +285,18 @@ describe('stop_sim_log_cap plugin', () => { }); it('should handle permission errors', async () => { - // Create session but make file reading fail in the log_capture utility - const mockProcess = { - pid: 12345, - killed: false, - exitCode: null, - kill: () => {}, - }; - - activeLogSessions.set('test-session-id', { - processes: [mockProcess as any], - logFilePath: `/tmp/test_permission_denied.log`, - simulatorUuid: 'test-simulator-uuid', - bundleId: 'com.example.TestApp', + const stopLogCaptureStub = async () => ({ + logContent: '', + error: 'EACCES: permission denied', }); const result = await stop_sim_log_capLogic( - { logSessionId: 'test-session-id' }, - createMockFileSystemExecutor({ - existsSync: () => true, - readFile: async () => { - throw new Error('Permission denied'); - }, - }), + { + logSessionId: 'test-session-id', + }, + mockExecutor, + stopLogCaptureStub, + mockFileSystem, ); expect(result.isError).toBe(true); @@ -316,29 +306,18 @@ describe('stop_sim_log_cap plugin', () => { }); it('should handle various error types', async () => { - // Create session but make file reading fail in the log_capture utility - const mockProcess = { - pid: 12345, - killed: false, - exitCode: null, - kill: () => {}, - }; - - activeLogSessions.set('test-session-id', { - processes: [mockProcess as any], - logFilePath: `/tmp/test_generic_error.log`, - simulatorUuid: 'test-simulator-uuid', - bundleId: 'com.example.TestApp', + const stopLogCaptureStub = async () => ({ + logContent: '', + error: 'Unexpected error', }); const result = await stop_sim_log_capLogic( - { logSessionId: 'test-session-id' }, - createMockFileSystemExecutor({ - existsSync: () => true, - readFile: async () => { - throw new Error('Something went wrong'); - }, - }), + { + logSessionId: 'test-session-id', + }, + mockExecutor, + stopLogCaptureStub, + mockFileSystem, ); expect(result.isError).toBe(true); diff --git a/src/mcp/tools/logging/start_sim_log_cap.ts b/src/mcp/tools/logging/start_sim_log_cap.ts index 04022e45..0ae9628d 100644 --- a/src/mcp/tools/logging/start_sim_log_cap.ts +++ b/src/mcp/tools/logging/start_sim_log_cap.ts @@ -8,6 +8,7 @@ import * as z from 'zod'; import { startLogCapture } from '../../../utils/log-capture/index.ts'; import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; import { ToolResponse, createTextContent } from '../../../types/common.ts'; +import type { SubsystemFilter } from '../../../utils/log_capture.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, @@ -23,35 +24,61 @@ const startSimLogCapSchema = z.object({ .boolean() .optional() .describe('Whether to capture console output (requires app relaunch).'), + subsystemFilter: z + .union([z.enum(['app', 'all', 'swiftui']), z.array(z.string()).min(1)]) + .default('app') + .describe( + "Controls which log subsystems to capture. Options: 'app' (default, only app logs), 'all' (capture all system logs), 'swiftui' (app + SwiftUI logs for Self._printChanges()), or an array of custom subsystem strings.", + ), }); // 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 { sessionId, error } = await logCaptureFunction( - { - simulatorUuid: params.simulatorId, - bundleId: params.bundleId, - captureConsole, - }, - _executor, - ); + 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.' : 'Note: Only structured logs are being captured.'}\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID '${sessionId}' to stop capture and retrieve logs.`, + `Log capture started successfully. Session ID: ${sessionId}.\n\n${captureConsole ? 'Note: Your app was relaunched to capture console output.\n' : ''}${filterDescription}\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID '${sessionId}' to stop capture and retrieve logs.`, ), ], }; @@ -64,7 +91,7 @@ const publicSchemaObject = z.strictObject( export default { name: 'start_sim_log_cap', description: - 'Starts capturing logs from a specified simulator. Returns a session ID. By default, captures only structured logs.', + "Starts capturing logs from a specified simulator. Returns a session ID. Use subsystemFilter to control what logs are captured: 'app' (default), 'all' (everything), 'swiftui' (includes Self._printChanges()), or custom subsystems.", schema: getSessionAwareToolSchemaShape({ sessionAware: publicSchemaObject, legacy: startSimLogCapSchema, diff --git a/src/mcp/tools/logging/stop_sim_log_cap.ts b/src/mcp/tools/logging/stop_sim_log_cap.ts index 9c662e0d..a143ba80 100644 --- a/src/mcp/tools/logging/stop_sim_log_cap.ts +++ b/src/mcp/tools/logging/stop_sim_log_cap.ts @@ -8,8 +8,9 @@ import * as z from 'zod'; import { stopLogCapture as _stopLogCapture } from '../../../utils/log-capture/index.ts'; import { ToolResponse, 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 { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; +import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; // Define schema as ZodObject const stopSimLogCapSchema = z.object({ @@ -22,11 +23,18 @@ 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, - fileSystem: FileSystemExecutor, + neverExecutor: CommandExecutor = getDefaultCommandExecutor(), + stopLogCaptureFunction: StopLogCaptureFunction = _stopLogCapture, + fileSystem: FileSystemExecutor = getDefaultFileSystemExecutor(), ): Promise { - const { logContent, error } = await _stopLogCapture(params.logSessionId, fileSystem); + const { logContent, error } = await stopLogCaptureFunction(params.logSessionId, fileSystem); if (error) { return { content: [ @@ -54,7 +62,8 @@ export default { }, handler: createTypedTool( stopSimLogCapSchema, - (params: StopSimLogCapParams) => stop_sim_log_capLogic(params, getDefaultFileSystemExecutor()), + (params: StopSimLogCapParams, executor: CommandExecutor) => + stop_sim_log_capLogic(params, executor), getDefaultCommandExecutor, ), }; diff --git a/src/utils/__tests__/log_capture.test.ts b/src/utils/__tests__/log_capture.test.ts new file mode 100644 index 00000000..77e2fec7 --- /dev/null +++ b/src/utils/__tests__/log_capture.test.ts @@ -0,0 +1,315 @@ +import { ChildProcess } from 'child_process'; +import { Writable } from 'stream'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + activeLogSessions, + startLogCapture, + stopLogCapture, + type SubsystemFilter, +} from '../log_capture.ts'; +import { CommandExecutor } from '../CommandExecutor.ts'; +import { FileSystemExecutor } from '../FileSystemExecutor.ts'; + +type CallHistoryEntry = { + command: string[]; + logPrefix?: string; + useShell?: boolean; + opts?: { env?: Record; cwd?: string }; + detached?: boolean; +}; + +function createMockExecutorWithCalls(callHistory: CallHistoryEntry[]): CommandExecutor { + const mockProcess: Partial = {}; + Object.assign(mockProcess, { + pid: 12345, + stdout: null, + stderr: null, + killed: false, + exitCode: null, + on: () => mockProcess, + }); + + return async (command, logPrefix, useShell, opts, detached) => { + callHistory.push({ command, logPrefix, useShell, opts, detached }); + return { success: true, output: '', process: mockProcess as ChildProcess }; + }; +} + +function expectPredicate( + call: CallHistoryEntry, + bundleId: string, + subsystemFilter: SubsystemFilter, +): void { + const predicateIndex = call.command.indexOf('--predicate'); + expect(predicateIndex).toBeGreaterThan(-1); + const predicate = call.command[predicateIndex + 1]; + + switch (subsystemFilter) { + case 'app': + expect(predicate).toBe(`subsystem == "${bundleId}"`); + return; + case 'swiftui': + expect(predicate).toBe(`subsystem == "${bundleId}" OR subsystem == "com.apple.SwiftUI"`); + return; + default: { + const subsystems = [bundleId, ...subsystemFilter]; + const expected = subsystems.map((s) => `subsystem == "${s}"`).join(' OR '); + expect(predicate).toBe(expected); + } + } +} + +type InMemoryFileRecord = { content: string; mtimeMs: number }; + +function createInMemoryFileSystemExecutor(): FileSystemExecutor { + const files = new Map(); + const tempDir = '/virtual/tmp'; + + return { + mkdir: async () => {}, + readFile: async (path) => { + const record = files.get(path); + if (!record) { + throw new Error(`Missing file: ${path}`); + } + return record.content; + }, + writeFile: async (path, content) => { + files.set(path, { content, mtimeMs: Date.now() }); + }, + createWriteStream: (path) => { + const chunks: Buffer[] = []; + + const stream = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(Buffer.from(chunk)); + callback(); + }, + final(callback) { + const existing = files.get(path)?.content ?? ''; + files.set(path, { + content: existing + Buffer.concat(chunks).toString('utf8'), + mtimeMs: Date.now(), + }); + callback(); + }, + }); + + return stream as unknown as ReturnType; + }, + cp: async () => {}, + readdir: async (dir) => { + const prefix = `${dir}/`; + return Array.from(files.keys()) + .filter((filePath) => filePath.startsWith(prefix)) + .map((filePath) => filePath.slice(prefix.length)); + }, + stat: async (path) => { + const record = files.get(path); + if (!record) { + throw new Error(`Missing file: ${path}`); + } + return { isDirectory: (): boolean => false, mtimeMs: record.mtimeMs }; + }, + rm: async (path) => { + files.delete(path); + }, + existsSync: (path) => files.has(path), + mkdtemp: async (prefix) => `${tempDir}/${prefix}mock-temp`, + tmpdir: () => tempDir, + }; +} + +beforeEach(() => { + activeLogSessions.clear(); +}); + +afterEach(() => { + activeLogSessions.clear(); +}); + +describe('startLogCapture', () => { + it('creates log stream command with app subsystem by default', async () => { + const callHistory: CallHistoryEntry[] = []; + const executor = createMockExecutorWithCalls(callHistory); + const fileSystem = createInMemoryFileSystemExecutor(); + + const result = await startLogCapture( + { simulatorUuid: 'sim-uuid', bundleId: 'com.example.app' }, + executor, + fileSystem, + ); + + expect(result.error).toBeUndefined(); + expect(callHistory).toHaveLength(1); + expect(callHistory[0].command).toEqual([ + 'xcrun', + 'simctl', + 'spawn', + 'sim-uuid', + 'log', + 'stream', + '--level=debug', + '--predicate', + 'subsystem == "com.example.app"', + ]); + expect(callHistory[0].logPrefix).toBe('OS Log Capture'); + expect(callHistory[0].useShell).toBe(false); + expect(callHistory[0].detached).toBe(true); + }); + + it('creates log stream command without predicate when filter is all', async () => { + const callHistory: CallHistoryEntry[] = []; + const executor = createMockExecutorWithCalls(callHistory); + const fileSystem = createInMemoryFileSystemExecutor(); + + const result = await startLogCapture( + { + simulatorUuid: 'sim-uuid', + bundleId: 'com.example.app', + subsystemFilter: 'all', + }, + executor, + fileSystem, + ); + + expect(result.error).toBeUndefined(); + expect(callHistory).toHaveLength(1); + expect(callHistory[0].command).toEqual([ + 'xcrun', + 'simctl', + 'spawn', + 'sim-uuid', + 'log', + 'stream', + '--level=debug', + ]); + }); + + it('creates log stream command with SwiftUI predicate', async () => { + const callHistory: CallHistoryEntry[] = []; + const executor = createMockExecutorWithCalls(callHistory); + const fileSystem = createInMemoryFileSystemExecutor(); + + const result = await startLogCapture( + { + simulatorUuid: 'sim-uuid', + bundleId: 'com.example.app', + subsystemFilter: 'swiftui', + }, + executor, + fileSystem, + ); + + expect(result.error).toBeUndefined(); + expect(callHistory).toHaveLength(1); + expectPredicate(callHistory[0], 'com.example.app', 'swiftui'); + }); + + it('creates log stream command with custom subsystem predicate', async () => { + const callHistory: CallHistoryEntry[] = []; + const executor = createMockExecutorWithCalls(callHistory); + const fileSystem = createInMemoryFileSystemExecutor(); + + const result = await startLogCapture( + { + simulatorUuid: 'sim-uuid', + bundleId: 'com.example.app', + subsystemFilter: ['com.apple.UIKit', 'com.apple.CoreData'], + }, + executor, + fileSystem, + ); + + expect(result.error).toBeUndefined(); + expect(callHistory).toHaveLength(1); + expectPredicate(callHistory[0], 'com.example.app', ['com.apple.UIKit', 'com.apple.CoreData']); + }); + + it('creates console capture and log stream commands when captureConsole is true', async () => { + const callHistory: CallHistoryEntry[] = []; + const executor = createMockExecutorWithCalls(callHistory); + const fileSystem = createInMemoryFileSystemExecutor(); + + const result = await startLogCapture( + { + simulatorUuid: 'sim-uuid', + bundleId: 'com.example.app', + captureConsole: true, + args: ['--flag', 'value'], + }, + executor, + fileSystem, + ); + + expect(result.error).toBeUndefined(); + expect(callHistory).toHaveLength(2); + expect(callHistory[0].command).toEqual([ + 'xcrun', + 'simctl', + 'launch', + '--console-pty', + '--terminate-running-process', + 'sim-uuid', + 'com.example.app', + '--flag', + 'value', + ]); + expect(callHistory[0].logPrefix).toBe('Console Log Capture'); + expect(callHistory[0].useShell).toBe(false); + expect(callHistory[0].detached).toBe(true); + + expect(callHistory[1].logPrefix).toBe('OS Log Capture'); + expect(callHistory[1].useShell).toBe(false); + expect(callHistory[1].detached).toBe(true); + }); +}); + +describe('stopLogCapture', () => { + it('returns error when session is missing', async () => { + const fileSystem = createInMemoryFileSystemExecutor(); + const result = await stopLogCapture('missing-session', fileSystem); + + expect(result.logContent).toBe(''); + expect(result.error).toBe('Log capture session not found: missing-session'); + }); + + it('kills active processes and returns log content', async () => { + const fileSystem = createInMemoryFileSystemExecutor(); + const logFilePath = `${fileSystem.tmpdir()}/session.log`; + await fileSystem.writeFile(logFilePath, 'test log content'); + const logStream = fileSystem.createWriteStream(logFilePath, { flags: 'a' }); + + let killCount = 0; + const runningProcess = { + killed: false, + exitCode: null, + kill: () => { + killCount += 1; + }, + } as unknown as ChildProcess; + + const finishedProcess = { + killed: false, + exitCode: 0, + kill: () => { + killCount += 1; + }, + } as unknown as ChildProcess; + + activeLogSessions.set('session-1', { + processes: [runningProcess, finishedProcess], + logFilePath, + simulatorUuid: 'sim-uuid', + bundleId: 'com.example.app', + logStream, + }); + + const result = await stopLogCapture('session-1', fileSystem); + + expect(result.error).toBeUndefined(); + expect(result.logContent).toBe('test log content'); + expect(killCount).toBe(1); + expect(activeLogSessions.has('session-1')).toBe(false); + }); +}); diff --git a/src/utils/log-capture/index.ts b/src/utils/log-capture/index.ts index 06cde410..2a25b5c3 100644 --- a/src/utils/log-capture/index.ts +++ b/src/utils/log-capture/index.ts @@ -1,5 +1,7 @@ import { activeLogSessions, startLogCapture, stopLogCapture } from '../log_capture.ts'; +export type { SubsystemFilter } from '../log_capture.ts'; + export function listActiveSimulatorLogSessionIds(): string[] { return Array.from(activeLogSessions.keys()).sort(); } diff --git a/src/utils/log_capture.ts b/src/utils/log_capture.ts index dfc602df..daece866 100644 --- a/src/utils/log_capture.ts +++ b/src/utils/log_capture.ts @@ -1,5 +1,7 @@ import * as path from 'path'; import type { ChildProcess } from 'child_process'; +import type { Writable } from 'stream'; +import { finished } from 'stream/promises'; import { v4 as uuidv4 } from 'uuid'; import { log } from '../utils/logger.ts'; import { @@ -22,6 +24,40 @@ export interface LogSession { logFilePath: string; simulatorUuid: string; bundleId: string; + logStream: Writable; +} + +/** + * Subsystem filter options for log capture. + * - 'app': Only capture logs from the app's bundle ID subsystem (default) + * - 'all': Capture all logs (no subsystem filtering) + * - 'swiftui': Capture logs from app + SwiftUI subsystem (useful for Self._printChanges()) + * - string[]: Custom array of subsystems to capture (always includes the app's bundle ID) + */ +export type SubsystemFilter = 'app' | 'all' | 'swiftui' | string[]; + +/** + * Build the predicate string for log filtering based on subsystem filter option. + */ +function buildLogPredicate(bundleId: string, subsystemFilter: SubsystemFilter): string | null { + if (subsystemFilter === 'all') { + // No filtering - capture everything from this process + return null; + } + + if (subsystemFilter === 'app') { + return `subsystem == "${bundleId}"`; + } + + if (subsystemFilter === 'swiftui') { + // Include both app logs and SwiftUI logs (for Self._printChanges()) + return `subsystem == "${bundleId}" OR subsystem == "com.apple.SwiftUI"`; + } + + // Custom array of subsystems - always include the app's bundle ID + const subsystems = new Set([bundleId, ...subsystemFilter]); + const predicates = Array.from(subsystems).map((s) => `subsystem == "${s}"`); + return predicates.join(' OR '); } export const activeLogSessions: Map = new Map(); @@ -36,6 +72,7 @@ export async function startLogCapture( bundleId: string; captureConsole?: boolean; args?: string[]; + subsystemFilter?: SubsystemFilter; }, executor: CommandExecutor = getDefaultCommandExecutor(), fileSystem: FileSystemExecutor = getDefaultFileSystemExecutor(), @@ -43,16 +80,54 @@ export async function startLogCapture( // Clean up old logs before starting a new session await cleanOldLogs(fileSystem); - const { simulatorUuid, bundleId, captureConsole = false, args = [] } = params; + const { + simulatorUuid, + bundleId, + captureConsole = false, + args = [], + subsystemFilter = 'app', + } = params; const logSessionId = uuidv4(); const logFileName = `${LOG_FILE_PREFIX}${logSessionId}.log`; const logFilePath = path.join(fileSystem.tmpdir(), logFileName); + let logStream: Writable | null = null; + const processes: ChildProcess[] = []; + const closeFailedCapture = async (): Promise => { + for (const process of processes) { + try { + if (!process.killed && process.exitCode === null) { + process.kill('SIGTERM'); + } + } catch (error) { + log( + 'warn', + `Failed to stop log capture process during cleanup: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + if (logStream) { + logStream.end(); + try { + await finished(logStream); + } catch (error) { + log( + 'warn', + `Failed to flush log stream during cleanup: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + }; + try { await fileSystem.mkdir(fileSystem.tmpdir(), { recursive: true }); await fileSystem.writeFile(logFilePath, ''); - const logStream = fileSystem.createWriteStream(logFilePath, { flags: 'a' }); - const processes: ChildProcess[] = []; + logStream = fileSystem.createWriteStream(logFilePath, { flags: 'a' }); logStream.write('\n--- Log capture for bundle ID: ' + bundleId + ' ---\n'); if (captureConsole) { @@ -72,12 +147,13 @@ export async function startLogCapture( const stdoutLogResult = await executor( launchCommand, 'Console Log Capture', - true, // useShell + false, // useShell undefined, // env true, // detached - don't wait for this streaming process to complete ); if (!stdoutLogResult.success) { + await closeFailedCapture(); return { sessionId: '', logFilePath: '', @@ -86,30 +162,38 @@ export async function startLogCapture( }; } - stdoutLogResult.process.stdout?.pipe(logStream); - stdoutLogResult.process.stderr?.pipe(logStream); + stdoutLogResult.process.stdout?.pipe(logStream, { end: false }); + stdoutLogResult.process.stderr?.pipe(logStream, { end: false }); processes.push(stdoutLogResult.process); } + // Build the log stream command based on subsystem filter + const logPredicate = buildLogPredicate(bundleId, subsystemFilter); + const osLogCommand = [ + 'xcrun', + 'simctl', + 'spawn', + simulatorUuid, + 'log', + 'stream', + '--level=debug', + ]; + + // Only add predicate if filtering is needed + if (logPredicate) { + osLogCommand.push('--predicate', logPredicate); + } + const osLogResult = await executor( - [ - 'xcrun', - 'simctl', - 'spawn', - simulatorUuid, - 'log', - 'stream', - '--level=debug', - '--predicate', - `subsystem == "${bundleId}"`, - ], + osLogCommand, 'OS Log Capture', - true, // useShell + false, // useShell undefined, // env true, // detached - don't wait for this streaming process to complete ); if (!osLogResult.success) { + await closeFailedCapture(); return { sessionId: '', logFilePath: '', @@ -118,8 +202,8 @@ export async function startLogCapture( }; } - osLogResult.process.stdout?.pipe(logStream); - osLogResult.process.stderr?.pipe(logStream); + osLogResult.process.stdout?.pipe(logStream, { end: false }); + osLogResult.process.stderr?.pipe(logStream, { end: false }); processes.push(osLogResult.process); for (const process of processes) { @@ -133,11 +217,13 @@ export async function startLogCapture( logFilePath, simulatorUuid, bundleId, + logStream, }); log('info', `Log capture started with session ID: ${logSessionId}`); return { sessionId: logSessionId, logFilePath, processes }; } catch (error) { + await closeFailedCapture(); const message = error instanceof Error ? error.message : String(error); log('error', `Failed to start log capture: ${message}`); return { sessionId: '', logFilePath: '', processes: [], error: message }; @@ -159,12 +245,14 @@ export async function stopLogCapture( try { log('info', `Attempting to stop log capture session: ${logSessionId}`); - const logFilePath = session.logFilePath; + const { logFilePath, logStream } = session; for (const process of session.processes) { if (!process.killed && process.exitCode === null) { process.kill('SIGTERM'); } } + logStream.end(); + await finished(logStream); activeLogSessions.delete(logSessionId); log( 'info', diff --git a/vitest.config.ts b/vitest.config.ts index 7736bd83..8be77e6a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ environment: 'node', globals: true, include: [ - 'src/**/__tests__/**/*.test.ts' // Only __tests__ directories + 'src/**/__tests__/**/*.test.ts', // Only __tests__ directories ], exclude: [ 'node_modules/**', @@ -19,16 +19,16 @@ export default defineConfig({ '**/full-output.txt', '**/experiments/**', '**/__pycache__/**', - '**/dist/**' + '**/dist/**', ], pool: 'threads', poolOptions: { threads: { - maxThreads: 4 - } + maxThreads: 4, + }, }, env: { - NODE_OPTIONS: '--max-old-space-size=4096' + NODE_OPTIONS: '--max-old-space-size=4096', }, testTimeout: 30000, hookTimeout: 10000, @@ -42,14 +42,14 @@ export default defineConfig({ 'tests/**', 'example_projects/**', '**/*.config.*', - '**/*.d.ts' - ] - } + '**/*.d.ts', + ], + }, }, resolve: { alias: { // Handle .js imports in TypeScript files - '^(\\.{1,2}/.*)\\.js$': '$1' - } - } -}); \ No newline at end of file + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + }, +});