Skip to content

feat(platform-apple): replace the iOS XCTest agent with agent-device - #193

Open
V3RON wants to merge 12 commits into
mainfrom
feat/agent-device-permissions
Open

V3RON wants to merge 12 commits into
mainfrom
feat/agent-device-permissions

Conversation

@V3RON

@V3RON V3RON commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

What is this?

iOS permission automation (permissions: true) no longer runs on a Harness-maintained XCUITest target. It runs on agent-device, which already owns a maintained runner, and the Harness side shrinks to one TypeScript module.

The decision and its trade-offs are recorded in docs/internal/adr/0001-replace-xctest-agent-with-agent-device.md, including three dated amendments for facts that turned out differently during implementation.

The user-facing contract is unchanged: with permissions: true, Harness watches for system permission prompts while the app under test is running and answers them, and physical devices still need device.codeSign.

Removed: packages/platform-ios/xctest-agent/ (an Xcode project and ~470 lines of Swift), seven TypeScript modules (xctest-agent*.ts, xctest-command.ts, startup-strategy.ts) and their tests, the harness xctest build CLI command, the buildXCTestAgent export and its four types, the build/boot overlap strategy, and the xctest-agent-simulator cache handling in the action. Harness now contains no Swift, no Xcode project, no xcodebuild invocation and no HTTP transport for permission automation.

Added: packages/platform-ios/src/permission-agent.ts, a harness ci plan-ios-runner-cache helper for the action's cache key, an optional device.codeSign.runnerBundleId, engines.node >= 22.12 on every package, and a vite.config.ts for platform-apple, which had no test target at all — its existing tests had never run in CI.

Breaking paths

  • Physical-device runner bundle ids. The runner is now signed as com.callstack.agentdevice.runner (plus its .uitests companion). Provisioning profiles created for HarnessXCTestAgent no longer apply. Personal teams can point the runner at their own identifier with the new device.codeSign.runnerBundleId.
  • External .xctestrun artifacts. HARNESS_IOS_XCTESTRUN_FILE and HARNESS_IOS_XCTEST_DERIVED_DATA_PATH are kept, but the artifact must be an AgentDeviceRunner build produced by agent-device prepare ios-runner --json.
  • harness xctest build is removed. The docs point to agent-device prepare ios-runner.
  • Node.js 22.12 floor, declared in engines on every package. This is agent-device's floor; Harness previously declared none.
  • The action requires a matching CLI. harnessActionProtocol moves to 2, because the action now calls a harness ci subcommand older CLIs do not have.

How does it work?

After the simulator is booted and the app installed, Harness calls command.prepare({ action: 'ios-runner' }) against its own daemon, whose state dir is ~/.agent-device/harness/<projectRootHash> so it never replaces or inherits environment from a developer's own daemon. Device claims are host-global, so arbitration between the two stays correct, and a claimed device surfaces DEVICE_IN_USE verbatim with its recovery hint and is never retried.

A Node loop then answers prompts. Each tick calls alert accept, which detects and presses in one round trip; when agent-device refuses the prompt — the three-button location sheet is the usual case — it falls back to alert get plus a press by label from the Harness positive-label list. The loop is serialised (one command in flight), abort-driven, and runs only while the app session reports the app running: polling with SpringBoard's home screen foregrounded walks the whole icon grid and trips the runner's watchdog. ALERT_NOT_FOUND, RUNNER_BUSY and timeouts are transient; anything else is reported and disables the watchdog after three consecutive failures rather than failing the run.

Harness stays the sole owner of the app lifecycle — simctl/devicectl launch, kill and relaunch it exactly as before. agent-device is given one session bound to com.apple.springboard, because interactions.press refuses to run session-less; it never opens the app under test.

Teardown stops the daemon with --clean and copies the session's runner.log and daemon.log into .harness/logs, so the xcodebuild output the action used to upload is still there.

The runner build is cached in .harness/cache/agent-device-runner; action.yml keys that cache on the installed agent-device version plus xcodebuild -version, with no restore-key prefixes, since a near-miss entry cannot be reused.

ADR merge gates

  1. E2E iOS job with permissions: true — pending; it runs on this PR.

  2. Signed physical iPhone with device.codeSign — outstanding. No signed device was available to me. A maintainer with a provisioned iPhone needs to run the playground iphone-16-pro runner and confirm the runner signs and attaches under the new bundle ids, including the codeSign.runnerBundleId path for personal teams.

  3. Startup benchmark — measured locally on iPhone 17 Pro / iOS 26.4, playground permissions test:

    Scenario prepare → session ready Test Jest total
    Fresh install, TCC reset, cached runner 5.8 s 7.8 s 30.8 s
    Warm (permission already granted) 5.8 s 0.2 s 23.2 s

    A forced cold runner rebuild cost 42 s locally. A true CI cold build was not reproducible on this machine and should be read from the first job run on this branch.

  4. No process survives a run — a new if: always() step on both iOS jobs fails when pgrep -f 'AgentDeviceRunner|xcodebuild test-without-building|agent-device/dist/src/internal/daemon' matches anything.

