Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
33 changes: 32 additions & 1 deletion src/mcp/tools/simulator/__tests__/boot_sim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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',
Expand Down
24 changes: 23 additions & 1 deletion src/mcp/tools/simulator/boot_sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: [
{
Expand Down
4 changes: 4 additions & 0 deletions src/mcp/tools/simulator/build_run_sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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}`);
Expand Down
120 changes: 120 additions & 0 deletions src/utils/__tests__/simulator-accessibility.test.ts
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
82 changes: 82 additions & 0 deletions src/utils/simulator-accessibility.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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}`);
}
}