Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
IOS_RUNTIME_VERSION: '26.2'
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS: '420000'
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down Expand Up @@ -57,7 +58,7 @@ jobs:
- name: Prepare iOS runner
run: |
pnpm clean:daemon
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 300000 --json
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout "$AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS" --json
pnpm clean:daemon

- name: Run iOS simulator smoke replay
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/perf-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
IOS_RUNTIME_VERSION: "26.2"
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS: "420000"
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down Expand Up @@ -63,7 +64,7 @@ jobs:
- name: Prepare iOS runner
run: |
pnpm clean:daemon
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout "$AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS"
pnpm clean:daemon

- name: Run iOS command perf benchmark
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/replays-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ jobs:
IOS_RUNTIME_VERSION: '26.2'
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS: '420000'
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down Expand Up @@ -80,7 +81,7 @@ jobs:
- name: Prepare iOS runner
run: |
pnpm clean:daemon
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 300000 --json
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout "$AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS" --json
pnpm clean:daemon

- name: Run iOS simulator replay suite
Expand Down
42 changes: 42 additions & 0 deletions src/daemon/handlers/__tests__/session-replay-vars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,48 @@ test('runReplayScriptFile reports Maestro runScript failures at the runScript st
assert.equal(calls.length, 0);
});

test('runReplayScriptFile reports iOS Maestro openLink setup failures before assertions', async () => {
const { response, calls } = await runReplayFixture({
label: 'maestro-ios-openlink-prewarm-fail',
script: [
'appId: demo.app',
'---',
'- openLink: demo://screen',
'- assertVisible: Ready',
'',
].join('\n'),
flags: { replayBackend: 'maestro', platform: 'ios' },
invoke: async (req) => {
if (req.command === 'open') {
return {
ok: false,
error: {
code: 'COMMAND_FAILED',
message: 'Developer mode is disabled for Apple development tools',
details: {
hint: 'Run `sudo DevToolsSecurity -enable`.',
},
},
};
}
return { ok: true, data: {} };
},
});

assert.equal(response.ok, false);
if (!response.ok) {
assert.match(response.error.message, /Replay failed at step 1/);
assert.match(response.error.message, /open "demo\.app" "demo:\/\/screen"/);
assert.match(response.error.message, /Developer mode is disabled/);
assert.match(String(response.error.details?.hint ?? ''), /DevToolsSecurity -enable/);
}
assert.deepEqual(
calls.map((call) => [call.command, call.positionals]),
[['open', ['demo.app', 'demo://screen']]],
);
assert.equal(calls[0]?.flags?.maestro?.prewarmRunnerBeforeOpen, true);
});

test('runReplayScriptFile explains empty Maestro runScript JSON bodies', async () => {
const { response, calls } = await runReplayFixture({
label: 'maestro-runscript-empty-json',
Expand Down
50 changes: 50 additions & 0 deletions src/daemon/handlers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2287,6 +2287,56 @@ test('open iOS Maestro app link waits for runner prewarm before launching app',
});
});

test('open iOS Maestro app link reports blocking runner prewarm failures before launching app', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-maestro-open-link-prewarm-failed';
sessionStore.set(sessionName, {
...makeSession(sessionName, {
platform: 'ios',
id: 'ios-device-1',
name: 'iPhone Device',
kind: 'device',
booted: true,
}),
appBundleId: 'com.example.previous',
appName: 'Previous App',
});
mockPrewarmIosRunnerSession.mockRejectedValueOnce(
new AppError('COMMAND_FAILED', 'Developer mode is disabled for Apple development tools', {
hint: 'Run `sudo DevToolsSecurity -enable`.',
}),
);

await expect(
handleSessionCommands({
req: {
token: 't',
session: sessionName,
command: 'open',
positionals: ['com.example.app', 'rne://screen-layout'],
flags: {
maestro: { prewarmRunnerBeforeOpen: true },
},
},
sessionName,
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
}),
).rejects.toMatchObject({
code: 'COMMAND_FAILED',
message: 'Developer mode is disabled for Apple development tools',
details: {
hint: expect.stringContaining('DevToolsSecurity -enable'),
},
});
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockPrewarmIosRunnerSession).toHaveBeenCalledWith(
expect.objectContaining({ platform: 'ios', id: 'ios-device-1' }),
expect.objectContaining({ propagateError: true }),
);
});

