Skip to content

Commit 785d690

Browse files
authored
[build-tools] Enable simulator accessibility prefs before boot (#3914)
## Summary - Add an opt-in `enable_accessibility_settings` boolean input to `eas/start_ios_simulator`. - When enabled, call `IosSimulatorUtils.enableAccessibilitySettingsAsync` inline immediately before each `IosSimulatorUtils.startAsync` call, including cloned simulators. - Reuse the existing `xcrun simctl list devices --json --no-escape-slashes available` wrapper so the `dataPath` from simctl JSON is used directly, then do an exact UDID/name equality check in JS. This avoids `simctl` search-term substring matches like `iPhone 17` matching `iPhone 17 Pro`. - Treat invalid requested simulator state as `UserError`; wrap command/filesystem setup failures as `SystemError`. ## Why These accessibility preferences help Argent and other AX-tree based tools inspect Simulator state more reliably. They can influence app/runtime accessibility behavior, so the setting is explicit and default-off rather than applied to every simulator startup. ## Maestro compatibility `enable_accessibility_settings` defaults to `false`, so existing Maestro test runs and other `eas/start_ios_simulator` users keep the previous startup behavior unless they opt in. When enabled, the setup only writes preferences for an exact, available, shutdown simulator before `simctl bootstatus -b`. If the requested simulator is not available or not shutdown, the step fails with `UserError`; if the setup command/filesystem work fails, it fails with `SystemError`. ## Notes - `plutil` is a standard macOS utility (`/usr/bin/plutil` on the local validation machine), so this does not add an external tool installation requirement. ## Validation CI should pass.
1 parent 47335f0 commit 785d690

4 files changed

Lines changed: 261 additions & 0 deletions

File tree

packages/build-tools/src/steps/functions/__tests__/startIosSimulator.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
1515
getAvailableDevicesAsync: jest.fn(),
1616
getDeviceAsync: jest.fn(),
1717
cloneAsync: jest.fn(),
18+
enableAccessibilitySettingsAsync: jest.fn(),
1819
startAsync: jest.fn(),
1920
waitForReadyAsync: jest.fn(),
2021
disableApsdAsync: jest.fn(),
@@ -34,15 +35,59 @@ function createStep(callInputs?: Record<string, unknown>) {
3435

3536
describe(createStartIosSimulatorBuildFunction, () => {
3637
beforeEach(() => {
38+
jest.clearAllMocks();
3739
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
3840
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
3941
mockedUtils.getDeviceAsync.mockResolvedValue(null);
4042
mockedUtils.cloneAsync.mockResolvedValue(undefined);
43+
mockedUtils.enableAccessibilitySettingsAsync.mockResolvedValue(undefined);
4144
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
4245
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
4346
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
4447
});
4548

49+
it('does not enable accessibility settings by default', async () => {
50+
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
51+
52+
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
53+
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
54+
deviceIdentifier: 'iPhone 15',
55+
env: expect.any(Object),
56+
});
57+
});
58+
59+
it('enables accessibility settings before starting the main device and every clone when requested', async () => {
60+
mockedUtils.startAsync
61+
.mockResolvedValueOnce({ udid: 'base' as any })
62+
.mockResolvedValueOnce({ udid: 'clone-1' as any })
63+
.mockResolvedValueOnce({ udid: 'clone-2' as any });
64+
65+
await createStep({
66+
device_identifier: 'iPhone 15',
67+
count: 2,
68+
enable_accessibility_settings: true,
69+
}).executeAsync();
70+
71+
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
72+
deviceIdentifier: 'iPhone 15',
73+
env: expect.any(Object),
74+
});
75+
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
76+
deviceIdentifier: 'eas-simulator-1',
77+
env: expect.any(Object),
78+
});
79+
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
80+
deviceIdentifier: 'eas-simulator-2',
81+
env: expect.any(Object),
82+
});
83+
for (const [callIndex] of mockedUtils.startAsync.mock.calls.entries()) {
84+
const enableCallOrder =
85+
mockedUtils.enableAccessibilitySettingsAsync.mock.invocationCallOrder[callIndex];
86+
const startCallOrder = mockedUtils.startAsync.mock.invocationCallOrder[callIndex];
87+
expect(enableCallOrder).toBeLessThan(startCallOrder);
88+
}
89+
});
90+
4691
it('disables apsd on the main device and every clone', async () => {
4792
mockedUtils.startAsync
4893
.mockResolvedValueOnce({ udid: 'base' as any })

packages/build-tools/src/steps/functions/startIosSimulator.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
3131
defaultValue: 1,
3232
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
3333
}),
34+
BuildStepInput.createProvider({
35+
id: 'enable_accessibility_settings',
36+
required: false,
37+
defaultValue: false,
38+
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
39+
}),
3440
],
3541
fn: async ({ logger }, { inputs, env }) => {
3642
try {
@@ -55,11 +61,18 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
5561
| undefined;
5662
const originalDeviceIdentifier =
5763
deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
64+
const enableAccessibilitySettings = Boolean(inputs.enable_accessibility_settings.value);
5865

5966
if (!originalDeviceIdentifier) {
6067
throw new Error('Could not find an iPhone among available simulator devices.');
6168
}
6269

70+
if (enableAccessibilitySettings) {
71+
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
72+
deviceIdentifier: originalDeviceIdentifier,
73+
env,
74+
});
75+
}
6376
const { udid } = await IosSimulatorUtils.startAsync({
6477
deviceIdentifier: originalDeviceIdentifier,
6578
env,
@@ -97,6 +110,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
97110
env,
98111
});
99112

