diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 2e48edc78..3893243b0 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -19,6 +19,14 @@ jobs: runs-on: macos-26 timeout-minutes: 80 continue-on-error: true + env: + IOS_RUNTIME_VERSION: '26.2' + DERIVED_DATA_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived + AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived + AGENT_DEVICE_IOS_SIMCTL_LIST_TIMEOUT_MS: "60000" + AGENT_DEVICE_DAEMON_TIMEOUT_MS: "300000" + AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS: "180000" + AGENT_DEVICE_IOS_APP_LAUNCH_TIMEOUT_MS: "60000" steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -26,43 +34,73 @@ jobs: - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm + - name: Resolve Xcode cache key + id: xcode + run: | + set -euo pipefail + XCODE_VERSION="$(xcodebuild -version | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g; s/[[:space:]]$//')" + XCODE_KEY="$(echo "$XCODE_VERSION" | tr ' ' '-' | tr -cd '[:alnum:]._-')" + echo "key=$XCODE_KEY" >> "$GITHUB_OUTPUT" + + - name: Resolve prebuild source hash + id: source-hash + run: echo "value=${{ hashFiles('ios-runner/**', 'package.json', 'pnpm-lock.yaml') }}" >> "$GITHUB_OUTPUT" + + - name: Cache iOS runner prebuilt + id: restore-prebuilt + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3 + with: + path: ${{ env.DERIVED_DATA_PATH }} + key: ios-runner-prebuilt-${{ steps.xcode.outputs.key }}-ios-${{ env.IOS_RUNTIME_VERSION }}-${{ steps.source-hash.outputs.value }} + - name: Resolve agent-device home id: ios-agent-home run: echo "dir=$HOME/.agent-device" >> "$GITHUB_OUTPUT" - - name: Select and start iOS simulator + - name: Build iOS integration artifacts + if: steps.restore-prebuilt.outputs.cache-hit != 'true' + run: | + set -euo pipefail + rm -rf "$DERIVED_DATA_PATH" + xcodebuild build-for-testing \ + -project ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj \ + -scheme AgentDeviceRunner \ + -destination "platform=iOS Simulator,name=iPhone 17 Pro,OS=${IOS_RUNTIME_VERSION}" \ + -derivedDataPath "$DERIVED_DATA_PATH" + + - name: Resolve and boot iOS test simulator run: | + set -euo pipefail + RUNTIME_TOKEN="SimRuntime.iOS-${IOS_RUNTIME_VERSION//./-}" + export RUNTIME_TOKEN UDID="$( - xcrun simctl list devices -j | node -e " - const fs = require('node:fs'); - const payload = JSON.parse(fs.readFileSync(0, 'utf8')); - const all = Object.values(payload.devices ?? {}).flat(); - const available = all.filter((d) => d.isAvailable); + xcrun simctl list devices -j | node -e ' + const fs = require("node:fs"); + const runtimeToken = process.env.RUNTIME_TOKEN; + const payload = JSON.parse(fs.readFileSync(0, "utf8")); + const entries = Object.entries(payload.devices ?? {}); + const iosRuntimes = entries.filter(([runtime]) => runtime.includes("SimRuntime.iOS-")); + const runtimeMatches = iosRuntimes.filter(([runtime]) => runtime.includes(runtimeToken)); + const pool = runtimeMatches.length > 0 ? runtimeMatches : iosRuntimes; + const available = pool.flatMap(([runtime, devices]) => + (devices ?? []) + .filter((device) => device.isAvailable) + .map((device) => ({ ...device, runtime })), + ); const preferred = - available.find((d) => d.state === 'Booted') ?? - available.find((d) => d.name === 'iPhone 17 Pro') ?? + available.find((device) => device.state === "Booted" && device.name === "iPhone 17 Pro") ?? + available.find((device) => device.name === "iPhone 17 Pro") ?? + available.find((device) => device.state === "Booted") ?? available[0]; if (!preferred?.udid) process.exit(1); process.stdout.write(preferred.udid); - " + ' )" + xcrun simctl shutdown all || true xcrun simctl boot "$UDID" || true - echo "IOS_UDID=$UDID" >> "$GITHUB_ENV" - - - name: Build iOS integration artifacts - run: pnpm build:xcuitest - - - name: Boot preflight via agent-device - run: | - set -euo pipefail - node --experimental-strip-types src/bin.ts boot --platform ios --udid "$IOS_UDID" --json - env: - AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS: "180000" - AGENT_DEVICE_RETRY_LOGS: "1" + xcrun simctl bootstatus "$UDID" -b - name: Run iOS integration test - env: - AGENT_DEVICE_DAEMON_TIMEOUT_MS: "300000" run: node --test test/integration/ios.test.ts - name: Upload iOS artifacts diff --git a/src/platforms/ios/index.ts b/src/platforms/ios/index.ts index 9dbf8fd13..5064a17c0 100644 --- a/src/platforms/ios/index.ts +++ b/src/platforms/ios/index.ts @@ -14,6 +14,16 @@ const IOS_BOOT_TIMEOUT_MS = resolveTimeoutMs( TIMEOUT_PROFILES.ios_boot.totalMs, 5_000, ); +const IOS_SIMCTL_LIST_TIMEOUT_MS = resolveTimeoutMs( + process.env.AGENT_DEVICE_IOS_SIMCTL_LIST_TIMEOUT_MS, + TIMEOUT_PROFILES.ios_boot.operationMs, + 1_000, +); +const IOS_APP_LAUNCH_TIMEOUT_MS = resolveTimeoutMs( + process.env.AGENT_DEVICE_IOS_APP_LAUNCH_TIMEOUT_MS, + 30_000, + 5_000, +); const RETRY_LOGS_ENABLED = isEnvTruthy(process.env.AGENT_DEVICE_RETRY_LOGS); export async function resolveIosApp(device: DeviceInfo, app: string): Promise { @@ -40,7 +50,35 @@ export async function openIosApp(device: DeviceInfo, app: string): Promise if (device.kind === 'simulator') { await ensureBootedSimulator(device); await runCmd('open', ['-a', 'Simulator'], { allowFailure: true }); - await runCmd('xcrun', ['simctl', 'launch', device.id, bundleId]); + const launchDeadline = Deadline.fromTimeoutMs(IOS_APP_LAUNCH_TIMEOUT_MS); + await retryWithPolicy( + async ({ deadline: attemptDeadline }) => { + if (attemptDeadline?.isExpired()) { + throw new AppError('COMMAND_FAILED', 'App launch deadline exceeded', { + timeoutMs: IOS_APP_LAUNCH_TIMEOUT_MS, + }); + } + const result = await runCmd('xcrun', ['simctl', 'launch', device.id, bundleId], { + allowFailure: true, + }); + if (result.exitCode === 0) return; + throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, { + cmd: 'xcrun', + args: ['simctl', 'launch', device.id, bundleId], + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + }, + { + maxAttempts: 30, + baseDelayMs: 1_000, + maxDelayMs: 5_000, + jitter: 0.2, + shouldRetry: isTransientSimulatorLaunchFailure, + }, + { deadline: launchDeadline }, + ); return; } await runCmd('xcrun', [ @@ -208,6 +246,18 @@ function parseSettingState(state: string): boolean { throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`); } +function isTransientSimulatorLaunchFailure(error: unknown): boolean { + if (!(error instanceof AppError)) return false; + if (error.code !== 'COMMAND_FAILED') return false; + const details = (error.details ?? {}) as { exitCode?: number; stderr?: unknown }; + if (details.exitCode !== 4) return false; + const stderr = String(details.stderr ?? '').toLowerCase(); + return ( + stderr.includes('fbsopenapplicationserviceerrordomain') && + stderr.includes('the request to open') + ); +} + export async function listSimulatorApps( device: DeviceInfo, ): Promise<{ bundleId: string; name: string }[]> { @@ -365,7 +415,7 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise { async function getSimulatorState(udid: string): Promise { const result = await runCmd('xcrun', ['simctl', 'list', 'devices', '-j'], { allowFailure: true, - timeoutMs: TIMEOUT_PROFILES.ios_boot.operationMs, + timeoutMs: IOS_SIMCTL_LIST_TIMEOUT_MS, }); if (result.exitCode !== 0) return null; try { diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index 74597d522..6f4e71f64 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -292,8 +292,7 @@ async function ensureXctestrun( udid: string, options: { verbose?: boolean; logPath?: string; traceLogPath?: string }, ): Promise { - const base = path.join(os.homedir(), '.agent-device', 'ios-runner'); - const derived = path.join(base, 'derived'); + const derived = resolveRunnerDerivedPath(); if (shouldCleanDerived()) { try { fs.rmSync(derived, { recursive: true, force: true }); @@ -354,6 +353,15 @@ async function ensureXctestrun( return built; } +function resolveRunnerDerivedPath(): string { + const override = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim(); + if (override) { + return path.resolve(override); + } + const base = path.join(os.homedir(), '.agent-device', 'ios-runner'); + return path.join(base, 'derived'); +} + function findXctestrun(root: string): string | null { if (!fs.existsSync(root)) return null; const candidates: { path: string; mtimeMs: number }[] = []; diff --git a/test/integration/ios.test.ts b/test/integration/ios.test.ts index 7bc917ee3..67feb716a 100644 --- a/test/integration/ios.test.ts +++ b/test/integration/ios.test.ts @@ -4,9 +4,10 @@ import path from 'node:path'; import { createIntegrationTestContext, runCliJson } from './test-helpers.ts'; const session = ['--session', 'ios-test']; +const iosTarget = ['--platform', 'ios']; test.after(() => { - runCliJson(['close', '--platform', 'ios', '--json', ...session]); + runCliJson(['close', ...iosTarget, '--json', ...session]); }); test('ios settings commands', { skip: shouldSkipIos() }, async () => { @@ -14,11 +15,11 @@ test('ios settings commands', { skip: shouldSkipIos() }, async () => { platform: 'ios', testName: 'ios settings commands', }); - const openArgs = ['open', 'com.apple.Preferences', '--platform', 'ios', '--json', ...session]; + const openArgs = ['open', 'com.apple.Preferences', ...iosTarget, '--json', ...session]; integration.runStep('open settings', openArgs); const outPath = path.resolve('test/screenshots/ios-settings.png'); - const shotArgs = ['screenshot', outPath, '--platform', 'ios', '--json', ...session]; + const shotArgs = ['screenshot', outPath, ...iosTarget, '--json', ...session]; const shot = integration.runStep('screenshot settings', shotArgs); integration.assertResult(existsSync(outPath), 'screenshot file missing', shotArgs, shot, { detail: `expected screenshot file at ${outPath}`,