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
84 changes: 61 additions & 23 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,50 +19,88 @@ 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

- 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
Expand Down
54 changes: 52 additions & 2 deletions src/platforms/ios/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
Expand All @@ -40,7 +50,35 @@ export async function openIosApp(device: DeviceInfo, app: string): Promise<void>
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', [
Expand Down Expand Up @@ -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 }[]> {
Expand Down Expand Up @@ -365,7 +415,7 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
async function getSimulatorState(udid: string): Promise<string | null> {
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 {
Expand Down
12 changes: 10 additions & 2 deletions src/platforms/ios/runner-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,7 @@ async function ensureXctestrun(
udid: string,
options: { verbose?: boolean; logPath?: string; traceLogPath?: string },
): Promise<string> {
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 });
Expand Down Expand Up @@ -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 }[] = [];
Expand Down
7 changes: 4 additions & 3 deletions test/integration/ios.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,22 @@ 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 () => {
const integration = createIntegrationTestContext({
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}`,
Expand Down
Loading