113+
if (enableAccessibilitySettings) {
114+
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
115+
deviceIdentifier: cloneDeviceName,
116+
env,
117+
});
118+
}
100119
const { udid: cloneUdid } = await IosSimulatorUtils.startAsync({
101120
deviceIdentifier: cloneDeviceName,
102121
env,

packages/build-tools/src/utils/IosSimulatorUtils.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ExpoError, SystemError, UserError } from '@expo/eas-build-job';
12
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
23
import fs from 'node:fs';
34
import os from 'node:os';
@@ -92,6 +93,67 @@ export namespace IosSimulatorUtils {
9293
});
9394
}
9495

96+
export async function enableAccessibilitySettingsAsync({
97+
deviceIdentifier,
98+
env,
99+
}: {
100+
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
101+
env: NodeJS.ProcessEnv;
102+
}): Promise<void> {
103+
try {
104+
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
105+
const device = devices.find(
106+
device =>
107+
device.isAvailable &&
108+
(device.udid === deviceIdentifier || device.name === deviceIdentifier)
109+
);
110+
if (!device) {
111+
throw new UserError(
112+
'EAS_IOS_SIMULATOR_NOT_FOUND',
113+
`Failed to find available iOS Simulator "${deviceIdentifier}" to update accessibility settings.`
114+
);
115+
}
116+
if (device.state !== 'Shutdown') {
117+
throw new UserError(
118+
'EAS_IOS_SIMULATOR_NOT_SHUTDOWN',
119+
`Expected iOS Simulator "${deviceIdentifier}" to be shutdown before updating accessibility settings, but it is ${device.state}.`
120+
);
121+
}
122+
123+
const plistPath = path.join(
124+
device.dataPath,
125+
'Library',
126+
'Preferences',
127+
'com.apple.Accessibility.plist'
128+
);
129+
await fs.promises.mkdir(path.dirname(plistPath), { recursive: true });
130+
131+
const plistExists = await fs.promises
132+
.access(plistPath)
133+
.then(() => true)
134+
.catch(() => false);
135+
if (!plistExists) {
136+
await spawn('plutil', ['-create', 'binary1', plistPath], { env });
137+
}
138+
139+
for (const key of [
140+
'AutomationEnabled',
141+
'IgnoreAXServerEntitlements',
142+
'AccessibilityEnabled',
143+
'ApplicationAccessibilityEnabled',
144+
]) {
145+
await spawn('plutil', ['-replace', key, '-bool', 'true', plistPath], { env });
146+
}
147+
} catch (err) {
148+
if (err instanceof ExpoError) {
149+
throw err;
150+
}
151+
throw new SystemError('Failed to update iOS Simulator accessibility settings.', {
152+
cause: err,
153+
});
154+
}
155+
}
156+
95157
export async function startAsync({
96158
deviceIdentifier,
97159
env,

packages/build-tools/src/utils/__tests__/IosSimulatorUtils.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { SystemError, UserError } from '@expo/eas-build-job';
12
import spawn from '@expo/turtle-spawn';
23
import os from 'node:os';
34
import path from 'node:path';
@@ -18,9 +19,143 @@ const mockedSpawn = jest.mocked(spawn);
1819

1920
describe('IosSimulatorUtils', () => {
2021
beforeEach(() => {
22+
mockedSpawn.mockReset();
2123
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
2224
});
2325

26+
describe(IosSimulatorUtils.startAsync, () => {
27+
it('waits for boot completion without writing accessibility prefs', async () => {
28+
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
29+
if (command === 'xcrun' && args.join(' ') === 'simctl bootstatus test-udid -b') {
30+
return {
31+
stdout: 'Monitoring boot status for iPhone 15 (test-udid).\n',
32+
stderr: '',
33+
} as any;
34+
}
35+
return { stdout: '', stderr: '' } as any;
36+
}) as any);
37+
38+
await IosSimulatorUtils.startAsync({
39+
deviceIdentifier: 'test-udid' as any,
40+
env: process.env,
41+
});
42+
43+
expect(mockedSpawn).not.toHaveBeenCalledWith(
44+
'xcrun',
45+
['simctl', 'list', 'devices', '--json', '--no-escape-slashes', 'test-udid'],
46+
expect.anything()
47+
);
48+
expect(mockedSpawn).not.toHaveBeenCalledWith('plutil', expect.anything(), expect.anything());
49+
});
50+
});
51+
52+
describe(IosSimulatorUtils.enableAccessibilitySettingsAsync, () => {
53+
function device(overrides: Record<string, unknown> = {}) {
54+
return {
55+
dataPath: '/tmp/eas-test-simulator-data',
56+
dataPathSize: 1,
57+
logPath: '/tmp/eas-test-simulator-logs',
58+
udid: 'test-udid',
59+
isAvailable: true,
60+
deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15',
61+
state: 'Shutdown',
62+
name: 'iPhone 15',
63+
...overrides,
64+
};
65+
}
66+
67+
function mockAvailableDevices(devices: Record<string, unknown>[]) {
68+
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
69+
if (
70+
command === 'xcrun' &&
71+
args.join(' ') === 'simctl list devices --json --no-escape-slashes available'
72+
) {
73+
return {
74+
stdout: JSON.stringify({
75+
devices: {
76+
'com.apple.CoreSimulator.SimRuntime.iOS-18-0': devices,
77+
},
78+
}),
79+
stderr: '',
80+
} as any;
81+
}
82+
return { stdout: '', stderr: '' } as any;
83+
}) as any);
84+
}
85+
86+
it('writes accessibility prefs for an exact shutdown simulator match', async () => {
87+
mockAvailableDevices([
88+
device({
89+
udid: 'test-udid-pro',
90+
name: 'iPhone 17 Pro',
91+
dataPath: '/tmp/eas-test-simulator-data-pro',
92+
}),
93+
device({ name: 'iPhone 17' }),
94+
]);
95+
96+
await expect(
97+
IosSimulatorUtils.enableAccessibilitySettingsAsync({
98+
deviceIdentifier: 'iPhone 17' as any,
99+
env: process.env,
100+
})
101+
).resolves.toBeUndefined();
102+
103+
for (const key of [
104+
'AutomationEnabled',
105+
'IgnoreAXServerEntitlements',
106+
'AccessibilityEnabled',
107+
'ApplicationAccessibilityEnabled',
108+
]) {
109+
expect(mockedSpawn).toHaveBeenCalledWith(
110+
'plutil',
111+
[
112+
'-replace',
113+
key,
114+
'-bool',
115+
'true',
116+
'/tmp/eas-test-simulator-data/Library/Preferences/com.apple.Accessibility.plist',
117+
],
118+
{ env: process.env }
119+
);
120+
}
121+
});
122+
123+
it('throws UserError when no available simulator exactly matches the identifier', async () => {
124+
mockAvailableDevices([device({ name: 'iPhone 17 Pro' })]);
125+
126+
await expect(
127+
IosSimulatorUtils.enableAccessibilitySettingsAsync({
128+
deviceIdentifier: 'iPhone 17' as any,
129+
env: process.env,
130+
})
131+
).rejects.toThrow(UserError);
132+
133+
expect(mockedSpawn).not.toHaveBeenCalledWith('plutil', expect.anything(), expect.anything());
134+
});
135+
136+
it('throws UserError when the matched simulator is already booted', async () => {
137+
mockAvailableDevices([device({ state: 'Booted' })]);
138+
139+
await expect(
140+
IosSimulatorUtils.enableAccessibilitySettingsAsync({
141+
deviceIdentifier: 'test-udid' as any,
142+
env: process.env,
143+
})
144+
).rejects.toThrow(UserError);
145+
});
146+
147+
it('throws SystemError when pre-boot accessibility setup fails', async () => {
148+
mockedSpawn.mockRejectedValue(new Error('Failed to list simulators'));
149+
150+
await expect(
151+
IosSimulatorUtils.enableAccessibilitySettingsAsync({
152+
deviceIdentifier: 'test-udid' as any,
153+
env: process.env,
154+
})
155+
).rejects.toThrow(SystemError);
156+
});
157+
});
158+
24159
describe(IosSimulatorUtils.waitForReadyAsync, () => {
25160
it('takes the readiness screenshot into a writable temp file, not /dev/null', async () => {
26161
await IosSimulatorUtils.waitForReadyAsync({

0 commit comments

Comments
 (0)