Skip to content

Commit 1b68bdf

Browse files
committed
fix: auto-enable accessibility defaults on simulator boot (#290)
On iOS 26+ fresh simulators, AccessibilityEnabled and ApplicationAccessibilityEnabled default to 0, which prevents accessibility hierarchy queries from returning any elements. Enable both flags via xcrun simctl spawn defaults write after boot_sim and build_run_sim boot the simulator. The check is idempotent (reads first, writes only if needed) and failures are logged but never propagated to avoid blocking boot.
1 parent b6f49dd commit 1b68bdf

File tree

6 files changed

+228
-2
lines changed

6 files changed

+228
-2
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### Fixed
6+
7+
- 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)).
8+
39
## [2.3.2]
410

511
### Fixed

src/mcp/tools/simulator/__tests__/boot_sim.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,36 @@ describe('boot_sim tool', () => {
8383
});
8484
});
8585

86+
it('should handle already-booted simulator and still enable accessibility', async () => {
87+
const executorCalls: string[][] = [];
88+
const mockExecutor = async (
89+
command: string[],
90+
description?: string,
91+
allowStderr?: boolean,
92+
opts?: { cwd?: string },
93+
detached?: boolean,
94+
) => {
95+
executorCalls.push(command);
96+
void description;
97+
void allowStderr;
98+
void opts;
99+
void detached;
100+
if (command.includes('boot')) {
101+
return createMockCommandResponse({
102+
success: false,
103+
error: 'Unable to boot device in current state: Booted',
104+
});
105+
}
106+
return createMockCommandResponse({ success: true, output: '0' });
107+
};
108+
109+
const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor);
110+
111+
expect(result.content[0].text).toBe('Simulator is already booted.');
112+
// Should have called accessibility write after detecting already-booted
113+
expect(executorCalls.some((cmd) => cmd.join(' ').includes('defaults write'))).toBe(true);
114+
});
115+
86116
it('should handle exception with Error object', async () => {
87117
const mockExecutor = async () => {
88118
throw new Error('Connection failed');
@@ -142,7 +172,8 @@ describe('boot_sim tool', () => {
142172

143173
await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor);
144174

145-
expect(calls).toHaveLength(1);
175+
// First call is the boot command; subsequent calls are from ensureSimulatorAccessibility
176+
expect(calls.length).toBeGreaterThanOrEqual(1);
146177
expect(calls[0]).toEqual({
147178
command: ['xcrun', 'simctl', 'boot', 'test-uuid-123'],
148179
description: 'Boot Simulator',

src/mcp/tools/simulator/boot_sim.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createSessionAwareTool,
88
getSessionAwareToolSchemaShape,
99
} from '../../../utils/typed-tool-factory.ts';
10+
import { ensureSimulatorAccessibility } from '../../../utils/simulator-accessibility.ts';
1011

1112
const baseSchemaObject = z.object({
1213
simulatorId: z
@@ -49,16 +50,27 @@ export async function boot_simLogic(
4950
const result = await executor(command, 'Boot Simulator', false);
5051

5152
if (!result.success) {
53+
// If the simulator is already booted, still ensure accessibility defaults
54+
const alreadyBooted =
55+
result.error?.includes('Unable to boot device in current state: Booted') ?? false;
56+
if (alreadyBooted) {
57+
await ensureSimulatorAccessibility(params.simulatorId, executor);
58+
}
5259
return {
5360
content: [
5461
{
5562
type: 'text',
56-
text: `Boot simulator operation failed: ${result.error}`,
63+
text: alreadyBooted
64+
? 'Simulator is already booted.'
65+
: `Boot simulator operation failed: ${result.error}`,
5766
},
5867
],
5968
};
6069
}
6170

71+
// Ensure accessibility defaults are enabled (required for UI hierarchy on iOS 26+)
72+
await ensureSimulatorAccessibility(params.simulatorId, executor);
73+
6274
return {
6375
content: [
6476
{

src/mcp/tools/simulator/build_run_sim.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts';
2222
import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts';
2323
import { inferPlatform } from '../../../utils/infer-platform.ts';
2424
import { constructDestinationString } from '../../../utils/xcode.ts';
25+
import { ensureSimulatorAccessibility } from '../../../utils/simulator-accessibility.ts';
2526

2627
// Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName
2728
const baseOptions = {
@@ -346,6 +347,9 @@ export async function build_run_simLogic(
346347
} else {
347348
log('info', `Simulator ${simulatorId} is already booted`);
348349
}
350+
351+
// Ensure accessibility defaults are enabled (required for UI hierarchy on iOS 26+)
352+
await ensureSimulatorAccessibility(simulatorId, executor);
349353
} catch (error) {
350354
const errorMessage = error instanceof Error ? error.message : String(error);
351355
log('error', `Error checking/booting simulator: ${errorMessage}`);
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
createMockExecutor,
4+
createCommandMatchingMockExecutor,
5+
createMockCommandResponse,
6+
} from '../../test-utils/mock-executors.ts';
7+
import type { CommandExecutor } from '../execution/index.ts';
8+
import { ensureSimulatorAccessibility } from '../simulator-accessibility.ts';
9+
10+
const SIM_UUID = '12345678-1234-4234-8234-123456789012';
11+
12+
describe('ensureSimulatorAccessibility', () => {
13+
it('should always write both accessibility flags', async () => {
14+
const executorCalls: string[][] = [];
15+
const mockExecutor = createCommandMatchingMockExecutor({
16+
'defaults write': { success: true, output: '' },
17+
});
18+
19+
const trackingExecutor: CommandExecutor = async (...args) => {
20+
executorCalls.push(args[0] as string[]);
21+
return mockExecutor(...args);
22+
};
23+
24+
await ensureSimulatorAccessibility(SIM_UUID, trackingExecutor);
25+
26+
expect(executorCalls).toHaveLength(2);
27+
expect(executorCalls[0].join(' ')).toContain('AccessibilityEnabled');
28+
expect(executorCalls[0].join(' ')).toContain('defaults write');
29+
expect(executorCalls[1].join(' ')).toContain('ApplicationAccessibilityEnabled');
30+
expect(executorCalls[1].join(' ')).toContain('defaults write');
31+
});
32+
33+
it('should not throw when executor throws', async () => {
34+
const mockExecutor = createMockExecutor(new Error('spawn failed'));
35+
36+
// Should not throw
37+
await ensureSimulatorAccessibility(SIM_UUID, mockExecutor);
38+
});
39+
40+
it('should stop and not write second flag if first write fails', async () => {
41+
const executorCalls: string[][] = [];
42+
const callCount = { n: 0 };
43+
const mockExecutor: CommandExecutor = async (command) => {
44+
executorCalls.push(command as string[]);
45+
callCount.n++;
46+
if (callCount.n === 1) {
47+
return createMockCommandResponse({ success: false, error: 'write failed' });
48+
}
49+
return createMockCommandResponse({ success: true, output: '' });
50+
};
51+
52+
await ensureSimulatorAccessibility(SIM_UUID, mockExecutor);
53+
54+
// Both writes should be attempted even when first fails
55+
expect(executorCalls).toHaveLength(2);
56+
});
57+
58+
it('should not throw when first write fails', async () => {
59+
const mockExecutor = createCommandMatchingMockExecutor({
60+
'AccessibilityEnabled -bool': { success: false, error: 'write failed' },
61+
});
62+
63+
// Should not throw
64+
await ensureSimulatorAccessibility(SIM_UUID, mockExecutor);
65+
});
66+
67+
it('should pass correct simctl spawn commands', async () => {
68+
const executorCalls: string[][] = [];
69+
const mockExecutor: CommandExecutor = async (command) => {
70+
executorCalls.push(command as string[]);
71+
return createMockCommandResponse({ success: true, output: '' });
72+
};
73+
74+
await ensureSimulatorAccessibility(SIM_UUID, mockExecutor);
75+
76+
expect(executorCalls[0]).toEqual([
77+
'xcrun',
78+
'simctl',
79+
'spawn',
80+
SIM_UUID,
81+
'defaults',
82+
'write',
83+
'com.apple.Accessibility',
84+
'AccessibilityEnabled',
85+
'-bool',
86+
'true',
87+
]);
88+
expect(executorCalls[1]).toEqual([
89+
'xcrun',
90+
'simctl',
91+
'spawn',
92+
SIM_UUID,
93+
'defaults',
94+
'write',
95+
'com.apple.Accessibility',
96+
'ApplicationAccessibilityEnabled',
97+
'-bool',
98+
'true',
99+
]);
100+
});
101+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { CommandExecutor } from './execution/index.ts';
2+
import { log } from './logging/index.ts';
3+
4+
const LOG_PREFIX = '[Simulator]';
5+
6+
/**
7+
* Ensure accessibility defaults are enabled on a simulator.
8+
* On iOS 26+ fresh simulators, AccessibilityEnabled and ApplicationAccessibilityEnabled
9+
* default to 0, which prevents accessibility hierarchy queries from returning any elements.
10+
*
11+
* Both flags are written unconditionally on every call — defaults write is idempotent
12+
* and avoids a partial-state problem where only checking one flag could skip the second.
13+
* Failures are logged but never propagated — accessibility setup should not block boot.
14+
*/
15+
export async function ensureSimulatorAccessibility(
16+
simulatorId: string,
17+
executor: CommandExecutor,
18+
): Promise<void> {
19+
try {
20+
const writeA11y = await executor(
21+
[
22+
'xcrun',
23+
'simctl',
24+
'spawn',
25+
simulatorId,
26+
'defaults',
27+
'write',
28+
'com.apple.Accessibility',
29+
'AccessibilityEnabled',
30+
'-bool',
31+
'true',
32+
],
33+
`${LOG_PREFIX}: enable AccessibilityEnabled`,
34+
);
35+
36+
if (!writeA11y.success) {
37+
log('warn', `${LOG_PREFIX}: Failed to enable AccessibilityEnabled: ${writeA11y.error}`);
38+
}
39+
40+
const writeAppA11y = await executor(
41+
[
42+
'xcrun',
43+
'simctl',
44+
'spawn',
45+
simulatorId,
46+
'defaults',
47+
'write',
48+
'com.apple.Accessibility',
49+
'ApplicationAccessibilityEnabled',
50+
'-bool',
51+
'true',
52+
],
53+
`${LOG_PREFIX}: enable ApplicationAccessibilityEnabled`,
54+
);
55+
56+
if (!writeAppA11y.success) {
57+
log(
58+
'warn',
59+
`${LOG_PREFIX}: Failed to enable ApplicationAccessibilityEnabled: ${writeAppA11y.error}`,
60+
);
61+
}
62+
63+
if (writeA11y.success && writeAppA11y.success) {
64+
log('info', `${LOG_PREFIX}: Accessibility defaults enabled for simulator ${simulatorId}`);
65+
}
66+
} catch (error) {
67+
log(
68+
'warn',
69+
`${LOG_PREFIX}: Failed to check/enable accessibility defaults: ${error instanceof Error ? error.message : String(error)}`,
70+
);
71+
}
72+
}

0 commit comments

Comments
 (0)