Skip to content

Commit 638147c

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 638147c

6 files changed

Lines changed: 267 additions & 2 deletions

File tree

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: 23 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,37 @@ 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
],
68+
...(alreadyBooted && {
69+
nextStepParams: {
70+
open_sim: {},
71+
install_app_sim: { simulatorId: params.simulatorId, appPath: 'PATH_TO_YOUR_APP' },
72+
launch_app_sim: {
73+
simulatorId: params.simulatorId,
74+
bundleId: 'YOUR_APP_BUNDLE_ID',
75+
},
76+
},
77+
}),
5978
};
6079
}
6180

81+
// Ensure accessibility defaults are enabled (required for UI hierarchy on iOS 26+)
82+
await ensureSimulatorAccessibility(params.simulatorId, executor);
83+
6284
return {
6385
content: [
6486
{

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: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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 attempt second write even when first executor call throws', 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+
throw new Error('spawn failed');
48+
}
49+
return createMockCommandResponse({ success: true, output: '' });
50+
};
51+
52+
await ensureSimulatorAccessibility(SIM_UUID, mockExecutor);
53+
54+
// Second write should still be attempted even when first throws
55+
expect(executorCalls).toHaveLength(2);
56+
expect(executorCalls[1].join(' ')).toContain('ApplicationAccessibilityEnabled');
57+
});
58+
59+
it('should attempt both writes even when first write fails', async () => {
60+
const executorCalls: string[][] = [];
61+
const callCount = { n: 0 };
62+
const mockExecutor: CommandExecutor = async (command) => {
63+
executorCalls.push(command as string[]);
64+
callCount.n++;
65+
if (callCount.n === 1) {
66+
return createMockCommandResponse({ success: false, error: 'write failed' });
67+
}
68+
return createMockCommandResponse({ success: true, output: '' });
69+
};
70+
71+
await ensureSimulatorAccessibility(SIM_UUID, mockExecutor);
72+
73+
// Both writes should be attempted even when first fails
74+
expect(executorCalls).toHaveLength(2);
75+
});
76+
77+
it('should not throw when first write fails', async () => {
78+
const mockExecutor = createCommandMatchingMockExecutor({
79+
'AccessibilityEnabled -bool': { success: false, error: 'write failed' },
80+
});
81+
82+
// Should not throw
83+
await ensureSimulatorAccessibility(SIM_UUID, mockExecutor);
84+
});
85+
86+
it('should pass correct simctl spawn commands', async () => {
87+
const executorCalls: string[][] = [];
88+
const mockExecutor: CommandExecutor = async (command) => {
89+
executorCalls.push(command as string[]);
90+
return createMockCommandResponse({ success: true, output: '' });
91+
};
92+
93+
await ensureSimulatorAccessibility(SIM_UUID, mockExecutor);
94+
95+
expect(executorCalls[0]).toEqual([
96+
'xcrun',
97+
'simctl',
98+
'spawn',
99+
SIM_UUID,
100+
'defaults',
101+
'write',
102+
'com.apple.Accessibility',
103+
'AccessibilityEnabled',
104+
'-bool',
105+
'true',
106+
]);
107+
expect(executorCalls[1]).toEqual([
108+
'xcrun',
109+
'simctl',
110+
'spawn',
111+
SIM_UUID,
112+
'defaults',
113+
'write',
114+
'com.apple.Accessibility',
115+
'ApplicationAccessibilityEnabled',
116+
'-bool',
117+
'true',
118+
]);
119+
});
120+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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+
let a11yOk = false;
20+
let appA11yOk = false;
21+
22+
try {
23+
const writeA11y = await executor(
24+
[
25+
'xcrun',
26+
'simctl',
27+
'spawn',
28+
simulatorId,
29+
'defaults',
30+
'write',
31+
'com.apple.Accessibility',
32+
'AccessibilityEnabled',
33+
'-bool',
34+
'true',
35+
],
36+
`${LOG_PREFIX}: enable AccessibilityEnabled`,
37+
);
38+
a11yOk = writeA11y.success;
39+
if (!a11yOk) {
40+
log('warn', `${LOG_PREFIX}: Failed to enable AccessibilityEnabled: ${writeA11y.error}`);
41+
}
42+
} catch (error) {
43+
log(
44+
'warn',
45+
`${LOG_PREFIX}: Failed to enable AccessibilityEnabled: ${error instanceof Error ? error.message : String(error)}`,
46+
);
47+
}
48+
49+
try {
50+
const writeAppA11y = await executor(
51+
[
52+
'xcrun',
53+
'simctl',
54+
'spawn',
55+
simulatorId,
56+
'defaults',
57+
'write',
58+
'com.apple.Accessibility',
59+
'ApplicationAccessibilityEnabled',
60+
'-bool',
61+
'true',
62+
],
63+
`${LOG_PREFIX}: enable ApplicationAccessibilityEnabled`,
64+
);
65+
appA11yOk = writeAppA11y.success;
66+
if (!appA11yOk) {
67+
log(
68+
'warn',
69+
`${LOG_PREFIX}: Failed to enable ApplicationAccessibilityEnabled: ${writeAppA11y.error}`,
70+
);
71+
}
72+
} catch (error) {
73+
log(
74+
'warn',
75+
`${LOG_PREFIX}: Failed to enable ApplicationAccessibilityEnabled: ${error instanceof Error ? error.message : String(error)}`,
76+
);
77+
}
78+
79+
if (a11yOk && appA11yOk) {
80+
log('info', `${LOG_PREFIX}: Accessibility defaults enabled for simulator ${simulatorId}`);
81+
}
82+
}

0 commit comments

Comments
 (0)