Skip to content

Commit d29b86d

Browse files
authored
fix: report maestro ios runner setup failures (#809)
1 parent d14dfab commit d29b86d

10 files changed

Lines changed: 167 additions & 21 deletions

File tree

.github/workflows/ios.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ jobs:
3030
IOS_RUNTIME_VERSION: '26.2'
3131
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
3232
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
33+
AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS: '420000'
3334
steps:
3435
- name: Checkout
3536
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -57,7 +58,7 @@ jobs:
5758
- name: Prepare iOS runner
5859
run: |
5960
pnpm clean:daemon
60-
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 300000 --json
61+
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout "$AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS" --json
6162
pnpm clean:daemon
6263
6364
- name: Run iOS simulator smoke replay

.github/workflows/perf-nightly.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ jobs:
3636
IOS_RUNTIME_VERSION: "26.2"
3737
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
3838
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
39+
AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS: "420000"
3940
steps:
4041
- name: Checkout
4142
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -63,7 +64,7 @@ jobs:
6364
- name: Prepare iOS runner
6465
run: |
6566
pnpm clean:daemon
66-
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000
67+
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout "$AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS"
6768
pnpm clean:daemon
6869
6970
- name: Run iOS command perf benchmark

.github/workflows/replays-nightly.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ jobs:
5353
IOS_RUNTIME_VERSION: '26.2'
5454
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
5555
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
56+
AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS: '420000'
5657
steps:
5758
- name: Checkout
5859
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -80,7 +81,7 @@ jobs:
8081
- name: Prepare iOS runner
8182
run: |
8283
pnpm clean:daemon
83-
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 300000 --json
84+
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout "$AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS" --json
8485
pnpm clean:daemon
8586
8687
- name: Run iOS simulator replay suite

src/daemon/handlers/__tests__/session-replay-vars.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,48 @@ test('runReplayScriptFile reports Maestro runScript failures at the runScript st
768768
assert.equal(calls.length, 0);
769769
});
770770

771+
test('runReplayScriptFile reports iOS Maestro openLink setup failures before assertions', async () => {
772+
const { response, calls } = await runReplayFixture({
773+
label: 'maestro-ios-openlink-prewarm-fail',
774+
script: [
775+
'appId: demo.app',
776+
'---',
777+
'- openLink: demo://screen',
778+
'- assertVisible: Ready',
779+
'',
780+
].join('\n'),
781+
flags: { replayBackend: 'maestro', platform: 'ios' },
782+
invoke: async (req) => {
783+
if (req.command === 'open') {
784+
return {
785+
ok: false,
786+
error: {
787+
code: 'COMMAND_FAILED',
788+
message: 'Developer mode is disabled for Apple development tools',
789+
details: {
790+
hint: 'Run `sudo DevToolsSecurity -enable`.',
791+
},
792+
},
793+
};
794+
}
795+
return { ok: true, data: {} };
796+
},
797+
});
798+
799+
assert.equal(response.ok, false);
800+
if (!response.ok) {
801+
assert.match(response.error.message, /Replay failed at step 1/);
802+
assert.match(response.error.message, /open "demo\.app" "demo:\/\/screen"/);
803+
assert.match(response.error.message, /Developer mode is disabled/);
804+
assert.match(String(response.error.details?.hint ?? ''), /DevToolsSecurity -enable/);
805+
}
806+
assert.deepEqual(
807+
calls.map((call) => [call.command, call.positionals]),
808+
[['open', ['demo.app', 'demo://screen']]],
809+
);
810+
assert.equal(calls[0]?.flags?.maestro?.prewarmRunnerBeforeOpen, true);
811+
});
812+
771813
test('runReplayScriptFile explains empty Maestro runScript JSON bodies', async () => {
772814
const { response, calls } = await runReplayFixture({
773815
label: 'maestro-runscript-empty-json',

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2287,6 +2287,56 @@ test('open iOS Maestro app link waits for runner prewarm before launching app',
22872287
});
22882288
});
22892289

2290+
test('open iOS Maestro app link reports blocking runner prewarm failures before launching app', async () => {
2291+
const sessionStore = makeSessionStore();
2292+
const sessionName = 'ios-maestro-open-link-prewarm-failed';
2293+
sessionStore.set(sessionName, {
2294+
...makeSession(sessionName, {
2295+
platform: 'ios',
2296+
id: 'ios-device-1',
2297+
name: 'iPhone Device',
2298+
kind: 'device',
2299+
booted: true,
2300+
}),
2301+
appBundleId: 'com.example.previous',
2302+
appName: 'Previous App',
2303+
});
2304+
mockPrewarmIosRunnerSession.mockRejectedValueOnce(
2305+
new AppError('COMMAND_FAILED', 'Developer mode is disabled for Apple development tools', {
2306+
hint: 'Run `sudo DevToolsSecurity -enable`.',
2307+
}),
2308+
);
2309+
2310+
await expect(
2311+
handleSessionCommands({
2312+
req: {
2313+
token: 't',
2314+
session: sessionName,
2315+
command: 'open',
2316+
positionals: ['com.example.app', 'rne://screen-layout'],
2317+
flags: {
2318+
maestro: { prewarmRunnerBeforeOpen: true },
2319+
},
2320+
},
2321+
sessionName,
2322+
logPath: path.join(os.tmpdir(), 'daemon.log'),
2323+
sessionStore,
2324+
invoke: noopInvoke,
2325+
}),
2326+
).rejects.toMatchObject({
2327+
code: 'COMMAND_FAILED',
2328+
message: 'Developer mode is disabled for Apple development tools',
2329+
details: {
2330+
hint: expect.stringContaining('DevToolsSecurity -enable'),
2331+
},
2332+
});
2333+
expect(mockDispatch).not.toHaveBeenCalled();
2334+
expect(mockPrewarmIosRunnerSession).toHaveBeenCalledWith(
2335+
expect.objectContaining({ platform: 'ios', id: 'ios-device-1' }),
2336+
expect.objectContaining({ propagateError: true }),
2337+
);
2338+
});
2339+
22902340
test('open iOS URL without app bundle id skips runner prewarm', async () => {
22912341
const sessionStore = makeSessionStore();
22922342
const sessionName = 'ios-device-session';

src/daemon/handlers/session-open.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,10 @@ async function completeOpenCommand(params: {
198198
timing.runnerPrewarmKind = 'session';
199199
timing.runnerPrewarmScheduled = true;
200200
if (shouldPrewarmRunnerBeforeOpen) {
201-
runnerPrewarm = prewarmIosRunnerSession(device, runnerPrewarmOptions);
201+
runnerPrewarm = prewarmIosRunnerSession(device, {
202+
...runnerPrewarmOptions,
203+
propagateError: true,
204+
});
202205
const runnerPrewarmStartedAtMs = Date.now();
203206
await runnerPrewarm;
204207
timing.runnerPrewarmWaited = true;

src/platforms/ios/__tests__/runner-command-retry.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,25 @@ test('prewarmIosRunnerSession proves cached runner health with uptime', async ()
196196
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[4], 45_000);
197197
});
198198

199+
test('prewarmIosRunnerSession can propagate setup failures for blocking callers', async () => {
200+
const failure = new AppError('COMMAND_FAILED', 'Developer mode is disabled');
201+
mockEnsureRunnerSession.mockRejectedValueOnce(failure);
202+
const prewarm = prewarmIosRunnerSession(IOS_SIMULATOR, { propagateError: true });
203+
204+
assert.ok(prewarm);
205+
await assert.rejects(prewarm, (error: unknown) => error === failure);
206+
207+
assert.deepEqual(mockEmitDiagnostic.mock.calls[0]?.[0], {
208+
level: 'warn',
209+
phase: 'ios_runner_session_prewarm_failed',
210+
data: {
211+
deviceId: IOS_SIMULATOR.id,
212+
error: 'Developer mode is disabled',
213+
},
214+
});
215+
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.propagateError, undefined);
216+
});
217+
199218
test('prepareIosRunner does not force a rebuild when the relaunched fresh session still cannot connect', async () => {
200219
const missArtifact = makeRunnerArtifact({
201220
xctestrunPath: '/tmp/miss.xctestrun',

src/platforms/ios/__tests__/runner-session.test.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import fs from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
66
import { beforeEach, test, vi } from 'vitest';
7-
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
7+
import { IOS_DEVICE, IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
88
import { AppError } from '../../../utils/errors.ts';
99
import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts';
1010
import type { RunnerSession } from '../runner-session-types.ts';
@@ -612,18 +612,9 @@ test('runner session starts xcodebuild through provider seams and reuses an aliv
612612
await stopRunnerSession(session);
613613
});
614614

615-
test('runner session fails early when Apple developer mode is disabled', async () => {
616-
const device = { ...IOS_SIMULATOR, id: 'runner-session-devtools-disabled-sim' };
617-
mockRunAppleToolCommand.mockImplementation(async (cmd, args) => {
618-
if (cmd === 'DevToolsSecurity' && args[0] === '-status') {
619-
return {
620-
exitCode: 0,
621-
stdout: 'Developer mode is currently disabled.\n',
622-
stderr: '',
623-
};
624-
}
625-
return { exitCode: 0, stdout: '', stderr: '' };
626-
});
615+
test('runner session fails early for physical iOS devices when Apple developer mode is disabled', async () => {
616+
const device = { ...IOS_DEVICE, id: 'runner-session-devtools-disabled-device' };
617+
mockDevToolsSecurityDisabled();
627618

628619
await assert.rejects(
629620
() => ensureRunnerSession(device, {}),
@@ -640,6 +631,18 @@ test('runner session fails early when Apple developer mode is disabled', async (
640631
assert.equal(mockRunCmdBackground.mock.calls.length, 0);
641632
});
642633

634+
test('runner session does not require Apple developer mode for iOS simulators', async () => {
635+
const device = { ...IOS_SIMULATOR, id: 'runner-session-devtools-disabled-sim' };
636+
mockDevToolsSecurityDisabled();
637+
638+
const session = await ensureRunnerSession(device, {});
639+
640+
assert.equal(session.deviceId, device.id);
641+
assert.equal(mockEnsureXctestrunArtifact.mock.calls.length, 1);
642+
assert.equal(mockRunCmdBackground.mock.calls.length, 1);
643+
assert.equal(mockRunAppleToolCommand.mock.calls.some(isDevToolsSecurityStatusCall), false);
644+
});
645+
643646
test('runner session startup kills legacy ownerless xcodebuild before launching a new runner', async () => {
644647
const device = { ...IOS_SIMULATOR, id: 'runner-session-startup-stale-sim' };
645648

@@ -884,6 +887,24 @@ function isXcodebuildPkillCall(call: unknown[]): boolean {
884887
return call[0] === 'pkill' && Array.isArray(args) && args.includes('-f');
885888
}
886889

890+
function isDevToolsSecurityStatusCall(call: unknown[]): boolean {
891+
const args = call[1];
892+
return call[0] === 'DevToolsSecurity' && Array.isArray(args) && args[0] === '-status';
893+
}
894+
895+
function mockDevToolsSecurityDisabled(): void {
896+
mockRunAppleToolCommand.mockImplementation(async (cmd, args) => {
897+
if (cmd === 'DevToolsSecurity' && args[0] === '-status') {
898+
return {
899+
exitCode: 0,
900+
stdout: 'Developer mode is currently disabled.\n',
901+
stderr: '',
902+
};
903+
}
904+
return { exitCode: 0, stdout: '', stderr: '' };
905+
});
906+
}
907+
887908
function isSimctlTerminateCall(call: unknown[]): boolean {
888909
const args = call[0];
889910
return Array.isArray(args) && args.includes('simctl') && args.includes('terminate');

src/platforms/ios/runner-client.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,19 @@ export async function runIosRunnerCommand(
5959
return provider.runCommand(device, runnerCommand, options);
6060
}
6161

62+
type PrewarmIosRunnerSessionOptions = RunnerSessionOptions & {
63+
propagateError?: boolean;
64+
};
65+
6266
export function prewarmIosRunnerSession(
6367
device: DeviceInfo,
64-
options: RunnerSessionOptions = {},
68+
options: PrewarmIosRunnerSessionOptions = {},
6569
): Promise<void> | undefined {
6670
if (device.platform !== 'ios') {
6771
return undefined;
6872
}
69-
const provider = resolveAppleRunnerRuntime(device, options);
73+
const { propagateError = false, ...runnerOptions } = options;
74+
const provider = resolveAppleRunnerRuntime(device, runnerOptions);
7075
if (!provider.prewarm) {
7176
emitDiagnostic({
7277
level: 'debug',
@@ -76,7 +81,7 @@ export function prewarmIosRunnerSession(
7681
return undefined;
7782
}
7883
const prewarm = provider
79-
.prewarm(device, options)
84+
.prewarm(device, runnerOptions)
8085
.then(() => {})
8186
.catch((error: unknown) => {
8287
emitDiagnostic({
@@ -87,6 +92,9 @@ export function prewarmIosRunnerSession(
8792
error: error instanceof Error ? error.message : String(error),
8893
},
8994
});
95+
if (propagateError) {
96+
throw error;
97+
}
9098
});
9199
void prewarm;
92100
return prewarm;

src/platforms/ios/runner-session.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ async function ensureBooted(device: DeviceInfo): Promise<void> {
464464
}
465465

466466
async function verifyDeveloperModeForIosRunner(device: DeviceInfo): Promise<void> {
467-
if (device.platform !== 'ios') return;
467+
if (device.platform !== 'ios' || device.kind !== 'device') return;
468468
const result = await runAppleToolCommand('DevToolsSecurity', ['-status'], {
469469
allowFailure: true,
470470
timeoutMs: 2_000,

0 commit comments

Comments
 (0)