Skip to content

Commit 8c15aa3

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 8c15aa3

File tree

6 files changed

+287
-2
lines changed

6 files changed

+287
-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 check after detecting already-booted
113+
expect(executorCalls.some((cmd) => cmd.join(' ').includes('defaults read'))).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: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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 enable accessibility when currently disabled', async () => {
14+
const executorCalls: string[][] = [];
15+
const mockExecutor = createCommandMatchingMockExecutor({
16+
'defaults read': { output: '0' },
17+
'defaults write': { success: true, output: '' },
18+
});
19+
20+
const trackingExecutor: CommandExecutor = async (...args) => {
21+
executorCalls.push(args[0] as string[]);
22+
return mockExecutor(...args);
23+
};
24+
25+
await ensureSimulatorAccessibility(SIM_UUID, trackingExecutor);
26+
27+
expect(executorCalls).toHaveLength(3);
28+
expect(executorCalls[0].join(' ')).toContain('defaults read');
29+
expect(executorCalls[1].join(' ')).toContain('AccessibilityEnabled');
30+
expect(executorCalls[1].join(' ')).toContain('defaults write');
31+
expect(executorCalls[2].join(' ')).toContain('ApplicationAccessibilityEnabled');
32+
expect(executorCalls[2].join(' ')).toContain('defaults write');
33+
});
34+
35+
it('should skip writes when accessibility is already enabled', async () => {
36+
const executorCalls: string[][] = [];
37+
const mockExecutor = createMockExecutor({
38+
success: true,
39+
output: '1',
40+
});
41+
42+
const trackingExecutor: CommandExecutor = async (...args) => {
43+
executorCalls.push(args[0] as string[]);
44+
return mockExecutor(...args);
45+
};
46+
47+
await ensureSimulatorAccessibility(SIM_UUID, trackingExecutor);
48+
49+
// Only the read command should have been called
50+
expect(executorCalls).toHaveLength(1);
51+
expect(executorCalls[0].join(' ')).toContain('defaults read');
52+
});
53+
54+
it('should not throw when read command fails', async () => {
55+
const mockExecutor = createCommandMatchingMockExecutor({
56+
'defaults read': { success: false, output: '', error: 'Domain does not exist' },
57+
'AccessibilityEnabled -bool': { success: false, error: 'write failed' },
58+
});
59+
60+
// Should not throw
61+
await ensureSimulatorAccessibility(SIM_UUID, mockExecutor);
62+
});
63+
64+
it('should not throw when executor throws', async () => {
65+
const mockExecutor = createMockExecutor(new Error('spawn failed'));
66+
67+
// Should not throw
68+
await ensureSimulatorAccessibility(SIM_UUID, mockExecutor);
69+
});
70+
71+
it('should stop if first write fails', async () => {
72+
const executorCalls: string[][] = [];
73+
const callCount = { n: 0 };
74+
const mockExecutor: CommandExecutor = async (command, ...rest) => {
75+
const cmd = (command as string[]).join(' ');
76+
executorCalls.push(command as string[]);
77+
callCount.n++;
78+
if (cmd.includes('defaults read')) {
79+
return createMockCommandResponse({ success: true, output: '0' });
80+
}
81+
if (cmd.includes('defaults write') && callCount.n === 2) {
82+
return createMockCommandResponse({ success: false, error: 'write failed' });
83+
}
84+
return createMockCommandResponse({ success: true, output: '' });
85+
};
86+
87+
await ensureSimulatorAccessibility(SIM_UUID, mockExecutor);
88+
89+
// read + first write only (second write should not happen)
90+
expect(executorCalls).toHaveLength(2);
91+
});
92+
93+
it('should pass correct simctl spawn commands', async () => {
94+
const executorCalls: string[][] = [];
95+
const mockExecutor: CommandExecutor = async (command) => {
96+
const cmd = (command as string[]).join(' ');
97+
executorCalls.push(command as string[]);
98+
if (cmd.includes('defaults read')) {
99+
return createMockCommandResponse({ success: true, output: '0' });
100+
}
101+
return createMockCommandResponse({ success: true, output: '' });
102+
};
103+
104+
await ensureSimulatorAccessibility(SIM_UUID, mockExecutor);
105+
106+
expect(executorCalls[0]).toEqual([
107+
'xcrun',
108+
'simctl',
109+
'spawn',
110+
SIM_UUID,
111+
'defaults',
112+
'read',
113+
'com.apple.Accessibility',
114+
'AccessibilityEnabled',
115+
]);
116+
expect(executorCalls[1]).toEqual([
117+
'xcrun',
118+
'simctl',
119+
'spawn',
120+
SIM_UUID,
121+
'defaults',
122+
'write',
123+
'com.apple.Accessibility',
124+
'AccessibilityEnabled',
125+
'-bool',
126+
'true',
127+
]);
128+
expect(executorCalls[2]).toEqual([
129+
'xcrun',
130+
'simctl',
131+
'spawn',
132+
SIM_UUID,
133+
'defaults',
134+
'write',
135+
'com.apple.Accessibility',
136+
'ApplicationAccessibilityEnabled',
137+
'-bool',
138+
'true',
139+
]);
140+
});
141+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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+
* This is safe to call on any simulator — it checks the current value first and
12+
* only writes if needed. The write is idempotent and persists until device erasure.
13+
*
14+
* Failures are logged but never propagated — accessibility setup should not block boot.
15+
*/
16+
export async function ensureSimulatorAccessibility(
17+
simulatorId: string,
18+
executor: CommandExecutor,
19+
): Promise<void> {
20+
try {
21+
const readResult = await executor(
22+
[
23+
'xcrun',
24+
'simctl',
25+
'spawn',
26+
simulatorId,
27+
'defaults',
28+
'read',
29+
'com.apple.Accessibility',
30+
'AccessibilityEnabled',
31+
],
32+
`${LOG_PREFIX}: read accessibility defaults`,
33+
);
34+
35+
if (readResult.output?.trim() === '1') {
36+
return; // already enabled
37+
}
38+
39+
const writeA11y = await executor(
40+
[
41+
'xcrun',
42+
'simctl',
43+
'spawn',
44+
simulatorId,
45+
'defaults',
46+
'write',
47+
'com.apple.Accessibility',
48+
'AccessibilityEnabled',
49+
'-bool',
50+
'true',
51+
],
52+
`${LOG_PREFIX}: enable AccessibilityEnabled`,
53+
);
54+
55+
if (!writeA11y.success) {
56+
log('warn', `${LOG_PREFIX}: Failed to enable AccessibilityEnabled: ${writeA11y.error}`);
57+
return;
58+
}
59+
60+
const writeAppA11y = await executor(
61+
[
62+
'xcrun',
63+
'simctl',
64+
'spawn',
65+
simulatorId,
66+
'defaults',
67+
'write',
68+
'com.apple.Accessibility',
69+
'ApplicationAccessibilityEnabled',
70+
'-bool',
71+
'true',
72+
],
73+
`${LOG_PREFIX}: enable ApplicationAccessibilityEnabled`,
74+
);
75+
76+
if (!writeAppA11y.success) {
77+
log(
78+
'warn',
79+
`${LOG_PREFIX}: Failed to enable ApplicationAccessibilityEnabled: ${writeAppA11y.error}`,
80+
);
81+
return;
82+
}
83+
84+
log('info', `${LOG_PREFIX}: Accessibility defaults enabled for simulator ${simulatorId}`);
85+
} catch (error) {
86+
log(
87+
'warn',
88+
`${LOG_PREFIX}: Failed to check/enable accessibility defaults: ${error instanceof Error ? error.message : String(error)}`,
89+
);
90+
}
91+
}

0 commit comments

Comments
 (0)