Things a reviewer should know

  • One unexplained run. One fresh-install run failed with The native runtime disconnected during test execution moments after the prompt was accepted. The immediately repeated run passed, and the runner log for the failed one shows leftover app-switcher state on the simulator. I could not reproduce it and have not explained it.
  • In-flight miss cost. A prompt that appears while a tick is already running waits for that tick, measured at ~5 s. Documented in the permissions guide, which tells users to budget ~10 s for a test that triggers a prompt.
  • Accept presses a slightly wider set of buttons than the old positive-label list — Yes/Done/Open, or the sole button of a one-button system alert. It can never press a deny or dismiss button (verified against the runner's chooseAlertButton). Documented.
  • Stale runner cache. agent-device refuses to clean a derived-data path supplied through AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH, which Harness always sets, so a cache from a different agent-device or Xcode version used to fail every run. Harness now clears its own cache directory and retries prepare once; a path the user provided is left untouched.
  • Pre-existing bug, not fixed here. isAppInstalled lets xcrun simctl appinfo's exit code 3 escape as an unhandled SubprocessError instead of falling through to the install path, so a run against a simulator without the app installed dies during platform setup. Reproducible on main.

Verification

pnpm install; pnpm exec nx run-many -t lint,typecheck,build,test --projects="packages/*" (20 projects green; platform-apple now contributes 9 files / 66 tests); pnpm exec nx release plan:check; and the two local simulator runs above, after which no agent-device daemon, xcodebuild or AgentDeviceRunner process from the run remained.

Why is this useful?

The Xcode/SDK cache keys, physical-device signing, external .xctestrun injection, graceful-shutdown races (#139, #164, #176) and CPU tuning (#163) that this layer existed to manage are now someone else's maintained problem, and every Xcode release stops being a potential break in this repository. What is left is a small, tested Node module with explicit error classification and bounded teardown.

Along the way the replacement closes gaps the old agent had: the watchdog disables itself with a clear message instead of failing a run, teardown is bounded rather than waiting out an in-flight command, a stale runner cache heals itself, and platform-apple's test suite runs in CI for the first time.

iOS permission automation now drives agent-device's maintained XCUITest
runner through its Node client instead of a Harness-owned Swift agent,
an ad-hoc HTTP transport and a bespoke xcodebuild cache.

The user-facing contract is unchanged: with `permissions: true` Harness
watches for system permission prompts and taps the first known positive
button. The watchdog is now a Node polling loop that runs only while the
app under test is running, so SpringBoard's home screen is never walked.

Removes the Xcode project, the seven XCTest modules, the `harness xctest
build` command and the build/boot overlap strategy. Adds an optional
`codeSign.runnerBundleId` for personal teams on physical devices, and a
vitest config so this package's tests run in CI at all.
`harness ci plan-ios-runner-cache` prints a cache key derived from the
installed agent-device version and `xcodebuild -version`, and action.yml
uses it to cache the runner build in place of the removed XCTest agent
cache. The action's required CLI protocol moves to 2 because the action
now invokes a subcommand older CLIs do not have.
…ssion

agent-device's `interactions.press` refuses to run without an open
session (`SESSION_NOT_FOUND`), unlike `command.alert`, which attaches an
implicit runner session per call. Open one session bound to SpringBoard
during prepare so taps land; the app under test is never opened or
otherwise touched by agent-device.
- prepare() races agent-device commands against the init abort signal and
  stops the daemon when it loses, so no daemon, xcodebuild or runner
  survives holding the host-global device claim; the prepare budget is
  derived from platformReadyTimeout instead of a fixed 600 s.
- dispose() stops the daemon first and then drains the watchdog with a
  bound, instead of waiting out an in-flight 20 s poll.
- The daemon stop runs through runCommand (SIGKILL-bounded) and reports
  failures at warn with the recovery command; the session is closed first.
- Unclassified command failures are reported at warn with code and hint,
  and the watchdog disables itself after three consecutive ones.
- Positive labels are tapped in trimmed form, and a label carrying a quote
  or backslash is refused rather than producing a broken selector.
- User-provided AGENT_DEVICE_IOS_* values are never overwritten.

Pins agent-device to 0.21.0 exactly, bounds `xcodebuild -version` in the
runner cache planner and annotates CI when the cache is disabled, and adds
the ADR merge-gate step asserting no agent-device process survives an iOS
e2e job.
agent-device refuses to clean a derived-data path supplied through
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH, which Harness always sets, so a
runner cache left over from a different agent-device, Xcode or SDK version
failed every run with an error naming an environment variable the user
never set. The cache directory belongs to Harness, so clear it and retry
the prepare once; a path the user provided is still left untouched and the
original error surfaces.
Each watchdog tick now calls `alert accept` directly instead of
`alert get` followed by `press`, so a two-button prompt costs one runner
round trip rather than two. The runner's accept can only activate a button
from its accept list, or the sole button of a one-button alert when that
label is not a dismiss label, so it can never press deny or cancel.

Prompts it refuses -- above all the three-button location sheet -- report
"alert accept button not found" and fall back to the previous
`alert get` plus label press. A dismissed prompt skips the poll gap so a
following prompt is handled straight away, bounded so a prompt that never
clears cannot turn the loop into a busy wait.
@vercel

vercel Bot commented Sep 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
react-native-harness Ready Ready Preview Sep 14, 2026 9:03am UTC

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant