Skip to content

Commit b2be474

Browse files
committed
fix: report maestro ios runner setup failures
1 parent 0b499f2 commit b2be474

8 files changed

Lines changed: 163 additions & 22 deletions

File tree

src/compat/maestro/support-matrix.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,9 @@ export const MAESTRO_COMPAT_UNSUPPORTED_CAPABILITIES = [
2525
'Android app state reset',
2626
] as const;
2727

28-
export const MAESTRO_COMPAT_TRACKER_URL =
29-
'https://github.com/callstack/agent-device/issues/558';
28+
export const MAESTRO_COMPAT_TRACKER_URL = 'https://github.com/callstack/agent-device/issues/558';
3029

31-
export const MAESTRO_NEW_ISSUE_URL =
32-
'https://github.com/callstack/agent-device/issues/new';
30+
export const MAESTRO_NEW_ISSUE_URL = 'https://github.com/callstack/agent-device/issues/new';
3331

3432
export function formatMaestroSupportedSubsetForCli(): string {
3533
return `Supported subset: ${formatMaestroCapabilityList(MAESTRO_COMPAT_SUPPORTED_CAPABILITIES)}.`;

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
@@ -195,7 +195,10 @@ async function completeOpenCommand(params: {
195195
timing.runnerPrewarmKind = 'session';
196196
timing.runnerPrewarmScheduled = true;
197197
if (shouldPrewarmRunnerBeforeOpen) {
198-
runnerPrewarm = prewarmIosRunnerSession(device, runnerPrewarmOptions);
198+
runnerPrewarm = prewarmIosRunnerSession(device, {
199+
...runnerPrewarmOptions,
200+
propagateError: true,
201+
});
199202
const runnerPrewarmStartedAtMs = Date.now();
200203
await runnerPrewarm;
201204
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';
@@ -609,18 +609,9 @@ test('runner session starts xcodebuild through provider seams and reuses an aliv
609609
await stopRunnerSession(session);
610610
});
611611

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

625616
await assert.rejects(
626617
() => ensureRunnerSession(device, {}),
@@ -637,6 +628,18 @@ test('runner session fails early when Apple developer mode is disabled', async (
637628
assert.equal(mockRunCmdBackground.mock.calls.length, 0);
638629
});
639630

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

@@ -861,6 +864,24 @@ function isXcodebuildPkillCall(call: unknown[]): boolean {
861864
return call[0] === 'pkill' && Array.isArray(args) && args.includes('-f');
862865
}
863866

867+
function isDevToolsSecurityStatusCall(call: unknown[]): boolean {
868+
const args = call[1];
869+
return call[0] === 'DevToolsSecurity' && Array.isArray(args) && args[0] === '-status';
870+
}
871+
872+
function mockDevToolsSecurityDisabled(): void {
873+
mockRunAppleToolCommand.mockImplementation(async (cmd, args) => {
874+
if (cmd === 'DevToolsSecurity' && args[0] === '-status') {
875+
return {
876+
exitCode: 0,
877+
stdout: 'Developer mode is currently disabled.\n',
878+
stderr: '',
879+
};
880+
}
881+
return { exitCode: 0, stdout: '', stderr: '' };
882+
});
883+
}
884+
864885
function isSimctlTerminateCall(call: unknown[]): boolean {
865886
const args = call[0];
866887
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
@@ -449,7 +449,7 @@ async function ensureBooted(device: DeviceInfo): Promise<void> {
449449
}
450450

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

0 commit comments

Comments
 (0)