test('open iOS URL without app bundle id skips runner prewarm', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-device-session';
Expand Down
5 changes: 4 additions & 1 deletion src/daemon/handlers/session-open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,10 @@ async function completeOpenCommand(params: {
timing.runnerPrewarmKind = 'session';
timing.runnerPrewarmScheduled = true;
if (shouldPrewarmRunnerBeforeOpen) {
runnerPrewarm = prewarmIosRunnerSession(device, runnerPrewarmOptions);
runnerPrewarm = prewarmIosRunnerSession(device, {
...runnerPrewarmOptions,
propagateError: true,
});
const runnerPrewarmStartedAtMs = Date.now();
await runnerPrewarm;
timing.runnerPrewarmWaited = true;
Expand Down
19 changes: 19 additions & 0 deletions src/platforms/ios/__tests__/runner-command-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,25 @@ test('prewarmIosRunnerSession proves cached runner health with uptime', async ()
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[0]?.[4], 45_000);
});

test('prewarmIosRunnerSession can propagate setup failures for blocking callers', async () => {
const failure = new AppError('COMMAND_FAILED', 'Developer mode is disabled');
mockEnsureRunnerSession.mockRejectedValueOnce(failure);
const prewarm = prewarmIosRunnerSession(IOS_SIMULATOR, { propagateError: true });

assert.ok(prewarm);
await assert.rejects(prewarm, (error: unknown) => error === failure);

assert.deepEqual(mockEmitDiagnostic.mock.calls[0]?.[0], {
level: 'warn',
phase: 'ios_runner_session_prewarm_failed',
data: {
deviceId: IOS_SIMULATOR.id,
error: 'Developer mode is disabled',
},
});
assert.equal(mockEnsureRunnerSession.mock.calls[0]?.[1]?.propagateError, undefined);
});

test('prepareIosRunner does not force a rebuild when the relaunched fresh session still cannot connect', async () => {
const missArtifact = makeRunnerArtifact({
xctestrunPath: '/tmp/miss.xctestrun',
Expand Down
47 changes: 34 additions & 13 deletions src/platforms/ios/__tests__/runner-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { beforeEach, test, vi } from 'vitest';
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
import { IOS_DEVICE, IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
import { AppError } from '../../../utils/errors.ts';
import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts';
import type { RunnerSession } from '../runner-session-types.ts';
Expand Down Expand Up @@ -612,18 +612,9 @@ test('runner session starts xcodebuild through provider seams and reuses an aliv
await stopRunnerSession(session);
});

test('runner session fails early when Apple developer mode is disabled', async () => {
const device = { ...IOS_SIMULATOR, id: 'runner-session-devtools-disabled-sim' };
mockRunAppleToolCommand.mockImplementation(async (cmd, args) => {
if (cmd === 'DevToolsSecurity' && args[0] === '-status') {
return {
exitCode: 0,
stdout: 'Developer mode is currently disabled.\n',
stderr: '',
};
}
return { exitCode: 0, stdout: '', stderr: '' };
});
test('runner session fails early for physical iOS devices when Apple developer mode is disabled', async () => {
const device = { ...IOS_DEVICE, id: 'runner-session-devtools-disabled-device' };
mockDevToolsSecurityDisabled();

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

test('runner session does not require Apple developer mode for iOS simulators', async () => {
const device = { ...IOS_SIMULATOR, id: 'runner-session-devtools-disabled-sim' };
mockDevToolsSecurityDisabled();

const session = await ensureRunnerSession(device, {});

assert.equal(session.deviceId, device.id);
assert.equal(mockEnsureXctestrunArtifact.mock.calls.length, 1);
assert.equal(mockRunCmdBackground.mock.calls.length, 1);
assert.equal(mockRunAppleToolCommand.mock.calls.some(isDevToolsSecurityStatusCall), false);
});

test('runner session startup kills legacy ownerless xcodebuild before launching a new runner', async () => {
const device = { ...IOS_SIMULATOR, id: 'runner-session-startup-stale-sim' };

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

function isDevToolsSecurityStatusCall(call: unknown[]): boolean {
const args = call[1];
return call[0] === 'DevToolsSecurity' && Array.isArray(args) && args[0] === '-status';
}

function mockDevToolsSecurityDisabled(): void {
mockRunAppleToolCommand.mockImplementation(async (cmd, args) => {
if (cmd === 'DevToolsSecurity' && args[0] === '-status') {
return {
exitCode: 0,
stdout: 'Developer mode is currently disabled.\n',
stderr: '',
};
}
return { exitCode: 0, stdout: '', stderr: '' };
});
}

function isSimctlTerminateCall(call: unknown[]): boolean {
const args = call[0];
return Array.isArray(args) && args.includes('simctl') && args.includes('terminate');
Expand Down
14 changes: 11 additions & 3 deletions src/platforms/ios/runner-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,19 @@ export async function runIosRunnerCommand(
return provider.runCommand(device, runnerCommand, options);
}

type PrewarmIosRunnerSessionOptions = RunnerSessionOptions & {
propagateError?: boolean;
};

export function prewarmIosRunnerSession(
device: DeviceInfo,
options: RunnerSessionOptions = {},
options: PrewarmIosRunnerSessionOptions = {},
): Promise<void> | undefined {
if (device.platform !== 'ios') {
return undefined;
}
const provider = resolveAppleRunnerRuntime(device, options);
const { propagateError = false, ...runnerOptions } = options;
const provider = resolveAppleRunnerRuntime(device, runnerOptions);
if (!provider.prewarm) {
emitDiagnostic({
level: 'debug',
Expand All @@ -76,7 +81,7 @@ export function prewarmIosRunnerSession(
return undefined;
}
const prewarm = provider
.prewarm(device, options)
.prewarm(device, runnerOptions)
.then(() => {})
.catch((error: unknown) => {
emitDiagnostic({
Expand All @@ -87,6 +92,9 @@ export function prewarmIosRunnerSession(
error: error instanceof Error ? error.message : String(error),
},
});
if (propagateError) {
throw error;
}
});
void prewarm;
return prewarm;
Expand Down
2 changes: 1 addition & 1 deletion src/platforms/ios/runner-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ async function ensureBooted(device: DeviceInfo): Promise<void> {
}

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