diff --git a/docs/session-aware-migration-todo.md b/docs/session-aware-migration-todo.md index 56fc6526..d7e3225c 100644 --- a/docs/session-aware-migration-todo.md +++ b/docs/session-aware-migration-todo.md @@ -12,15 +12,15 @@ Reference: `docs/session_management_plan.md` - [x] `src/mcp/tools/project-discovery/show_build_settings.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`. ## Device Workflows -- [ ] `src/mcp/tools/device/build_device.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. -- [ ] `src/mcp/tools/device/test_device.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `deviceId`, `configuration`. -- [ ] `src/mcp/tools/device/get_device_app_path.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. -- [ ] `src/mcp/tools/device/install_app_device.ts` — session defaults: `deviceId`. -- [ ] `src/mcp/tools/device/launch_app_device.ts` — session defaults: `deviceId`. -- [ ] `src/mcp/tools/device/stop_app_device.ts` — session defaults: `deviceId`. +- [x] `src/mcp/tools/device/build_device.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. +- [x] `src/mcp/tools/device/test_device.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `deviceId`, `configuration`. +- [x] `src/mcp/tools/device/get_device_app_path.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. +- [x] `src/mcp/tools/device/install_app_device.ts` — session defaults: `deviceId`. +- [x] `src/mcp/tools/device/launch_app_device.ts` — session defaults: `deviceId`. +- [x] `src/mcp/tools/device/stop_app_device.ts` — session defaults: `deviceId`. ## Device Logging -- [ ] `src/mcp/tools/logging/start_device_log_cap.ts` — session defaults: `deviceId`. +- [x] `src/mcp/tools/logging/start_device_log_cap.ts` — session defaults: `deviceId`. ## macOS Workflows - [ ] `src/mcp/tools/macos/build_macos.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`, `arch`. diff --git a/src/mcp/tools/device/__tests__/build_device.test.ts b/src/mcp/tools/device/__tests__/build_device.test.ts index ad2d3512..8d937d36 100644 --- a/src/mcp/tools/device/__tests__/build_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_device.test.ts @@ -4,50 +4,40 @@ * Using dependency injection for deterministic testing */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts'; import buildDevice, { buildDeviceLogic } from '../build_device.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; describe('build_device plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(buildDevice.name).toBe('build_device'); }); it('should have correct description', () => { - expect(buildDevice.description).toBe( - "Builds an app from a project or workspace for a physical Apple device. Provide exactly one of projectPath or workspacePath. Example: build_device({ projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' })", - ); + expect(buildDevice.description).toBe('Builds an app for a connected device.'); }); it('should have handler function', () => { expect(typeof buildDevice.handler).toBe('function'); }); - it('should validate schema correctly', () => { - // Test required fields - expect(buildDevice.schema.projectPath.safeParse('/path/to/MyProject.xcodeproj').success).toBe( - true, - ); + it('should expose only optional build-tuning fields in public schema', () => { + const schema = z.object(buildDevice.schema).strict(); + expect(schema.safeParse({}).success).toBe(true); expect( - buildDevice.schema.workspacePath.safeParse('/path/to/MyProject.xcworkspace').success, + schema.safeParse({ derivedDataPath: '/path/to/derived-data', extraArgs: [] }).success, ).toBe(true); - expect(buildDevice.schema.scheme.safeParse('MyScheme').success).toBe(true); + expect(schema.safeParse({ projectPath: '/path/to/MyProject.xcodeproj' }).success).toBe(false); - // Test optional fields - expect(buildDevice.schema.configuration.safeParse('Debug').success).toBe(true); - expect(buildDevice.schema.derivedDataPath.safeParse('/path/to/derived-data').success).toBe( - true, - ); - expect(buildDevice.schema.extraArgs.safeParse(['--arg1', '--arg2']).success).toBe(true); - expect(buildDevice.schema.preferXcodebuild.safeParse(true).success).toBe(true); - - // Test invalid inputs - expect(buildDevice.schema.projectPath.safeParse(null).success).toBe(false); - expect(buildDevice.schema.workspacePath.safeParse(null).success).toBe(false); - expect(buildDevice.schema.scheme.safeParse(null).success).toBe(false); - expect(buildDevice.schema.extraArgs.safeParse('not-array').success).toBe(false); - expect(buildDevice.schema.preferXcodebuild.safeParse('not-boolean').success).toBe(false); + const schemaKeys = Object.keys(buildDevice.schema).sort(); + expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild']); }); }); @@ -58,7 +48,8 @@ describe('build_device plugin', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Either projectPath or workspacePath is required'); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide a project or workspace'); }); it('should error when both projectPath and workspacePath provided', async () => { @@ -69,7 +60,8 @@ describe('build_device plugin', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('mutually exclusive'); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); }); }); @@ -80,9 +72,8 @@ describe('build_device plugin', () => { }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Parameter validation failed'); - expect(result.content[0].text).toContain('scheme'); - expect(result.content[0].text).toContain('Required'); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('scheme is required'); }); it('should return Zod validation error for invalid parameter types', async () => { @@ -93,6 +84,10 @@ describe('build_device plugin', () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('projectPath'); + expect(result.content[0].text).toContain( + 'Tip: set session defaults via session-set-defaults', + ); }); }); diff --git a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts index 3ad9437f..6f578c37 100644 --- a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts +++ b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts @@ -4,11 +4,17 @@ * Using dependency injection for deterministic testing */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import getDeviceAppPath, { get_device_app_pathLogic } from '../get_device_app_path.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; describe('get_device_app_path plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(getDeviceAppPath.name).toBe('get_device_app_path'); @@ -16,7 +22,7 @@ describe('get_device_app_path plugin', () => { it('should have correct description', () => { expect(getDeviceAppPath.description).toBe( - "Gets the app bundle path for a physical device application (iOS, watchOS, tvOS, visionOS) using either a project or workspace. Provide exactly one of projectPath or workspacePath. Example: get_device_app_path({ projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme' })", + 'Retrieves the built app path for a connected device.', ); }); @@ -24,32 +30,14 @@ describe('get_device_app_path plugin', () => { expect(typeof getDeviceAppPath.handler).toBe('function'); }); - it('should validate schema correctly', () => { - // Test project path - expect( - getDeviceAppPath.schema.projectPath.safeParse('/path/to/project.xcodeproj').success, - ).toBe(true); - - // Test workspace path - expect( - getDeviceAppPath.schema.workspacePath.safeParse('/path/to/workspace.xcworkspace').success, - ).toBe(true); - - // Test required scheme field - expect(getDeviceAppPath.schema.scheme.safeParse('MyScheme').success).toBe(true); - - // Test optional fields - expect(getDeviceAppPath.schema.configuration.safeParse('Debug').success).toBe(true); - expect(getDeviceAppPath.schema.platform.safeParse('iOS').success).toBe(true); - expect(getDeviceAppPath.schema.platform.safeParse('watchOS').success).toBe(true); - expect(getDeviceAppPath.schema.platform.safeParse('tvOS').success).toBe(true); - expect(getDeviceAppPath.schema.platform.safeParse('visionOS').success).toBe(true); - - // Test invalid inputs - expect(getDeviceAppPath.schema.projectPath.safeParse(null).success).toBe(false); - expect(getDeviceAppPath.schema.workspacePath.safeParse(null).success).toBe(false); - expect(getDeviceAppPath.schema.scheme.safeParse(null).success).toBe(false); - expect(getDeviceAppPath.schema.platform.safeParse('invalidPlatform').success).toBe(false); + it('should expose only platform in public schema', () => { + const schema = z.object(getDeviceAppPath.schema).strict(); + expect(schema.safeParse({}).success).toBe(true); + expect(schema.safeParse({ platform: 'iOS' }).success).toBe(true); + expect(schema.safeParse({ projectPath: '/path/to/project.xcodeproj' }).success).toBe(false); + + const schemaKeys = Object.keys(getDeviceAppPath.schema).sort(); + expect(schemaKeys).toEqual(['platform']); }); }); @@ -59,7 +47,8 @@ describe('get_device_app_path plugin', () => { scheme: 'MyScheme', }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Either projectPath or workspacePath is required'); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide a project or workspace'); }); it('should error when both projectPath and workspacePath provided', async () => { @@ -69,7 +58,27 @@ describe('get_device_app_path plugin', () => { scheme: 'MyScheme', }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('mutually exclusive'); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + }); + }); + + describe('Handler Requirements', () => { + it('should require scheme when missing', async () => { + const result = await getDeviceAppPath.handler({ + projectPath: '/path/to/project.xcodeproj', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('scheme is required'); + }); + + it('should require project or workspace when scheme default exists', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await getDeviceAppPath.handler({}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); }); }); diff --git a/src/mcp/tools/device/__tests__/install_app_device.test.ts b/src/mcp/tools/device/__tests__/install_app_device.test.ts index 59f8f204..95dc314a 100644 --- a/src/mcp/tools/device/__tests__/install_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/install_app_device.test.ts @@ -4,35 +4,48 @@ * Using dependency injection for deterministic testing */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import installAppDevice, { install_app_deviceLogic } from '../install_app_device.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; describe('install_app_device plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Handler Requirements', () => { + it('should require deviceId when session defaults are missing', async () => { + const result = await installAppDevice.handler({ + appPath: '/path/to/test.app', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('deviceId is required'); + }); + }); + describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(installAppDevice.name).toBe('install_app_device'); }); it('should have correct description', () => { - expect(installAppDevice.description).toBe( - 'Installs an app on a physical Apple device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Requires deviceId and appPath.', - ); + expect(installAppDevice.description).toBe('Installs an app on a connected device.'); }); it('should have handler function', () => { expect(typeof installAppDevice.handler).toBe('function'); }); - it('should validate schema correctly', () => { - // Test required fields - expect(installAppDevice.schema.deviceId.safeParse('test-device-123').success).toBe(true); - expect(installAppDevice.schema.appPath.safeParse('/path/to/test.app').success).toBe(true); + it('should require appPath in public schema', () => { + const schema = z.object(installAppDevice.schema).strict(); + expect(schema.safeParse({ appPath: '/path/to/test.app' }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ deviceId: 'test-device-123' }).success).toBe(false); - // Test invalid inputs - expect(installAppDevice.schema.deviceId.safeParse(null).success).toBe(false); - expect(installAppDevice.schema.deviceId.safeParse(123).success).toBe(false); - expect(installAppDevice.schema.appPath.safeParse(null).success).toBe(false); + expect(Object.keys(installAppDevice.schema)).toEqual(['appPath']); }); }); diff --git a/src/mcp/tools/device/__tests__/launch_app_device.test.ts b/src/mcp/tools/device/__tests__/launch_app_device.test.ts index 21e2e202..28b78b83 100644 --- a/src/mcp/tools/device/__tests__/launch_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/launch_app_device.test.ts @@ -7,21 +7,24 @@ * Uses createMockExecutor for command execution and manual stubs for file operations. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import launchAppDevice, { launch_app_deviceLogic } from '../launch_app_device.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; describe('launch_app_device plugin (device-shared)', () => { + beforeEach(() => { + sessionStore.clear(); + }); + describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(launchAppDevice.name).toBe('launch_app_device'); }); it('should have correct description', () => { - expect(launchAppDevice.description).toBe( - 'Launches an app on a physical Apple device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Requires deviceId and bundleId.', - ); + expect(launchAppDevice.description).toBe('Launches an app on a connected device.'); }); it('should have handler function', () => { @@ -29,42 +32,25 @@ describe('launch_app_device plugin (device-shared)', () => { }); it('should validate schema with valid inputs', () => { - const schema = z.object(launchAppDevice.schema); - expect( - schema.safeParse({ - deviceId: 'test-device-123', - bundleId: 'com.example.app', - }).success, - ).toBe(true); - expect( - schema.safeParse({ - deviceId: '00008030-001E14BE2288802E', - bundleId: 'com.apple.calculator', - }).success, - ).toBe(true); + const schema = z.object(launchAppDevice.schema).strict(); + expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + expect(Object.keys(launchAppDevice.schema)).toEqual(['bundleId']); }); it('should validate schema with invalid inputs', () => { - const schema = z.object(launchAppDevice.schema); - expect(schema.safeParse({}).success).toBe(false); - expect( - schema.safeParse({ - deviceId: null, - bundleId: 'com.example.app', - }).success, - ).toBe(false); - expect( - schema.safeParse({ - deviceId: 'test-device-123', - bundleId: null, - }).success, - ).toBe(false); - expect( - schema.safeParse({ - deviceId: 123, - bundleId: 'com.example.app', - }).success, - ).toBe(false); + const schema = z.object(launchAppDevice.schema).strict(); + expect(schema.safeParse({ bundleId: null }).success).toBe(false); + expect(schema.safeParse({ bundleId: 123 }).success).toBe(false); + }); + }); + + describe('Handler Requirements', () => { + it('should require deviceId when not provided', async () => { + const result = await launchAppDevice.handler({ bundleId: 'com.example.app' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('deviceId is required'); }); }); diff --git a/src/mcp/tools/device/__tests__/stop_app_device.test.ts b/src/mcp/tools/device/__tests__/stop_app_device.test.ts index b8a05974..5d0014ed 100644 --- a/src/mcp/tools/device/__tests__/stop_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/stop_app_device.test.ts @@ -4,36 +4,46 @@ * Using dependency injection for deterministic testing */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import stopAppDevice, { stop_app_deviceLogic } from '../stop_app_device.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; describe('stop_app_device plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(stopAppDevice.name).toBe('stop_app_device'); }); it('should have correct description', () => { - expect(stopAppDevice.description).toBe( - 'Stops an app running on a physical Apple device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Requires deviceId and processId.', - ); + expect(stopAppDevice.description).toBe('Stops a running app on a connected device.'); }); it('should have handler function', () => { expect(typeof stopAppDevice.handler).toBe('function'); }); - it('should validate schema correctly', () => { - // Test required fields - expect(stopAppDevice.schema.deviceId.safeParse('test-device-123').success).toBe(true); - expect(stopAppDevice.schema.processId.safeParse(12345).success).toBe(true); + it('should require processId in public schema', () => { + const schema = z.object(stopAppDevice.schema).strict(); + expect(schema.safeParse({ processId: 12345 }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ deviceId: 'test-device-123' }).success).toBe(false); + + expect(Object.keys(stopAppDevice.schema)).toEqual(['processId']); + }); + }); + + describe('Handler Requirements', () => { + it('should require deviceId when not provided', async () => { + const result = await stopAppDevice.handler({ processId: 12345 }); - // Test invalid inputs - expect(stopAppDevice.schema.deviceId.safeParse(null).success).toBe(false); - expect(stopAppDevice.schema.deviceId.safeParse(123).success).toBe(false); - expect(stopAppDevice.schema.processId.safeParse(null).success).toBe(false); - expect(stopAppDevice.schema.processId.safeParse('not-number').success).toBe(false); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('deviceId is required'); }); }); diff --git a/src/mcp/tools/device/__tests__/test_device.test.ts b/src/mcp/tools/device/__tests__/test_device.test.ts index a5f86350..ce6e0bb4 100644 --- a/src/mcp/tools/device/__tests__/test_device.test.ts +++ b/src/mcp/tools/device/__tests__/test_device.test.ts @@ -6,57 +6,54 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; import { createMockExecutor, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; import testDevice, { testDeviceLogic } from '../test_device.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; describe('test_device plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(testDevice.name).toBe('test_device'); }); it('should have correct description', () => { - expect(testDevice.description).toBe( - 'Runs tests for an Apple project or workspace on a physical device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) using xcodebuild test and parses xcresult output. Provide exactly one of projectPath or workspacePath. IMPORTANT: Requires scheme and deviceId. Example: test_device({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyScheme", deviceId: "device-uuid" })', - ); + expect(testDevice.description).toBe('Runs tests on a physical Apple device.'); }); it('should have handler function', () => { expect(typeof testDevice.handler).toBe('function'); }); - it('should validate schema correctly', () => { - // Test required fields - expect(testDevice.schema.projectPath.safeParse('/path/to/project.xcodeproj').success).toBe( - true, - ); + it('should expose only session-free fields in public schema', () => { + const schema = z.object(testDevice.schema).strict(); expect( - testDevice.schema.workspacePath.safeParse('/path/to/workspace.xcworkspace').success, + schema.safeParse({ + derivedDataPath: '/path/to/derived-data', + extraArgs: ['--arg1'], + preferXcodebuild: true, + platform: 'iOS', + testRunnerEnv: { FOO: 'bar' }, + }).success, ).toBe(true); - expect(testDevice.schema.scheme.safeParse('MyScheme').success).toBe(true); - expect(testDevice.schema.deviceId.safeParse('test-device-123').success).toBe(true); + expect(schema.safeParse({}).success).toBe(true); + expect(schema.safeParse({ projectPath: '/path/to/project.xcodeproj' }).success).toBe(false); - // Test optional fields - expect(testDevice.schema.configuration.safeParse('Debug').success).toBe(true); - expect(testDevice.schema.derivedDataPath.safeParse('/path/to/derived-data').success).toBe( - true, - ); - expect(testDevice.schema.extraArgs.safeParse(['--arg1', '--arg2']).success).toBe(true); - expect(testDevice.schema.preferXcodebuild.safeParse(true).success).toBe(true); - expect(testDevice.schema.platform.safeParse('iOS').success).toBe(true); - expect(testDevice.schema.platform.safeParse('watchOS').success).toBe(true); - expect(testDevice.schema.platform.safeParse('tvOS').success).toBe(true); - expect(testDevice.schema.platform.safeParse('visionOS').success).toBe(true); - - // Test invalid inputs - expect(testDevice.schema.projectPath.safeParse(null).success).toBe(false); - expect(testDevice.schema.workspacePath.safeParse(null).success).toBe(false); - expect(testDevice.schema.scheme.safeParse(null).success).toBe(false); - expect(testDevice.schema.deviceId.safeParse(null).success).toBe(false); - expect(testDevice.schema.platform.safeParse('invalidPlatform').success).toBe(false); + const schemaKeys = Object.keys(testDevice.schema).sort(); + expect(schemaKeys).toEqual([ + 'derivedDataPath', + 'extraArgs', + 'platform', + 'preferXcodebuild', + 'testRunnerEnv', + ]); }); it('should validate XOR between projectPath and workspacePath', async () => { @@ -111,6 +108,38 @@ describe('test_device plugin', () => { }); }); + describe('Handler Requirements', () => { + it('should require scheme and device defaults', async () => { + const result = await testDevice.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide scheme and deviceId'); + }); + + it('should require project or workspace when defaults provide scheme and device', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme', deviceId: 'test-device-123' }); + + const result = await testDevice.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should reject mutually exclusive project inputs when defaults satisfy requirements', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme', deviceId: 'test-device-123' }); + + const result = await testDevice.handler({ + projectPath: '/path/to/project.xcodeproj', + workspacePath: '/path/to/workspace.xcworkspace', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + }); + }); + describe('Handler Behavior (Complete Literal Returns)', () => { beforeEach(() => { // Clean setup for standard testing pattern diff --git a/src/mcp/tools/device/build_device.ts b/src/mcp/tools/device/build_device.ts index d838ef1b..40a3f510 100644 --- a/src/mcp/tools/device/build_device.ts +++ b/src/mcp/tools/device/build_device.ts @@ -10,7 +10,7 @@ import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; // Unified schema: XOR between projectPath and workspacePath @@ -63,12 +63,21 @@ export async function buildDeviceLogic( export default { name: 'build_device', - description: - "Builds an app from a project or workspace for a physical Apple device. Provide exactly one of projectPath or workspacePath. Example: build_device({ projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' })", - schema: baseSchemaObject.shape, - handler: createTypedTool( - buildDeviceSchema as z.ZodType, - buildDeviceLogic, - getDefaultCommandExecutor, - ), + description: 'Builds an app for a connected device.', + schema: baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + configuration: true, + } as const).shape, + handler: createSessionAwareTool({ + internalSchema: buildDeviceSchema as unknown as z.ZodType, + logicFunction: buildDeviceLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }), }; diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts index 15455e27..77ab9647 100644 --- a/src/mcp/tools/device/get_device_app_path.ts +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -11,7 +11,7 @@ import { log } from '../../../utils/logging/index.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; // Unified schema: XOR between projectPath and workspacePath, sharing common options @@ -146,12 +146,21 @@ export async function get_device_app_pathLogic( export default { name: 'get_device_app_path', - description: - "Gets the app bundle path for a physical device application (iOS, watchOS, tvOS, visionOS) using either a project or workspace. Provide exactly one of projectPath or workspacePath. Example: get_device_app_path({ projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme' })", - schema: baseSchemaObject.shape, // MCP SDK compatibility - handler: createTypedTool( - getDeviceAppPathSchema as z.ZodType, - get_device_app_pathLogic, - getDefaultCommandExecutor, - ), + description: 'Retrieves the built app path for a connected device.', + schema: baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + configuration: true, + } as const).shape, + handler: createSessionAwareTool({ + internalSchema: getDeviceAppPathSchema as unknown as z.ZodType, + logicFunction: get_device_app_pathLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }), }; diff --git a/src/mcp/tools/device/install_app_device.ts b/src/mcp/tools/device/install_app_device.ts index c7e65b7a..16a2913d 100644 --- a/src/mcp/tools/device/install_app_device.ts +++ b/src/mcp/tools/device/install_app_device.ts @@ -10,7 +10,7 @@ import { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; // Define schema as ZodObject const installAppDeviceSchema = z.object({ @@ -82,12 +82,12 @@ export async function install_app_deviceLogic( export default { name: 'install_app_device', - description: - 'Installs an app on a physical Apple device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Requires deviceId and appPath.', - schema: installAppDeviceSchema.shape, // MCP SDK compatibility - handler: createTypedTool( - installAppDeviceSchema, - install_app_deviceLogic, - getDefaultCommandExecutor, - ), + description: 'Installs an app on a connected device.', + schema: installAppDeviceSchema.omit({ deviceId: true } as const).shape, + handler: createSessionAwareTool({ + internalSchema: installAppDeviceSchema as unknown as z.ZodType, + logicFunction: install_app_deviceLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], + }), }; diff --git a/src/mcp/tools/device/launch_app_device.ts b/src/mcp/tools/device/launch_app_device.ts index e0a0843b..bebe0dc2 100644 --- a/src/mcp/tools/device/launch_app_device.ts +++ b/src/mcp/tools/device/launch_app_device.ts @@ -10,7 +10,7 @@ import { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; import { promises as fs } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -141,12 +141,12 @@ export async function launch_app_deviceLogic( export default { name: 'launch_app_device', - description: - 'Launches an app on a physical Apple device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Requires deviceId and bundleId.', - schema: launchAppDeviceSchema.shape, // MCP SDK compatibility - handler: createTypedTool( - launchAppDeviceSchema, - launch_app_deviceLogic, - getDefaultCommandExecutor, - ), + description: 'Launches an app on a connected device.', + schema: launchAppDeviceSchema.omit({ deviceId: true } as const).shape, + handler: createSessionAwareTool({ + internalSchema: launchAppDeviceSchema as unknown as z.ZodType, + logicFunction: launch_app_deviceLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], + }), }; diff --git a/src/mcp/tools/device/stop_app_device.ts b/src/mcp/tools/device/stop_app_device.ts index 9785db55..93789707 100644 --- a/src/mcp/tools/device/stop_app_device.ts +++ b/src/mcp/tools/device/stop_app_device.ts @@ -10,7 +10,7 @@ import { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; // Define schema as ZodObject const stopAppDeviceSchema = z.object({ @@ -84,8 +84,12 @@ export async function stop_app_deviceLogic( export default { name: 'stop_app_device', - description: - 'Stops an app running on a physical Apple device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Requires deviceId and processId.', - schema: stopAppDeviceSchema.shape, // MCP SDK compatibility - handler: createTypedTool(stopAppDeviceSchema, stop_app_deviceLogic, getDefaultCommandExecutor), + description: 'Stops a running app on a connected device.', + schema: stopAppDeviceSchema.omit({ deviceId: true } as const).shape, + handler: createSessionAwareTool({ + internalSchema: stopAppDeviceSchema as unknown as z.ZodType, + logicFunction: stop_app_deviceLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], + }), }; diff --git a/src/mcp/tools/device/test_device.ts b/src/mcp/tools/device/test_device.ts index b02719aa..80ac57a2 100644 --- a/src/mcp/tools/device/test_device.ts +++ b/src/mcp/tools/device/test_device.ts @@ -21,7 +21,7 @@ import { getDefaultCommandExecutor, getDefaultFileSystemExecutor, } from '../../../utils/execution/index.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; // Unified schema: XOR between projectPath and workspacePath @@ -275,21 +275,30 @@ export async function testDeviceLogic( export default { name: 'test_device', - description: - 'Runs tests for an Apple project or workspace on a physical device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) using xcodebuild test and parses xcresult output. Provide exactly one of projectPath or workspacePath. IMPORTANT: Requires scheme and deviceId. Example: test_device({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyScheme", deviceId: "device-uuid" })', - schema: baseSchemaObject.shape, - handler: createTypedTool( - testDeviceSchema as z.ZodType, - (params: TestDeviceParams, executor: CommandExecutor) => { - return testDeviceLogic( + description: 'Runs tests on a physical Apple device.', + schema: baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + deviceId: true, + configuration: true, + } as const).shape, + handler: createSessionAwareTool({ + internalSchema: testDeviceSchema as unknown as z.ZodType, + logicFunction: (params: TestDeviceParams, executor: CommandExecutor) => + testDeviceLogic( { ...params, platform: params.platform ?? 'iOS', }, executor, getDefaultFileSystemExecutor(), - ); - }, - getDefaultCommandExecutor, - ), + ), + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme', 'deviceId'], message: 'Provide scheme and deviceId' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }), }; 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 index ce7b2c4f..25fa148a 100644 --- a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts @@ -2,13 +2,19 @@ * Tests for start_device_log_cap plugin * Following CLAUDE.md testing standards with pure dependency injection */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import type { ChildProcess } from 'child_process'; import { z } from 'zod'; import { createMockExecutor, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; -import plugin, { start_device_log_capLogic } from '../start_device_log_cap.ts'; +import plugin, { + start_device_log_capLogic, + activeDeviceLogSessions, +} from '../start_device_log_cap.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; describe('start_device_log_cap plugin', () => { // Mock state tracking @@ -26,6 +32,22 @@ describe('start_device_log_cap plugin', () => { mkdirCalls = []; writeFileCalls = []; + const originalJsonWaitEnv = process.env.XBMCP_LAUNCH_JSON_WAIT_MS; + + beforeEach(() => { + sessionStore.clear(); + activeDeviceLogSessions.clear(); + process.env.XBMCP_LAUNCH_JSON_WAIT_MS = '25'; + }); + + afterEach(() => { + if (originalJsonWaitEnv === undefined) { + delete process.env.XBMCP_LAUNCH_JSON_WAIT_MS; + } else { + process.env.XBMCP_LAUNCH_JSON_WAIT_MS = originalJsonWaitEnv; + } + }); + describe('Plugin Structure', () => { it('should export an object with required properties', () => { expect(plugin).toHaveProperty('name'); @@ -39,23 +61,18 @@ describe('start_device_log_cap plugin', () => { }); it('should have correct description', () => { - expect(plugin.description).toBe( - 'Starts capturing logs from a specified Apple device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) by launching the app with console output. Returns a session ID.', - ); + expect(plugin.description).toBe('Starts log capture on a connected device.'); }); it('should have correct schema structure', () => { // Schema should be a plain object for MCP protocol compliance expect(typeof plugin.schema).toBe('object'); - expect(plugin.schema).toHaveProperty('deviceId'); - expect(plugin.schema).toHaveProperty('bundleId'); + expect(Object.keys(plugin.schema)).toEqual(['bundleId']); // Validate that schema fields are Zod types that can be used for validation - const schema = z.object(plugin.schema); - expect(schema.safeParse({ deviceId: 'test-device', bundleId: 'com.test.app' }).success).toBe( - true, - ); - expect(schema.safeParse({ deviceId: 123, bundleId: 'com.test.app' }).success).toBe(false); + const schema = z.object(plugin.schema).strict(); + expect(schema.safeParse({ bundleId: 'com.test.app' }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); }); it('should have handler as a function', () => { @@ -63,6 +80,15 @@ describe('start_device_log_cap plugin', () => { }); }); + describe('Handler Requirements', () => { + it('should require deviceId when not provided', async () => { + const result = await plugin.handler({ bundleId: 'com.example.MyApp' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('deviceId is required'); + }); + }); + describe('Handler Functionality', () => { it('should start log capture successfully', async () => { // Mock successful command execution @@ -123,6 +149,262 @@ describe('start_device_log_cap plugin', () => { expect(result.content[0].text).toContain('Use stop_device_log_cap'); }); + it('should surface early launch failures when process exits immediately', async () => { + const failingProcess = new EventEmitter() as unknown as ChildProcess & { + exitCode: number | null; + killed: boolean; + kill(signal?: string): boolean; + stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; + stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; + }; + + const stubOutput = new EventEmitter() as NodeJS.ReadableStream & { + setEncoding?: (encoding: string) => void; + }; + stubOutput.setEncoding = () => {}; + const stubError = new EventEmitter() as NodeJS.ReadableStream & { + setEncoding?: (encoding: string) => void; + }; + stubError.setEncoding = () => {}; + + 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 unknown as ChildProcess & { + exitCode: number | null; + killed: boolean; + kill(signal?: string): boolean; + stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; + stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; + }; + + const stubOutput = new EventEmitter() as NodeJS.ReadableStream & { + setEncoding?: (encoding: string) => void; + }; + stubOutput.setEncoding = () => {}; + const stubError = new EventEmitter() as NodeJS.ReadableStream & { + setEncoding?: (encoding: string) => void; + }; + stubError.setEncoding = () => {}; + + 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 unknown as ChildProcess & { + exitCode: number | null; + killed: boolean; + kill(signal?: string): boolean; + stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; + stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; + }; + + const stubOutput = new EventEmitter() as NodeJS.ReadableStream & { + setEncoding?: (encoding: string) => void; + }; + stubOutput.setEncoding = () => {}; + const stubError = new EventEmitter() as NodeJS.ReadableStream & { + setEncoding?: (encoding: string) => void; + }; + stubError.setEncoding = () => {}; + + 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: 'com.example.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({ 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 index a7bb3423..2d6c4d03 100644 --- a/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts @@ -2,9 +2,10 @@ * Tests for stop_device_log_cap plugin */ import { describe, it, expect, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; import { z } from 'zod'; import plugin, { stop_device_log_capLogic } from '../stop_device_log_cap.ts'; -import { activeDeviceLogSessions } from '../start_device_log_cap.ts'; +import { activeDeviceLogSessions, type DeviceLogSession } from '../start_device_log_cap.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; // Note: Logger is allowed to execute normally (integration testing pattern) @@ -57,17 +58,25 @@ describe('stop_device_log_cap plugin', () => { exitCode?: number | null; } = {}, ) { - const testProcess = { + const emitter = new EventEmitter(); + const processState = { killed: options.killed ?? false, - exitCode: options.exitCode !== undefined ? options.exitCode : null, + exitCode: options.exitCode ?? (options.killed ? 0 : null), killCalls: [] as string[], - kill: function (signal?: 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; }, }; - return testProcess; + const testProcess = Object.assign(emitter, processState); + return testProcess as typeof testProcess; } it('should handle stop log capture when session not found', async () => { @@ -98,10 +107,11 @@ describe('stop_device_log_cap plugin', () => { }); activeDeviceLogSessions.set(testSessionId, { - process: testProcess, + process: testProcess as unknown as DeviceLogSession['process'], logFilePath: testLogFilePath, deviceUuid: '00008110-001A2C3D4E5F', bundleId: 'com.example.MyApp', + hasEnded: false, }); // Configure test file system for successful operation @@ -142,10 +152,11 @@ describe('stop_device_log_cap plugin', () => { }); activeDeviceLogSessions.set(testSessionId, { - process: testProcess, + process: testProcess as unknown as DeviceLogSession['process'], logFilePath: testLogFilePath, deviceUuid: '00008110-001A2C3D4E5F', bundleId: 'com.example.MyApp', + hasEnded: false, }); // Configure test file system for successful operation @@ -183,10 +194,11 @@ describe('stop_device_log_cap plugin', () => { }); activeDeviceLogSessions.set(testSessionId, { - process: testProcess, + process: testProcess as unknown as DeviceLogSession['process'], logFilePath: testLogFilePath, deviceUuid: '00008110-001A2C3D4E5F', bundleId: 'com.example.MyApp', + hasEnded: false, }); // Configure test file system for access failure (file doesn't exist) @@ -224,10 +236,11 @@ describe('stop_device_log_cap plugin', () => { }); activeDeviceLogSessions.set(testSessionId, { - process: testProcess, + process: testProcess as unknown as DeviceLogSession['process'], logFilePath: testLogFilePath, deviceUuid: '00008110-001A2C3D4E5F', bundleId: 'com.example.MyApp', + hasEnded: false, }); // Configure test file system for successful access but failed read @@ -267,10 +280,11 @@ describe('stop_device_log_cap plugin', () => { }); activeDeviceLogSessions.set(testSessionId, { - process: testProcess, + process: testProcess as unknown as DeviceLogSession['process'], logFilePath: testLogFilePath, deviceUuid: '00008110-001A2C3D4E5F', bundleId: 'com.example.MyApp', + hasEnded: false, }); // Configure test file system for access failure with string error diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts index af55133e..49bfc7ec 100644 --- a/src/mcp/tools/logging/start_device_log_cap.ts +++ b/src/mcp/tools/logging/start_device_log_cap.ts @@ -7,13 +7,14 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import type { ChildProcess } from 'child_process'; import { v4 as uuidv4 } from 'uuid'; import { z } from 'zod'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { ToolResponse } from '../../../types/common.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; /** * Log file retention policy for device logs: @@ -28,7 +29,205 @@ const DEVICE_LOG_FILE_PREFIX = 'xcodemcp_device_log_'; // - 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. -export const activeDeviceLogSessions = new Map(); +export interface DeviceLogSession { + process: ChildProcess; + logFilePath: string; + deviceUuid: string; + bundleId: string; + logStream?: fs.WriteStream; + hasEnded: boolean; +} + +export const activeDeviceLogSessions = new Map(); + +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 raw = process.env.XBMCP_LAUNCH_JSON_WAIT_MS; + if (raw === undefined) { + return DEFAULT_JSON_RESULT_WAIT_MS; + } + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) { + return DEFAULT_JSON_RESULT_WAIT_MS; + } + + return parsed; +} + +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) { + if (fileExecutor.existsSync(targetPath)) { + await fileExecutor.rm(targetPath, { force: true }); + } + return; + } + + if (fs.existsSync(targetPath)) { + await fs.promises.rm(targetPath, { force: true }); + } + } catch { + // Best-effort cleanup only + } +} + +async function pollJsonOutcome( + jsonPath: string, + fileExecutor: FileSystemExecutor | undefined, + timeoutMs: number, +): Promise { + const start = Date.now(); + + const readOnce = async (): Promise => { + try { + const exists = fileExecutor?.existsSync(jsonPath) ?? fs.existsSync(jsonPath); + + if (!exists) { + return null; + } + + const content = fileExecutor + ? await fileExecutor.readFile(jsonPath, 'utf8') + : await fs.promises.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 = fs.WriteStream & { closed?: boolean }; /** * Start a log capture session for an iOS device by launching the app with console output. @@ -49,19 +248,23 @@ export async function startDeviceLogCapture( const { deviceUuid, bundleId } = params; const logSessionId = uuidv4(); const logFileName = `${DEVICE_LOG_FILE_PREFIX}${logSessionId}.log`; - const logFilePath = path.join(os.tmpdir(), logFileName); + const tempDir = fileSystemExecutor ? fileSystemExecutor.tmpdir() : os.tmpdir(); + const logFilePath = path.join(tempDir, logFileName); + const launchJsonPath = path.join(tempDir, `devicectl-launch-${logSessionId}.json`); + + let logStream: fs.WriteStream | undefined; try { // Use injected file system executor or default if (fileSystemExecutor) { - await fileSystemExecutor.mkdir(fileSystemExecutor.tmpdir(), { recursive: true }); + await fileSystemExecutor.mkdir(tempDir, { recursive: true }); await fileSystemExecutor.writeFile(logFilePath, ''); } else { - await fs.promises.mkdir(os.tmpdir(), { recursive: true }); + await fs.promises.mkdir(tempDir, { recursive: true }); await fs.promises.writeFile(logFilePath, ''); } - const logStream = fs.createWriteStream(logFilePath, { flags: 'a' }); + logStream = fs.createWriteStream(logFilePath, { flags: 'a' }); logStream.write( `\n--- Device log capture for bundle ID: ${bundleId} on device: ${deviceUuid} ---\n`, @@ -79,31 +282,309 @@ export async function startDeviceLogCapture( '--terminate-existing', '--device', deviceUuid, + '--json-output', + launchJsonPath, bundleId, ], 'Device Log Capture', true, undefined, + true, ); - // For testing purposes, we'll simulate process management - // In actual usage, the process would be managed by the executor result - activeDeviceLogSessions.set(logSessionId, { - process: result.process, + 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 + 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) { + if (process.exitCode === 0) { + const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); + return Promise.resolve( + failureFromOutput ? { exitCode: process.exitCode, errorMessage: failureFromOutput } : null, + ); + } + const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); + 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({ exitCode: code ?? null, errorMessage: failureFromOutput }); + return; + } + if (code === 0) { + finalize(null); + } else { + finalize({ exitCode: code ?? null, 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. @@ -197,12 +678,12 @@ export async function start_device_log_capLogic( export default { name: 'start_device_log_cap', - description: - 'Starts capturing logs from a specified Apple device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) by launching the app with console output. Returns a session ID.', - schema: startDeviceLogCapSchema.shape, // MCP SDK compatibility - handler: createTypedTool( - startDeviceLogCapSchema, - start_device_log_capLogic, - getDefaultCommandExecutor, - ), + description: 'Starts log capture on a connected device.', + schema: startDeviceLogCapSchema.omit({ deviceId: true } as const).shape, + handler: createSessionAwareTool({ + internalSchema: startDeviceLogCapSchema as unknown as z.ZodType, + logicFunction: start_device_log_capLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], + }), }; diff --git a/src/mcp/tools/logging/stop_device_log_cap.ts b/src/mcp/tools/logging/stop_device_log_cap.ts index 9c6f13e0..cc2b8a9f 100644 --- a/src/mcp/tools/logging/stop_device_log_cap.ts +++ b/src/mcp/tools/logging/stop_device_log_cap.ts @@ -5,24 +5,14 @@ */ import * as fs from 'fs'; -import type { ChildProcess } from 'child_process'; import { z } from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import { activeDeviceLogSessions } from './start_device_log_cap.ts'; +import { activeDeviceLogSessions, type DeviceLogSession } from './start_device_log_cap.ts'; import { ToolResponse } from '../../../types/common.ts'; import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; -interface DeviceLogSession { - process: - | ChildProcess - | { killed?: boolean; exitCode?: number | null; kill?: (signal?: string) => boolean }; - logFilePath: string; - deviceUuid: string; - bundleId: string; -} - // Define schema as ZodObject const stopDeviceLogCapSchema = z.object({ logSessionId: z.string().describe('The session ID returned by start_device_log_cap.'), @@ -31,23 +21,6 @@ const stopDeviceLogCapSchema = z.object({ // Use z.infer for type safety type StopDeviceLogCapParams = z.infer; -/** - * Type guard to validate device log session structure - */ -function isValidDeviceLogSession(session: unknown): session is DeviceLogSession { - return ( - typeof session === 'object' && - session !== null && - 'process' in session && - 'logFilePath' in session && - 'deviceUuid' in session && - 'bundleId' in session && - typeof (session as DeviceLogSession).logFilePath === 'string' && - typeof (session as DeviceLogSession).deviceUuid === 'string' && - typeof (session as DeviceLogSession).bundleId === 'string' - ); -} - /** * Business logic for stopping device log capture session */ @@ -57,8 +30,8 @@ export async function stop_device_log_capLogic( ): Promise { const { logSessionId } = params; - const sessionData: unknown = activeDeviceLogSessions.get(logSessionId); - if (!sessionData) { + const session = activeDeviceLogSessions.get(logSessionId); + if (!session) { log('warning', `Device log session not found: ${logSessionId}`); return { content: [ @@ -71,35 +44,26 @@ export async function stop_device_log_capLogic( }; } - // Validate session structure - if (!isValidDeviceLogSession(sessionData)) { - log('error', `Invalid device log session structure for session ${logSessionId}`); - return { - content: [ - { - type: 'text', - text: `Failed to stop device log capture session ${logSessionId}: Invalid session structure`, - }, - ], - isError: true, - }; - } - - const session = sessionData as DeviceLogSession; - try { log('info', `Attempting to stop device log capture session: ${logSessionId}`); - const logFilePath = session.logFilePath; - if (!session.process.killed && session.process.exitCode === null) { + const shouldSignalStop = + !(session.hasEnded ?? false) && + session.process.killed !== true && + session.process.exitCode == null; + + if (shouldSignalStop) { session.process.kill?.('SIGTERM'); } + await waitForSessionToFinish(session); + + if (session.logStream) { + await ensureStreamClosed(session.logStream); + } + + const logFilePath = session.logFilePath; activeDeviceLogSessions.delete(logSessionId); - log( - 'info', - `Device log capture session ${logSessionId} stopped. Log file retained at: ${logFilePath}`, - ); // Check file access if (!fileSystemExecutor.existsSync(logFilePath)) { @@ -109,6 +73,11 @@ export async function stop_device_log_capLogic( const fileContent = await fileSystemExecutor.readFile(logFilePath, 'utf-8'); log('info', `Successfully read device log content from ${logFilePath}`); + log( + 'info', + `Device log capture session ${logSessionId} stopped. Log file retained at: ${logFilePath}`, + ); + return { content: [ { @@ -132,6 +101,67 @@ export async function stop_device_log_capLogic( } } +type WriteStreamWithClosed = fs.WriteStream & { closed?: boolean }; + +async function ensureStreamClosed(stream: fs.WriteStream): Promise { + const typedStream = stream as WriteStreamWithClosed; + if (typedStream.destroyed || typedStream.closed) { + return; + } + + await new Promise((resolve) => { + const onClose = (): void => resolve(); + typedStream.once('close', onClose); + typedStream.end(); + }).catch(() => { + // Ignore cleanup errors – best-effort close + }); +} + +async function waitForSessionToFinish(session: DeviceLogSession): Promise { + if (session.hasEnded) { + return; + } + + if (session.process.exitCode != null) { + session.hasEnded = true; + return; + } + + if (typeof session.process.once === 'function') { + await new Promise((resolve) => { + const onClose = (): void => { + clearTimeout(timeout); + session.hasEnded = true; + resolve(); + }; + + const timeout = setTimeout(() => { + session.process.removeListener?.('close', onClose); + session.hasEnded = true; + resolve(); + }, 1000); + + session.process.once('close', onClose); + + if (session.hasEnded || session.process.exitCode != null) { + session.process.removeListener?.('close', onClose); + onClose(); + } + }); + return; + } + + // Fallback polling for minimal mock processes (primarily in tests) + for (let i = 0; i < 20; i += 1) { + if (session.hasEnded || session.process.exitCode != null) { + session.hasEnded = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + /** * Type guard to check if an object has fs-like promises interface */