diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index f3ba43eab..b7987ac38 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -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' }; @@ -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); }); @@ -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, {}); diff --git a/src/platforms/ios/runner-disposal.ts b/src/platforms/ios/runner-disposal.ts index 886286ea7..ebb6a8271 100644 --- a/src/platforms/ios/runner-disposal.ts +++ b/src/platforms/ios/runner-disposal.ts @@ -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'; @@ -7,8 +8,9 @@ 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; @@ -16,6 +18,7 @@ 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, @@ -104,6 +107,7 @@ async function waitForRunnerProcessExit( } async function cleanupRunnerSessionResources(session: RunnerSession): Promise { + await terminateRunnerSimulatorApps(session.device); cleanupTempFile(session.xctestrunPath); cleanupTempFile(session.jsonPath); try { @@ -113,6 +117,32 @@ async function cleanupRunnerSessionResources(session: RunnerSession): Promise { + 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); diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 83e5fbcd1..2a696e81e 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -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, @@ -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); @@ -444,6 +447,20 @@ async function ensureBooted(device: DeviceInfo): Promise { }); } +async function verifyDeveloperModeForIosRunner(device: DeviceInfo): Promise { + 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(