Skip to content
Open
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
44 changes: 44 additions & 0 deletions src/platforms/ios/__tests__/runner-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,34 @@ 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: '' };
});

await assert.rejects(
() => ensureRunnerSession(device, {}),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'COMMAND_FAILED');
assert.match(error.message, /Developer mode is disabled/);
assert.match(String(error.details?.hint ?? ''), /DevToolsSecurity -enable/);
return true;
},
);

assert.equal(mockEnsureXctestrunArtifact.mock.calls.length, 0);
assert.equal(mockRunCmdBackground.mock.calls.length, 0);
});

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 @@ -774,6 +802,17 @@ test('runner session stop sends shutdown, cleans temporary runner files, and rel
['/tmp/session-runner.xctestrun'],
['/tmp/session-runner.json'],
]);
const terminateCalls = mockRunXcrun.mock.calls.filter(isSimctlTerminateCall);
assert.equal(
terminateCalls.some((call) =>
call[0]?.includes('com.callstack.agentdevice.runner.uitests.xctrunner'),
),
true,
);
assert.equal(
terminateCalls.every((call) => call[1]?.timeoutMs === 2_000),
true,
);
assert.equal(mockRedirectRelease.mock.calls.length, 1);
assert.equal(getRunnerSessionSnapshot(device.id), null);
});
Expand Down Expand Up @@ -822,6 +861,11 @@ function isXcodebuildPkillCall(call: unknown[]): boolean {
return call[0] === 'pkill' && Array.isArray(args) && args.includes('-f');
}

function isSimctlTerminateCall(call: unknown[]): boolean {
const args = call[0];
return Array.isArray(args) && args.includes('simctl') && args.includes('terminate');
}

test('runner session invalidation skips graceful shutdown and removes stale session', async () => {
const device = { ...IOS_SIMULATOR, id: 'runner-session-invalidate-sim' };
const session = await ensureRunnerSession(device, {});
Expand Down
34 changes: 32 additions & 2 deletions src/platforms/ios/runner-disposal.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { emitDiagnostic } from '../../utils/diagnostics.ts';
import type { DeviceInfo } from '../../utils/device.ts';
import { isProcessAlive, isProcessGroupAlive } from '../../utils/process-identity.ts';
import { cleanupTempFile, waitForRunner } from './runner-transport.ts';
import { withRunnerCommandId, type RunnerCommand } from './runner-contract.ts';
Expand All @@ -7,15 +8,17 @@ import {
releaseRunnerLease,
type RunnerLeaseCleanupAdapter,
} from './runner-lease.ts';
import { runnerPrepProcesses } from './runner-xctestrun.ts';
import { runAppleToolCommand } from './tool-provider.ts';
import { IOS_RUNNER_CONTAINER_BUNDLE_IDS, runnerPrepProcesses } from './runner-xctestrun.ts';
import { buildSimctlArgsForDevice } from './simctl.ts';
import { runAppleToolCommand, runXcrun } from './tool-provider.ts';
import type { RunnerSession } from './runner-session-types.ts';

export const RUNNER_INVALIDATE_WAIT_TIMEOUT_MS = 1_000;

const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
const RUNNER_STALE_XCODEBUILD_KILL_TIMEOUT_MS = 2_000;
const RUNNER_SIMULATOR_TERMINATE_TIMEOUT_MS = 2_000;

export const runnerLeaseCleanupAdapter: RunnerLeaseCleanupAdapter = {
cleanupRunnerProcessTree: killRunnerProcessTree,
Expand Down Expand Up @@ -104,6 +107,7 @@ async function waitForRunnerProcessExit(
}

async function cleanupRunnerSessionResources(session: RunnerSession): Promise<void> {
await terminateRunnerSimulatorApps(session.device);
cleanupTempFile(session.xctestrunPath);
cleanupTempFile(session.jsonPath);
try {
Expand All @@ -113,6 +117,32 @@ async function cleanupRunnerSessionResources(session: RunnerSession): Promise<vo
}
}

async function terminateRunnerSimulatorApps(device: DeviceInfo): Promise<void> {
if (device.kind !== 'simulator') return;

await Promise.allSettled(
IOS_RUNNER_CONTAINER_BUNDLE_IDS.map(async (bundleId) => {
try {
await runXcrun(buildSimctlArgsForDevice(device, ['terminate', device.id, bundleId]), {
allowFailure: true,
timeoutMs: RUNNER_SIMULATOR_TERMINATE_TIMEOUT_MS,
});
} catch (error) {
emitDiagnostic({
level: 'warn',
phase: 'ios_runner_simulator_app_terminate_failed',
data: {
deviceId: device.id,
bundleId,
timeoutMs: RUNNER_SIMULATOR_TERMINATE_TIMEOUT_MS,
error: error instanceof Error ? error.message : String(error),
},
});
}
}),
);
}

function isRunnerProcessTreeAlive(pid: number | undefined): boolean {
if (!pid) return false;
return isRunnerProcessAlive(pid) || isProcessGroupAlive(pid);
Expand Down
19 changes: 18 additions & 1 deletion src/platforms/ios/runner-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { DeviceInfo } from '../../utils/device.ts';
import type { AppleRunnerLifecycleOptions } from './runner-provider.ts';
import { emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts';
import { buildSimctlArgsForDevice } from './simctl.ts';
import { runXcrun } from './tool-provider.ts';
import { runAppleToolCommand, runXcrun } from './tool-provider.ts';
import {
waitForRunner,
sendRunnerCommandOnce,
Expand Down Expand Up @@ -123,6 +123,9 @@ async function startRunnerSessionWithLease(
await measureRunnerStartupStep(startupTimings, 'ensure_booted', async () => {
await ensureBootedIfNeeded(device);
});
await measureRunnerStartupStep(startupTimings, 'verify_developer_mode', async () => {
await verifyDeveloperModeForIosRunner(device);
});
if (options.cleanStaleBundles) {
await measureRunnerStartupStep(startupTimings, 'cleanup_stale_bundles', async () => {
await cleanupStaleSimulatorRunnerBundles(device);
Expand Down Expand Up @@ -444,6 +447,20 @@ async function ensureBooted(device: DeviceInfo): Promise<void> {
});
}

async function verifyDeveloperModeForIosRunner(device: DeviceInfo): Promise<void> {
if (device.platform !== 'ios') return;
const result = await runAppleToolCommand('DevToolsSecurity', ['-status'], {
allowFailure: true,
timeoutMs: 2_000,
});
const output = `${result.stdout}\n${result.stderr}`;
if (!/developer mode is currently disabled/i.test(output)) return;
throw new AppError('COMMAND_FAILED', 'Developer mode is disabled for Apple development tools', {
hint: 'Run `sudo DevToolsSecurity -enable`, then retry the iOS runner. UI test runners start suspended until Xcode/testmanagerd can attach.',
devToolsSecurityStatus: output.trim(),
});
}

export function validateRunnerDevice(device: DeviceInfo): void {
if (device.platform !== 'ios' && device.platform !== 'macos') {
throw new AppError(
Expand Down