diff --git a/CHANGELOG.md b/CHANGELOG.md index 9040109d..6c70aad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Fixed + +- Fixed `snapshot_ui` returning empty accessibility hierarchy on iOS 26+ simulators by auto-enabling accessibility defaults at simulator boot ([#290](https://github.com/getsentry/XcodeBuildMCP/issues/290)). + ## [2.3.2] ### Fixed diff --git a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts index 1ad047db..29200af6 100644 --- a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts @@ -83,6 +83,36 @@ describe('boot_sim tool', () => { }); }); + it('should handle already-booted simulator and still enable accessibility', async () => { + const executorCalls: string[][] = []; + const mockExecutor = async ( + command: string[], + description?: string, + allowStderr?: boolean, + opts?: { cwd?: string }, + detached?: boolean, + ) => { + executorCalls.push(command); + void description; + void allowStderr; + void opts; + void detached; + if (command.includes('boot')) { + return createMockCommandResponse({ + success: false, + error: 'Unable to boot device in current state: Booted', + }); + } + return createMockCommandResponse({ success: true, output: '0' }); + }; + + const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); + + expect(result.content[0].text).toBe('Simulator is already booted.'); + // Should have called accessibility write after detecting already-booted + expect(executorCalls.some((cmd) => cmd.join(' ').includes('defaults write'))).toBe(true); + }); + it('should handle exception with Error object', async () => { const mockExecutor = async () => { throw new Error('Connection failed'); @@ -142,7 +172,8 @@ describe('boot_sim tool', () => { await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); - expect(calls).toHaveLength(1); + // First call is the boot command; subsequent calls are from ensureSimulatorAccessibility + expect(calls.length).toBeGreaterThanOrEqual(1); expect(calls[0]).toEqual({ command: ['xcrun', 'simctl', 'boot', 'test-uuid-123'], description: 'Boot Simulator', diff --git a/src/mcp/tools/simulator/boot_sim.ts b/src/mcp/tools/simulator/boot_sim.ts index ba356254..f33cf6ee 100644 --- a/src/mcp/tools/simulator/boot_sim.ts +++ b/src/mcp/tools/simulator/boot_sim.ts @@ -7,6 +7,7 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { ensureSimulatorAccessibility } from '../../../utils/simulator-accessibility.ts'; const baseSchemaObject = z.object({ simulatorId: z @@ -49,16 +50,37 @@ export async function boot_simLogic( const result = await executor(command, 'Boot Simulator', false); if (!result.success) { + // If the simulator is already booted, still ensure accessibility defaults + const alreadyBooted = + result.error?.includes('Unable to boot device in current state: Booted') ?? false; + if (alreadyBooted) { + await ensureSimulatorAccessibility(params.simulatorId, executor); + } return { content: [ { type: 'text', - text: `Boot simulator operation failed: ${result.error}`, + text: alreadyBooted + ? 'Simulator is already booted.' + : `Boot simulator operation failed: ${result.error}`, }, ], + ...(alreadyBooted && { + nextStepParams: { + open_sim: {}, + install_app_sim: { simulatorId: params.simulatorId, appPath: 'PATH_TO_YOUR_APP' }, + launch_app_sim: { + simulatorId: params.simulatorId, + bundleId: 'YOUR_APP_BUNDLE_ID', + }, + }, + }), }; } + // Ensure accessibility defaults are enabled (required for UI hierarchy on iOS 26+) + await ensureSimulatorAccessibility(params.simulatorId, executor); + return { content: [ { diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index 08bd0136..356cf83f 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -22,6 +22,7 @@ import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { inferPlatform } from '../../../utils/infer-platform.ts'; import { constructDestinationString } from '../../../utils/xcode.ts'; +import { ensureSimulatorAccessibility } from '../../../utils/simulator-accessibility.ts'; // Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName const baseOptions = { @@ -346,6 +347,9 @@ export async function build_run_simLogic( } else { log('info', `Simulator ${simulatorId} is already booted`); } + + // Ensure accessibility defaults are enabled (required for UI hierarchy on iOS 26+) + await ensureSimulatorAccessibility(simulatorId, executor); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error checking/booting simulator: ${errorMessage}`); diff --git a/src/utils/__tests__/simulator-accessibility.test.ts b/src/utils/__tests__/simulator-accessibility.test.ts new file mode 100644 index 00000000..b51ffc51 --- /dev/null +++ b/src/utils/__tests__/simulator-accessibility.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { + createMockExecutor, + createCommandMatchingMockExecutor, + createMockCommandResponse, +} from '../../test-utils/mock-executors.ts'; +import type { CommandExecutor } from '../execution/index.ts'; +import { ensureSimulatorAccessibility } from '../simulator-accessibility.ts'; + +const SIM_UUID = '12345678-1234-4234-8234-123456789012'; + +describe('ensureSimulatorAccessibility', () => { + it('should always write both accessibility flags', async () => { + const executorCalls: string[][] = []; + const mockExecutor = createCommandMatchingMockExecutor({ + 'defaults write': { success: true, output: '' }, + }); + + const trackingExecutor: CommandExecutor = async (...args) => { + executorCalls.push(args[0] as string[]); + return mockExecutor(...args); + }; + + await ensureSimulatorAccessibility(SIM_UUID, trackingExecutor); + + expect(executorCalls).toHaveLength(2); + expect(executorCalls[0].join(' ')).toContain('AccessibilityEnabled'); + expect(executorCalls[0].join(' ')).toContain('defaults write'); + expect(executorCalls[1].join(' ')).toContain('ApplicationAccessibilityEnabled'); + expect(executorCalls[1].join(' ')).toContain('defaults write'); + }); + + it('should not throw when executor throws', async () => { + const mockExecutor = createMockExecutor(new Error('spawn failed')); + + // Should not throw + await ensureSimulatorAccessibility(SIM_UUID, mockExecutor); + }); + + it('should attempt second write even when first executor call throws', async () => { + const executorCalls: string[][] = []; + const callCount = { n: 0 }; + const mockExecutor: CommandExecutor = async (command) => { + executorCalls.push(command as string[]); + callCount.n++; + if (callCount.n === 1) { + throw new Error('spawn failed'); + } + return createMockCommandResponse({ success: true, output: '' }); + }; + + await ensureSimulatorAccessibility(SIM_UUID, mockExecutor); + + // Second write should still be attempted even when first throws + expect(executorCalls).toHaveLength(2); + expect(executorCalls[1].join(' ')).toContain('ApplicationAccessibilityEnabled'); + }); + + it('should attempt both writes even when first write fails', async () => { + const executorCalls: string[][] = []; + const callCount = { n: 0 }; + const mockExecutor: CommandExecutor = async (command) => { + executorCalls.push(command as string[]); + callCount.n++; + if (callCount.n === 1) { + return createMockCommandResponse({ success: false, error: 'write failed' }); + } + return createMockCommandResponse({ success: true, output: '' }); + }; + + await ensureSimulatorAccessibility(SIM_UUID, mockExecutor); + + // Both writes should be attempted even when first fails + expect(executorCalls).toHaveLength(2); + }); + + it('should not throw when first write fails', async () => { + const mockExecutor = createCommandMatchingMockExecutor({ + 'AccessibilityEnabled -bool': { success: false, error: 'write failed' }, + }); + + // Should not throw + await ensureSimulatorAccessibility(SIM_UUID, mockExecutor); + }); + + it('should pass correct simctl spawn commands', async () => { + const executorCalls: string[][] = []; + const mockExecutor: CommandExecutor = async (command) => { + executorCalls.push(command as string[]); + return createMockCommandResponse({ success: true, output: '' }); + }; + + await ensureSimulatorAccessibility(SIM_UUID, mockExecutor); + + expect(executorCalls[0]).toEqual([ + 'xcrun', + 'simctl', + 'spawn', + SIM_UUID, + 'defaults', + 'write', + 'com.apple.Accessibility', + 'AccessibilityEnabled', + '-bool', + 'true', + ]); + expect(executorCalls[1]).toEqual([ + 'xcrun', + 'simctl', + 'spawn', + SIM_UUID, + 'defaults', + 'write', + 'com.apple.Accessibility', + 'ApplicationAccessibilityEnabled', + '-bool', + 'true', + ]); + }); +}); diff --git a/src/utils/simulator-accessibility.ts b/src/utils/simulator-accessibility.ts new file mode 100644 index 00000000..88db59d4 --- /dev/null +++ b/src/utils/simulator-accessibility.ts @@ -0,0 +1,82 @@ +import type { CommandExecutor } from './execution/index.ts'; +import { log } from './logging/index.ts'; + +const LOG_PREFIX = '[Simulator]'; + +/** + * Ensure accessibility defaults are enabled on a simulator. + * On iOS 26+ fresh simulators, AccessibilityEnabled and ApplicationAccessibilityEnabled + * default to 0, which prevents accessibility hierarchy queries from returning any elements. + * + * Both flags are written unconditionally on every call — defaults write is idempotent + * and avoids a partial-state problem where only checking one flag could skip the second. + * Failures are logged but never propagated — accessibility setup should not block boot. + */ +export async function ensureSimulatorAccessibility( + simulatorId: string, + executor: CommandExecutor, +): Promise { + let a11yOk = false; + let appA11yOk = false; + + try { + const writeA11y = await executor( + [ + 'xcrun', + 'simctl', + 'spawn', + simulatorId, + 'defaults', + 'write', + 'com.apple.Accessibility', + 'AccessibilityEnabled', + '-bool', + 'true', + ], + `${LOG_PREFIX}: enable AccessibilityEnabled`, + ); + a11yOk = writeA11y.success; + if (!a11yOk) { + log('warn', `${LOG_PREFIX}: Failed to enable AccessibilityEnabled: ${writeA11y.error}`); + } + } catch (error) { + log( + 'warn', + `${LOG_PREFIX}: Failed to enable AccessibilityEnabled: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + try { + const writeAppA11y = await executor( + [ + 'xcrun', + 'simctl', + 'spawn', + simulatorId, + 'defaults', + 'write', + 'com.apple.Accessibility', + 'ApplicationAccessibilityEnabled', + '-bool', + 'true', + ], + `${LOG_PREFIX}: enable ApplicationAccessibilityEnabled`, + ); + appA11yOk = writeAppA11y.success; + if (!appA11yOk) { + log( + 'warn', + `${LOG_PREFIX}: Failed to enable ApplicationAccessibilityEnabled: ${writeAppA11y.error}`, + ); + } + } catch (error) { + log( + 'warn', + `${LOG_PREFIX}: Failed to enable ApplicationAccessibilityEnabled: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (a11yOk && appA11yOk) { + log('info', `${LOG_PREFIX}: Accessibility defaults enabled for simulator ${simulatorId}`); + } +}