From fb31f888f64233e9affa39eae000e3a590305f96 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 14 Sep 2026 08:56:41 +0200 Subject: [PATCH 01/12] docs: record ADR-0001 for replacing the XCTest agent with agent-device --- AGENTS.md | 2 + ...-replace-xctest-agent-with-agent-device.md | 202 ++++++++++++++++++ docs/internal/adr/README.md | 14 ++ 3 files changed, 218 insertions(+) create mode 100644 docs/internal/adr/0001-replace-xctest-agent-with-agent-device.md create mode 100644 docs/internal/adr/README.md diff --git a/AGENTS.md b/AGENTS.md index 01bc7d4d..8db78a1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ making a change. - For version plans, see @./docs/agents/version-plans.md. - Before preparing or opening a pull request, see @./docs/agents/pull-requests.md. +- Architecture decisions are recorded under `docs/internal/adr/`; read the + relevant ADR before implementing a change it covers. - Preserve unrelated work already present in the working tree. - Keep changes focused; do not make opportunistic refactors. - Never commit credentials, secrets, generated build output, or local diff --git a/docs/internal/adr/0001-replace-xctest-agent-with-agent-device.md b/docs/internal/adr/0001-replace-xctest-agent-with-agent-device.md new file mode 100644 index 00000000..aef15622 --- /dev/null +++ b/docs/internal/adr/0001-replace-xctest-agent-with-agent-device.md @@ -0,0 +1,202 @@ +# ADR-0001: Replace the custom iOS XCTest agent with agent-device + +## Status + +Accepted + +## Date + +2026-09-14 + +## Context + +`permissions: true` on iOS is implemented by a Harness-owned XCUITest target +(`packages/platform-ios/xctest-agent`, ~470 lines of Swift) plus ~1.7k lines of +TypeScript that build it with `xcodebuild build-for-testing`, cache the +DerivedData under `.harness/cache/xctest-agent-simulator-*`, run it with +`test-without-building`, talk to it over an ad-hoc HTTP server, and tear it +down (`xctest-agent*.ts`, `xctest-command.ts`, `startup-strategy.ts`, the +`harness xctest build` CLI command, the action.yml cache steps). The agent does +one thing: poll SpringBoard every second and tap known positive buttons +(`Allow`, `OK`, `While Using the App`, ...) so permission prompts never block a +test. + +This layer is expensive to keep: Xcode/SDK cache keys, signing for physical +devices, external `.xctestrun` injection for hosted device farms, graceful +shutdown races (#139, #164, #176), CPU tuning (#163). Every Xcode release is a +potential break, and it exists only for iOS while Android uses `adb pm grant`. + +[agent-device](https://github.com/callstack/agent-device) (Callstack, MIT) +already owns a maintained XCUITest runner (`AgentDeviceRunner`) with a typed +Node.js client, runner build caching keyed on Xcode + package version, physical +device signing, external `.xctestrun` support, and an `alert` command that +resolves blocking SpringBoard modals. Its help text and typings were read +directly from v0.21.0 (`agent-device help workflow|physical-device|prepare| +alert|settings`, `dist/src/client-types.d.ts`, runner Swift sources); see +"Verified facts" below. + +## Decision + +Delete the Harness XCTest agent and implement iOS permission automation on top +of the `agent-device` Node.js API (`createAgentDeviceClient`), keeping the +user-facing `permissions: true` contract and its reactive "tap the positive +button when a prompt appears" semantics. + +Concretely: + +1. `@react-native-harness/platform-apple` depends on `agent-device` (pinned + `~0.x`, loaded lazily only when `permissions` is enabled on iOS). No global + install; the daemon is spawned from the dependency with `process.execPath`. +2. Harness runs its own agent-device daemon: `AGENT_DEVICE_STATE_DIR` = + `~/.agent-device/harness/`. This isolates Harness from a + developer's own daemon, which the bundled client would otherwise replace on + version mismatch or reuse with stale signing env. Device-claim arbitration + stays correct because claims are host-global (`~/.agent-device/ + device-claims`), independent of the state dir. +3. Session model: Harness never calls `apps.open`. After the simulator is + booted and the app installed it calls `command.prepare({ action: + 'ios-runner', udid })` with an explicit `session` name; `alert` and `press` + then run session-less against the same `udid` (verified: agent-device + attaches an implicit runner session and answers `ALERT_NOT_FOUND` when no + prompt is shown). Harness keeps launching, killing and relaunching the app + under test through `simctl`/`devicectl` exactly as today (launch env, + stdout capture, crash detection, `resetEnvironmentBetweenTestFiles` are + untouched). agent-device never launches the app under test. +4. Permission watchdog moves to Node: a loop (default 1 s, env + `HARNESS_PERMISSION_WATCHDOG_INTERVAL_MS`, old + `HARNESS_XCTEST_AGENT_TICK_INTERVAL_MS` accepted as alias) calls + `command.alert({ action: 'get', timeoutMs })`; when a modal is present it + picks the first button from the Harness positive-label list among the + returned `items` and taps it with `interactions.press({ selector: + 'label="…"' })` (the runner routes taps to SpringBoard for blocking system + modals; `alert accept` is not used because it rejects 3-button sheets such + as location). The loop runs only while the app under test is running in + the foreground: polling with the SpringBoard home screen foregrounded walks + the whole icon grid, exceeds the runner's main-thread watchdog and triggers + `RUNNER_BUSY` restart storms (reproduced on iOS 26.4). The loop is + abort-signal driven and serialised (one in-flight command at a time), and + `RUNNER_BUSY`/timeouts are logged and retried, never fatal. +5. Physical devices: `device.codeSign` maps to the daemon's env at spawn + (`AGENT_DEVICE_IOS_TEAM_ID`, `_SIGNING_IDENTITY`, `_PROVISIONING_PROFILE`); + a new optional `codeSign.runnerBundleId` maps to + `AGENT_DEVICE_IOS_BUNDLE_ID`. Without `codeSign` Harness skips the runner, + as today. +6. External/hosted runners: `HARNESS_IOS_XCTESTRUN_FILE` and + `HARNESS_IOS_XCTEST_DERIVED_DATA_PATH` are kept and forwarded to + `iosXctestrunFile` / `iosXctestDerivedDataPath`; the artifact must now be an + `AgentDeviceRunner` build (`agent-device prepare ios-runner --json` prints + `xctestrunPath`). `harness xctest build` is removed. +7. Runner build cache: Harness sets `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` to + `.harness/cache/agent-device-runner`; action.yml caches that directory keyed + on `agent-device` version + `xcodebuild -version` (replacing the + `xctest-agent-simulator-*` cache and `cache.json` hashing). +8. Teardown: `daemon stop --state-dir … --clean` (CLI bin from the + dependency; verified to kill daemon, `xcodebuild`, `AgentDeviceRunner` and + snapshot-bridge and release the device claim) so a Harness run leaves no + process behind, matching current guarantees. Warm-runner reuse across runs + is deliberately not used; a fresh daemon attaches a cached runner in ~3 s + (~12 s on the first attach after a simulator boot), the on-disk build + cache survives. + +## Architecture impact + +- Removed: `packages/platform-ios/xctest-agent/` (Xcode project), + `xctest-agent.ts`, `xctest-agent-client.ts`, `xctest-agent-transport*.ts`, + `xctest-agent-capabilities.ts`, `xctest-command.ts`, `startup-strategy.ts`, + their tests, the `xctest` platform CLI command, `buildXCTestAgent` exports, + the `xctest-agent-simulator` special case in `@react-native-harness/cache` + and `tools` artifact naming, and the action.yml "Harness cache" steps. +- Added: `packages/platform-ios/src/permission-agent.ts` (client lifecycle, + daemon state dir, prepare, watchdog loop, teardown) with tests mocking the + client; a `harness ci` helper that prints the runner cache key for + action.yml. +- `instance.ts` loses the build/boot overlap strategy; the runner is prepared + after boot (agent-device requires a booted target) but its build is cached. +- Node floor: every Harness package declares `engines.node >= 22.12` + (agent-device's floor). Harness declares no `engines` today; this is a + repo-wide change made in the same release rather than a permissions-only + runtime check. +- Harness stays the sole owner of app lifecycle; agent-device owns only the + XCUITest runner and SpringBoard alert handling. + +## Compatibility and conflicts + +| Conflict | Resolution | +| --- | --- | +| Accept-label mismatch: agent-device `alert accept` knows `ok/allow/yes/continue/done/open/open settings/confirm*`; Harness also taps `While Using the App`, `Allow Once`, `Always Allow`, `Next`, `Join`, `Pair`, `Allow Full Access`. Verified: `alert accept` fails with "alert accept button not found" on the Maps location prompt. | Watchdog reads `items` from `alert get` and taps by label with `press`; verified to dismiss the location prompt. Upstreaming the labels to agent-device is optional follow-up. | +| Polling cost depends on the foreground app; SpringBoard home screen is pathological (watchdog timeouts, `RUNNER_BUSY`). | Poll only while Harness's app session reports the app running; pass `timeoutMs`; treat busy/timeout as transient. | +| One-shot vs continuous: agent-device activates once per command and only observes. | Continuous behaviour is reproduced by the Node polling loop. | +| Reaction time: each `alert get` round-trip is ~4 s, so a prompt can sit up to ~4 s before it is tapped (in-process watchdog: ~1 s). | Accepted; documented in the permissions guide. Tests already wait tens of seconds for permission results. | +| Daemon env is captured at spawn (signing vars) and shared per state dir. | Harness-owned state dir, daemon stopped at end of run. | +| Device claims: an open developer agent-device session on the same simulator yields `DEVICE_IN_USE` (reproduced locally against a live session from another workspace). | Surface the error verbatim with the recovery command; do not retry. | +| Physical device runner bundle ids change (`AgentDeviceRunner` + `.uitests`), so provisioning profiles for the old `HarnessXCTestAgent` id stop working. | Breaking change, documented; `codeSign.runnerBundleId` for personal teams. | +| Hosted device farms shipped the Harness agent `.xctestrun`. | Env var names kept; artifact must be rebuilt with `agent-device prepare ios-runner`. Breaking, documented. | +| `harness xctest build` disappears. | Removed; docs point to `agent-device prepare ios-runner`. | +| Runner cache location and key change (CI cache miss once). | New action.yml key; one cold build per Xcode/agent-device bump. | +| Logs: xcodebuild output moved from `.harness/logs/…/xcodebuild.log` to the session `runner.log` in the state dir. | Harness copies `runner.log`/`daemon.log` into `.harness/logs` on teardown so the action still uploads them. | + +## Expected outcome + +Merge gates for the implementation PR (all four are required): + +1. The E2E iOS CI job with `permissions: true` (playground camera test) is + green. +2. Manual verification on a signed physical iPhone with `device.codeSign`, + recorded in the PR description. +3. A startup benchmark, old agent vs new, cold and cached, measured on CI + and pasted into the PR (a true cold runner build could not be provoked + locally; expected cached cost is ~3 s per run, see "Verified facts"). +4. A test or CI step asserts that no `xcodebuild`, `AgentDeviceRunner` or + agent-device daemon process survives a Harness run. + +Also expected: + +- Harness contains no Swift, no Xcode project, no `xcodebuild` invocation and + no HTTP transport for permission automation. +- Ships as a minor release (version plan for `platform-apple`, `cli`, and + every package gaining `engines`) whose changelog and the permissions guide + call out the two breaking paths (physical device signing, external + `.xctestrun`) and the Node floor. + +## Not building + +- Android changes: `adb pm grant` stays; agent-device's fixed permission list + is a subset of what the manifest-driven grant covers. +- Proactive iOS pre-granting (Info.plist usage keys → `settings permission + grant`); it would change semantics for tests that expect a prompt. +- Driving app UI through agent-device (`@react-native-harness/ui` stays on the + in-app bridge), MCP, cloud/device-farm providers, recordings, snapshots. +- Per-permission or per-platform `permissions` configuration. +- Keeping a warm runner between Harness runs. + +## Verified facts (agent-device 0.21.0) + +- Node client: `createAgentDeviceClient(config)` with `stateDir`, `session`, + `lockPolicy`, `iosXctestrunFile`, `iosXctestDerivedDataPath`; + `command.alert/prepare`, `apps.open/close`, `sessions.close`, + `settings.update`, `interactions.press`. No daemon-stop method (CLI only). +- Runner sources ship in `dist/apple/runner`; built by the daemon, cached in + `~/.agent-device/apple-runner/derived//cache-` keyed on Xcode, + SDK, package version and runner source fingerprint; + `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` overrides the location. +- `alert` resolves SpringBoard alerts/sheets first, then the session app's + alerts; `accept` verifies the presentation changed and activates once. + `snapshot`/`press` route to SpringBoard while a blocking system modal is up. +- `prepare ios-runner` needs a booted device and works without an open + session; docs recommend exact cache keys and no broad `restore-keys`. Runner + derived data is ~165 MB. Measured locally (iPhone 17 Pro, iOS 26.4, cached + runner): fresh daemon + settled simulator 3.2 s (0.45 s connect, 2.65 s + health check), first attach after boot 11.6 s, warm runner 17 ms; a true + cold build (`buildMs > 0`) could not be provoked and remains unmeasured. +- `alert get` with no prompt showing and Settings foregrounded: 3.9 s wall + clock per CLI call (5 samples, tight spread), so the watchdog's reaction + time is bounded by the runner round-trip, not by the poll interval. +- `settings permission grant` only covers the simctl privacy services of the + runtime (no `camera`, no `notifications` on iOS 26.4), which is why a + proactive pre-grant design was rejected. +- Daemon: spawned detached with the client's `process.env`, idle-reaps after + 5 min, replaced on version/code-signature mismatch; runner kept warm 5 min + after `close` unless stopped. +- Physical devices: signing via `AGENT_DEVICE_IOS_*` env (`DEVELOPMENT_TEAM`, + `CODE_SIGN_STYLE` Manual/Automatic). Session-less `alert`/`press` were + verified on a simulator only; the physical path is merge gate 2. diff --git a/docs/internal/adr/README.md b/docs/internal/adr/README.md new file mode 100644 index 00000000..9046f261 --- /dev/null +++ b/docs/internal/adr/README.md @@ -0,0 +1,14 @@ +# Architecture decision records + +Internal ADRs for React Native Harness. One file per decision, named +`NNNN-short-title.md` with a zero-padded sequence number that is never reused. + +Each ADR keeps to these sections, in this order, and stays concise: `Status` +(Proposed, Accepted, Superseded by ADR-NNNN, Deprecated), `Date`, `Context` +(the problem), `Decision` (what we build), `Architecture impact`, +`Compatibility and conflicts`, `Expected outcome` (merge gates), `Not +building`. Add `Verified facts` when the decision rests on measurements or +third-party behaviour that was checked, with the version checked. + +Do not edit an accepted ADR to change the decision; write a new one that +supersedes it. From 7f87d47d35f992e2439715b698578cf642c4b217 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 14 Sep 2026 08:57:18 +0200 Subject: [PATCH 02/12] chore: declare a Node.js 22.12 engines floor on every package --- package.json | 5 ++++- packages/babel-preset/package.json | 5 ++++- packages/bridge/package.json | 5 ++++- packages/bundler-metro/package.json | 5 ++++- packages/cache/package.json | 5 ++++- packages/cli/package.json | 5 ++++- packages/config/package.json | 5 ++++- packages/coverage-ios/package.json | 5 ++++- packages/jest/package.json | 5 ++++- packages/metro/package.json | 5 ++++- packages/platform-android/package.json | 5 ++++- packages/platform-vega/package.json | 5 ++++- packages/platform-web/package.json | 5 ++++- packages/platform-windows/package.json | 5 ++++- packages/platforms/package.json | 5 ++++- packages/plugins/package.json | 5 ++++- packages/react-native-harness/package.json | 5 ++++- packages/runtime/package.json | 5 ++++- packages/tools/package.json | 5 ++++- packages/ui/package.json | 5 ++++- 20 files changed, 80 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index 3be1a167..fe5bb143 100644 --- a/package.json +++ b/package.json @@ -52,5 +52,8 @@ "vite": "7.2.2", "vitest": "^3.2.4" }, - "packageManager": "pnpm@10.6.1+sha512.40ee09af407fa9fbb5fbfb8e1cb40fbb74c0af0c3e10e9224d7b53c7658528615b2c92450e74cfad91e3a2dcafe3ce4050d80bda71d757756d2ce2b66213e9a3" + "packageManager": "pnpm@10.6.1+sha512.40ee09af407fa9fbb5fbfb8e1cb40fbb74c0af0c3e10e9224d7b53c7658528615b2c92450e74cfad91e3a2dcafe3ce4050d80bda71d757756d2ce2b66213e9a3", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/babel-preset/package.json b/packages/babel-preset/package.json index 7badcd57..b5a95641 100644 --- a/packages/babel-preset/package.json +++ b/packages/babel-preset/package.json @@ -26,5 +26,8 @@ "@babel/core": "^7.22.0", "@types/babel__core": "^7.20.5" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 93263ba8..fb16c144 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -42,5 +42,8 @@ "vite": "^7.2.2", "vitest": "^3.2.4" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/bundler-metro/package.json b/packages/bundler-metro/package.json index 18d10bcf..60ac229b 100644 --- a/packages/bundler-metro/package.json +++ b/packages/bundler-metro/package.json @@ -39,5 +39,8 @@ "metro-config": "*", "metro-resolver": "*" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/cache/package.json b/packages/cache/package.json index d46f539b..31a82287 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -18,5 +18,8 @@ "@react-native-harness/tools": "workspace:*", "tslib": "^2.3.0" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 7312c420..6f9f96cc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,5 +34,8 @@ "@react-native-harness/platform-apple": "workspace:*", "@react-native-harness/platform-web": "workspace:*" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/config/package.json b/packages/config/package.json index 707d5e1e..c64de49f 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -20,5 +20,8 @@ "tslib": "^2.3.0", "zod": "^3.25.67" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/coverage-ios/package.json b/packages/coverage-ios/package.json index 8c1a95b4..84230a49 100644 --- a/packages/coverage-ios/package.json +++ b/packages/coverage-ios/package.json @@ -36,5 +36,8 @@ }, "license": "MIT", "author": "Callstack", - "homepage": "https://github.com/callstackincubator/react-native-harness" + "homepage": "https://github.com/callstackincubator/react-native-harness", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/jest/package.json b/packages/jest/package.json index f27d1847..dc0cc09b 100644 --- a/packages/jest/package.json +++ b/packages/jest/package.json @@ -51,5 +51,8 @@ "@types/yargs": "^17.0.32", "jest-runner": "^30.2.0" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/metro/package.json b/packages/metro/package.json index 3221868e..0c49643f 100644 --- a/packages/metro/package.json +++ b/packages/metro/package.json @@ -23,5 +23,8 @@ "devDependencies": { "metro": "*" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/platform-android/package.json b/packages/platform-android/package.json index 490c52a3..28798816 100644 --- a/packages/platform-android/package.json +++ b/packages/platform-android/package.json @@ -23,5 +23,8 @@ "zod": "^3.25.67", "tslib": "^2.3.0" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/platform-vega/package.json b/packages/platform-vega/package.json index 93c369c2..22dce862 100644 --- a/packages/platform-vega/package.json +++ b/packages/platform-vega/package.json @@ -22,5 +22,8 @@ "zod": "^3.25.67", "tslib": "^2.3.0" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/platform-web/package.json b/packages/platform-web/package.json index 5a7d3a04..c7167b4d 100644 --- a/packages/platform-web/package.json +++ b/packages/platform-web/package.json @@ -22,5 +22,8 @@ "zod": "^3.25.67", "tslib": "^2.3.0" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/platform-windows/package.json b/packages/platform-windows/package.json index ce13ab19..b4ac1bbd 100644 --- a/packages/platform-windows/package.json +++ b/packages/platform-windows/package.json @@ -31,5 +31,8 @@ "metro-config": "*", "metro-resolver": "*" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/platforms/package.json b/packages/platforms/package.json index e35c2f93..9b42a620 100644 --- a/packages/platforms/package.json +++ b/packages/platforms/package.json @@ -19,5 +19,8 @@ "@react-native-harness/tools": "workspace:*", "tslib": "^2.3.0" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/plugins/package.json b/packages/plugins/package.json index ec99b77f..b8ba4ec9 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -21,5 +21,8 @@ "hookable": "^6.1.0", "tslib": "^2.3.0" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/react-native-harness/package.json b/packages/react-native-harness/package.json index 3d19ef53..6e6e1e82 100644 --- a/packages/react-native-harness/package.json +++ b/packages/react-native-harness/package.json @@ -49,5 +49,8 @@ "@react-native-harness/babel-preset": "workspace:*", "@react-native-harness/jest": "workspace:*" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 7a6d4539..f8fc8dbe 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -61,5 +61,8 @@ "type": "git", "url": "https://github.com/callstackincubator/react-native-harness.git" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/tools/package.json b/packages/tools/package.json index 79c797a0..0a607c24 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -40,5 +40,8 @@ "react-native": "*", "vite": "^7.2.2" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/ui/package.json b/packages/ui/package.json index dc6d6068..b8017e93 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -61,5 +61,8 @@ "type": "git", "url": "https://github.com/callstackincubator/react-native-harness.git" }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } From 1ab82e3b7fd771fa398981c5ee427b4e580c1cea Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 14 Sep 2026 08:57:48 +0200 Subject: [PATCH 03/12] feat(platform-apple): replace the XCTest agent with agent-device 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. --- packages/cache/src/__tests__/boundary.test.ts | 2 +- .../src/__tests__/platform-commands.test.ts | 14 +- packages/config/src/types.ts | 2 +- packages/platform-ios/.npmignore | 2 - packages/platform-ios/README.md | 3 + packages/platform-ios/package.json | 14 +- .../src/__tests__/app-session.test.ts | 34 + .../instance-permission-agent.test.ts | 256 ++++ .../__tests__/instance-xctest-agent.test.ts | 378 ------ .../src/__tests__/instance.test.ts | 34 +- .../src/__tests__/launch-options.test.ts | 11 +- .../src/__tests__/permission-agent.test.ts | 371 ++++++ .../platform-ios/src/__tests__/simctl.test.ts | 8 +- .../src/__tests__/startup-strategy.test.ts | 30 - .../xctest-agent-capabilities.test.ts | 28 - .../src/__tests__/xctest-agent-client.test.ts | 111 -- .../src/__tests__/xctest-agent.test.ts | 972 -------------- .../src/__tests__/xctest-command.test.ts | 88 -- packages/platform-ios/src/app-session.ts | 29 + packages/platform-ios/src/cli.ts | 23 - packages/platform-ios/src/config.ts | 6 + packages/platform-ios/src/factory.ts | 1 - packages/platform-ios/src/index.ts | 7 - packages/platform-ios/src/instance.ts | 64 +- packages/platform-ios/src/permission-agent.ts | 665 ++++++++++ packages/platform-ios/src/startup-strategy.ts | 13 - .../src/xctest-agent-capabilities.ts | 20 - .../platform-ios/src/xctest-agent-client.ts | 104 -- .../src/xctest-agent-transport-device.ts | 123 -- .../src/xctest-agent-transport-simulator.ts | 103 -- .../src/xctest-agent-transport.ts | 18 - packages/platform-ios/src/xctest-agent.ts | 1149 ----------------- packages/platform-ios/src/xctest-command.ts | 177 --- packages/platform-ios/vite.config.ts | 18 + .../project.pbxproj | 455 ------- .../contents.xcworkspacedata | 7 - .../AccentColor.colorset/Contents.json | 11 - .../AppIcon.appiconset/Contents.json | 36 - .../AppIcon.appiconset/logo.jpg | Bin 107504 -> 0 bytes .../Assets.xcassets/Contents.json | 6 - .../Logo.imageset/Contents.json | 21 - .../Assets.xcassets/Logo.imageset/logo.jpg | Bin 107504 -> 0 bytes .../PoweredBy.imageset/Contents.json | 21 - .../PoweredBy.imageset/powered-by.png | Bin 6006 -> 0 bytes .../HarnessXCTestAgent/ContentView.swift | 34 - .../HarnessXCTestAgentApp.swift | 17 - .../AgentCapability.swift | 11 - .../HarnessXCTestAgentUITests.swift | 354 ----- .../PermissionPromptWatchdog.swift | 75 -- .../src/__tests__/harness-artifacts.test.ts | 8 +- pnpm-lock.yaml | 24 +- 51 files changed, 1470 insertions(+), 4488 deletions(-) create mode 100644 packages/platform-ios/src/__tests__/instance-permission-agent.test.ts delete mode 100644 packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts create mode 100644 packages/platform-ios/src/__tests__/permission-agent.test.ts delete mode 100644 packages/platform-ios/src/__tests__/startup-strategy.test.ts delete mode 100644 packages/platform-ios/src/__tests__/xctest-agent-capabilities.test.ts delete mode 100644 packages/platform-ios/src/__tests__/xctest-agent-client.test.ts delete mode 100644 packages/platform-ios/src/__tests__/xctest-agent.test.ts delete mode 100644 packages/platform-ios/src/__tests__/xctest-command.test.ts delete mode 100644 packages/platform-ios/src/cli.ts create mode 100644 packages/platform-ios/src/permission-agent.ts delete mode 100644 packages/platform-ios/src/startup-strategy.ts delete mode 100644 packages/platform-ios/src/xctest-agent-capabilities.ts delete mode 100644 packages/platform-ios/src/xctest-agent-client.ts delete mode 100644 packages/platform-ios/src/xctest-agent-transport-device.ts delete mode 100644 packages/platform-ios/src/xctest-agent-transport-simulator.ts delete mode 100644 packages/platform-ios/src/xctest-agent-transport.ts delete mode 100644 packages/platform-ios/src/xctest-agent.ts delete mode 100644 packages/platform-ios/src/xctest-command.ts create mode 100644 packages/platform-ios/vite.config.ts delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgent.xcodeproj/project.pbxproj delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgent.xcodeproj/project.xcworkspace/contents.xcworkspacedata delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AccentColor.colorset/Contents.json delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AppIcon.appiconset/logo.jpg delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Contents.json delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Logo.imageset/Contents.json delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Logo.imageset/logo.jpg delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/PoweredBy.imageset/Contents.json delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/PoweredBy.imageset/powered-by.png delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgent/ContentView.swift delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgent/HarnessXCTestAgentApp.swift delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/AgentCapability.swift delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/HarnessXCTestAgentUITests.swift delete mode 100644 packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/PermissionPromptWatchdog.swift diff --git a/packages/cache/src/__tests__/boundary.test.ts b/packages/cache/src/__tests__/boundary.test.ts index 5398c8a6..44733530 100644 --- a/packages/cache/src/__tests__/boundary.test.ts +++ b/packages/cache/src/__tests__/boundary.test.ts @@ -9,7 +9,7 @@ import { describe, expect, it } from 'vitest'; // // Allowlisted until phase 2 migrates them onto this package: // - packages/tools/src/harness-artifacts.ts: getHarnessCacheRootPath, used by -// platform-ios for the XCTest agent cache. +// platform-ios for the agent-device runner cache. const ALLOWLIST = new Set(['tools/src/harness-artifacts.ts']); const findRepoRoot = (start: string): string => { diff --git a/packages/cli/src/__tests__/platform-commands.test.ts b/packages/cli/src/__tests__/platform-commands.test.ts index 41486e2b..234a1322 100644 --- a/packages/cli/src/__tests__/platform-commands.test.ts +++ b/packages/cli/src/__tests__/platform-commands.test.ts @@ -20,7 +20,7 @@ describe('platform CLI command discovery', () => { it('runs a discovered platform command', async () => { const moduleUrl = createCommandModuleUrl(` export const commands = [{ - name: 'xctest', + name: 'diagnostics', async run(args, context) { globalThis.__platformCommandCall = { args, context }; } @@ -63,7 +63,7 @@ describe('platform CLI command discovery', () => { expect( await runPlatformCommand({ - argv: ['xctest', 'build', '--destination', 'simulator'], + argv: ['diagnostics', 'build', '--destination', 'simulator'], cwd: '/tmp/project', loadConfig, }) @@ -80,7 +80,7 @@ describe('platform CLI command discovery', () => { it('deduplicates platform CLI modules across runners', async () => { const moduleUrl = createCommandModuleUrl(` export const commands = [{ - name: 'xctest', + name: 'diagnostics', async run() {} }]; `); @@ -171,7 +171,7 @@ describe('platform CLI command discovery', () => { await expect( runPlatformCommand({ - argv: ['xctest', 'build'], + argv: ['diagnostics', 'build'], cwd: '/tmp/project', loadConfig, }) @@ -181,14 +181,14 @@ describe('platform CLI command discovery', () => { it('throws when two platform modules define the same command', async () => { const firstModuleUrl = createCommandModuleUrl(` export const commands = [{ - name: 'xctest', + name: 'diagnostics', async run() {} }]; `); const secondModuleUrl = createCommandModuleUrl(` // second module export const commands = [{ - name: 'xctest', + name: 'diagnostics', async run() {} }]; `); @@ -239,6 +239,6 @@ describe('platform CLI command discovery', () => { cwd: '/tmp/project', loadConfig, }) - ).rejects.toThrow("Duplicate platform CLI command 'xctest'"); + ).rejects.toThrow("Duplicate platform CLI command 'diagnostics'"); }); }); diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 965c6f53..858f1d75 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -162,7 +162,7 @@ export const ConfigSchema = z .optional() .default(false) .describe( - 'Enable platform-specific permission prompt automation. When false, Harness does not start permission-handling helpers such as the iOS XCTest agent.' + 'Enable platform-specific permission prompt automation. When false, Harness does not start permission-handling helpers such as the iOS agent-device permission watchdog.' ), detectNativeCrashes: z.boolean().optional().default(true), diff --git a/packages/platform-ios/.npmignore b/packages/platform-ios/.npmignore index 87f5cd75..64094718 100644 --- a/packages/platform-ios/.npmignore +++ b/packages/platform-ios/.npmignore @@ -3,5 +3,3 @@ **/*.test.* **/*.tsbuildinfo dist/*.tsbuildinfo -xctest-agent/build/ -xctest-agent/**/xcuserdata/ diff --git a/packages/platform-ios/README.md b/packages/platform-ios/README.md index 497e8d97..b6cd287d 100644 --- a/packages/platform-ios/README.md +++ b/packages/platform-ios/README.md @@ -83,6 +83,9 @@ Creates a physical Apple device configuration. - macOS with Xcode installed - iOS Simulator or physical device connected - React Native project configured for iOS +- Node.js 22.12 or newer + +Permission prompt automation (`permissions: true`) is powered by [agent-device](https://github.com/callstack/agent-device), which Harness loads only when the flag is enabled. See the [permissions guide](https://react-native-harness.dev/docs/guides/permissions) for details, including physical device signing. Harness uses Apple-native Xcode tooling for crash diagnostics: `simctl diagnose` on simulators and `devicectl` crash log / diagnose flows on physical devices. Native crash monitoring stays internal to the platform package, so the implementation can evolve without changing the public API. diff --git a/packages/platform-ios/package.json b/packages/platform-ios/package.json index d9d49833..daee4098 100644 --- a/packages/platform-ios/package.json +++ b/packages/platform-ios/package.json @@ -19,12 +19,12 @@ "@react-native-harness/config": "workspace:*", "@react-native-harness/platforms": "workspace:*", "@react-native-harness/tools": "workspace:*", - "yargs": "^17.7.2", - "zod": "^3.25.67", - "tslib": "^2.3.0" + "agent-device": "~0.21.0", + "tslib": "^2.3.0", + "zod": "^3.25.67" }, - "devDependencies": { - "@types/yargs": "^17.0.32" - }, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=22.12" + } } diff --git a/packages/platform-ios/src/__tests__/app-session.test.ts b/packages/platform-ios/src/__tests__/app-session.test.ts index 754f0093..65862f71 100644 --- a/packages/platform-ios/src/__tests__/app-session.test.ts +++ b/packages/platform-ios/src/__tests__/app-session.test.ts @@ -152,4 +152,38 @@ describe('createIosAppSession', () => { vi.useRealTimers(); } }); + it('reports the observed app run state to onAppRunningChange', async () => { + vi.useFakeTimers(); + + try { + const launchProcess = createPendingLaunchProcess(); + const isAppRunning = vi + .fn<() => Promise>() + .mockResolvedValue(true); + const onAppRunningChange = vi.fn(); + + const sessionPromise = createIosAppSession({ + launch: () => launchProcess, + stopApp: vi.fn(async () => undefined), + isAppRunning, + onAppRunningChange, + }); + + await vi.advanceTimersByTimeAsync(100); + const session = await sessionPromise; + await vi.advanceTimersByTimeAsync(1000); + + expect(onAppRunningChange).toHaveBeenCalledWith(true); + // Deduplicated: repeated polls of an unchanged state emit nothing. + expect(onAppRunningChange).toHaveBeenCalledTimes(1); + + const disposePromise = session.dispose(); + await vi.advanceTimersByTimeAsync(1000); + await disposePromise; + + expect(onAppRunningChange).toHaveBeenLastCalledWith(false); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/packages/platform-ios/src/__tests__/instance-permission-agent.test.ts b/packages/platform-ios/src/__tests__/instance-permission-agent.test.ts new file mode 100644 index 00000000..aed3e4ef --- /dev/null +++ b/packages/platform-ios/src/__tests__/instance-permission-agent.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_METRO_PORT, + type Config as HarnessConfig, +} from '@react-native-harness/config'; +import * as simctl from '../xcrun/simctl.js'; +import * as devicectl from '../xcrun/devicectl.js'; + +const mocks = vi.hoisted(() => ({ + createIosPermissionAgent: vi.fn(), + dispose: vi.fn(async () => undefined), + prepare: vi.fn(async () => undefined), + setAppRunning: vi.fn(), +})); + +const appSessionMocks = vi.hoisted(() => ({ + createIosAppSession: vi.fn(), +})); + +vi.mock('../permission-agent.js', () => ({ + createIosPermissionAgent: mocks.createIosPermissionAgent, +})); + +vi.mock('../app-session.js', () => ({ + createIosAppSession: appSessionMocks.createIosAppSession, +})); + +const { + getApplePhysicalDevicePlatformInstance, + getAppleSimulatorPlatformInstance, +} = await import('../instance.js'); + +const harnessConfig = { + metroPort: DEFAULT_METRO_PORT, +} as HarnessConfig; +const harnessConfigWithPermissionsEnabled = { + metroPort: DEFAULT_METRO_PORT, + permissions: true, +} as HarnessConfig; + +const simulatorConfig = { + name: 'ios', + device: { + type: 'simulator' as const, + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', +}; + +const physicalDevice = { + identifier: 'physical-device-id', + deviceProperties: { + name: 'My iPhone', + osVersionNumber: '18.0', + }, + hardwareProperties: { + marketingName: 'iPhone', + productType: 'iPhone17,1', + udid: 'device-udid', + }, +}; + +describe('iOS permission agent runner integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + appSessionMocks.createIosAppSession.mockResolvedValue({ + dispose: async () => undefined, + }); + mocks.prepare.mockResolvedValue(undefined); + mocks.createIosPermissionAgent.mockReturnValue({ + prepare: mocks.prepare, + setAppRunning: mocks.setAppRunning, + dispose: mocks.dispose, + }); + }); + + it('prepares the runner only after the simulator is booted and the app installed', async () => { + const getSimulatorStatus = vi + .spyOn(simctl, 'getSimulatorStatus') + .mockResolvedValue('Shutdown'); + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + const bootSimulator = vi + .spyOn(simctl, 'bootSimulator') + .mockResolvedValue(undefined); + vi.spyOn(simctl, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + const applyOverride = vi + .spyOn(simctl, 'applyHarnessJsLocationOverride') + .mockResolvedValue(undefined); + vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); + vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + vi.spyOn(simctl, 'shutdownSimulator').mockResolvedValue(undefined); + + const initSignal = new AbortController().signal; + const instance = await getAppleSimulatorPlatformInstance( + simulatorConfig, + harnessConfigWithPermissionsEnabled, + { signal: initSignal } + ); + + expect(mocks.createIosPermissionAgent).toHaveBeenCalledWith({ + appBundleId: 'com.harnessplayground', + target: { kind: 'simulator', udid: 'sim-udid' }, + }); + expect(getSimulatorStatus).toHaveBeenCalledBefore(mocks.prepare); + expect(bootSimulator).toHaveBeenCalledBefore(mocks.prepare); + expect(applyOverride).toHaveBeenCalledBefore(mocks.prepare); + expect(mocks.prepare).toHaveBeenCalledWith(initSignal); + + await instance.dispose(); + + expect(mocks.dispose).toHaveBeenCalledTimes(1); + }); + + it('wires the app session run state into the permission watchdog', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); + vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + vi.spyOn(simctl, 'getAppInfo').mockResolvedValue(null); + vi.spyOn(simctl, 'isAppRunning').mockResolvedValue(true); + + const instance = await getAppleSimulatorPlatformInstance( + simulatorConfig, + harnessConfigWithPermissionsEnabled, + { signal: new AbortController().signal } + ); + + await instance.createAppSession(); + + const [sessionOptions] = appSessionMocks.createIosAppSession.mock + .calls[0] as [{ onAppRunningChange?: (running: boolean) => void }]; + + sessionOptions.onAppRunningChange?.(true); + expect(mocks.setAppRunning).toHaveBeenCalledWith(true); + + sessionOptions.onAppRunningChange?.(false); + expect(mocks.setAppRunning).toHaveBeenLastCalledWith(false); + + await instance.dispose(); + }); + + it('cleans up the simulator when preparing the runner fails', async () => { + const prepareError = new Error('runner unavailable'); + mocks.prepare.mockRejectedValue(prepareError); + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Shutdown'); + vi.spyOn(simctl, 'bootSimulator').mockResolvedValue(undefined); + vi.spyOn(simctl, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + const clearOverride = vi + .spyOn(simctl, 'clearHarnessJsLocationOverride') + .mockResolvedValue(undefined); + const shutdownSimulator = vi + .spyOn(simctl, 'shutdownSimulator') + .mockResolvedValue(undefined); + + await expect( + getAppleSimulatorPlatformInstance( + simulatorConfig, + harnessConfigWithPermissionsEnabled, + { signal: new AbortController().signal } + ) + ).rejects.toThrow(prepareError); + + expect(mocks.dispose).toHaveBeenCalledTimes(1); + expect(clearOverride).toHaveBeenCalledWith( + 'sim-udid', + 'com.harnessplayground' + ); + expect(shutdownSimulator).toHaveBeenCalledWith('sim-udid'); + }); + + it('passes physical-device code signing through to the permission agent', async () => { + vi.spyOn(devicectl, 'getDevice').mockResolvedValue(physicalDevice); + vi.spyOn(devicectl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(devicectl, 'stopApp').mockResolvedValue(undefined); + + const instance = await getApplePhysicalDevicePlatformInstance( + { + name: 'ios-device', + device: { + type: 'physical', + name: 'My iPhone', + codeSign: { + teamId: 'TEAMID1234', + runnerBundleId: 'com.example.runner', + }, + }, + bundleId: 'com.harnessplayground', + }, + harnessConfigWithPermissionsEnabled + ); + + expect(mocks.createIosPermissionAgent).toHaveBeenCalledWith({ + appBundleId: 'com.harnessplayground', + target: { + kind: 'device', + udid: 'device-udid', + codeSign: { + teamId: 'TEAMID1234', + runnerBundleId: 'com.example.runner', + }, + }, + }); + + await instance.dispose(); + + expect(mocks.dispose).toHaveBeenCalledTimes(1); + }); + + it('skips physical-device permission automation without codeSign', async () => { + vi.spyOn(devicectl, 'getDevice').mockResolvedValue(physicalDevice); + vi.spyOn(devicectl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(devicectl, 'stopApp').mockResolvedValue(undefined); + + await getApplePhysicalDevicePlatformInstance( + { + name: 'ios-device', + device: { type: 'physical', name: 'My iPhone' }, + bundleId: 'com.harnessplayground', + }, + harnessConfigWithPermissionsEnabled + ); + + expect(mocks.createIosPermissionAgent).not.toHaveBeenCalled(); + }); + + it('does not create a permission agent when permissions are disabled', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + + await getAppleSimulatorPlatformInstance(simulatorConfig, harnessConfig, { + signal: new AbortController().signal, + }); + + expect(mocks.createIosPermissionAgent).not.toHaveBeenCalled(); + expect(mocks.prepare).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts b/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts deleted file mode 100644 index bd80c9d5..00000000 --- a/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - DEFAULT_METRO_PORT, - type Config as HarnessConfig, -} from '@react-native-harness/config'; -import * as simctl from '../xcrun/simctl.js'; -import * as devicectl from '../xcrun/devicectl.js'; -import { logger } from '@react-native-harness/tools'; - -const mocks = vi.hoisted(() => ({ - dispose: vi.fn(async () => undefined), - ensureStarted: vi.fn(async () => undefined), - prepare: vi.fn(async () => undefined), - createXCTestAgentController: vi.fn(), -})); -const startupStrategyMocks = vi.hoisted(() => ({ - shouldOverlapXCTestBuildAndSimulatorBoot: vi.fn(), -})); - -vi.mock('../xctest-agent.js', () => ({ - createXCTestAgentController: mocks.createXCTestAgentController, -})); -vi.mock('../startup-strategy.js', () => ({ - shouldOverlapXCTestBuildAndSimulatorBoot: - startupStrategyMocks.shouldOverlapXCTestBuildAndSimulatorBoot, -})); - -import { - getApplePhysicalDevicePlatformInstance, - getAppleSimulatorPlatformInstance, -} from '../instance.js'; - -const harnessConfig = { - metroPort: DEFAULT_METRO_PORT, -} as HarnessConfig; -const harnessConfigWithPermissionsEnabled = { - metroPort: DEFAULT_METRO_PORT, - permissions: true, -} as HarnessConfig; - -describe('iOS XCTest agent runner integration', () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.prepare.mockResolvedValue(undefined); - startupStrategyMocks.shouldOverlapXCTestBuildAndSimulatorBoot.mockReturnValue( - false, - ); - mocks.createXCTestAgentController.mockReturnValue({ - prepare: mocks.prepare, - ensureStarted: mocks.ensureStarted, - stop: vi.fn(async () => undefined), - dispose: mocks.dispose, - }); - }); - - it('starts the simulator XCTest agent during platform initialization when permissions are enabled', async () => { - vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); - vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); - vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); - vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( - undefined, - ); - vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); - vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( - undefined, - ); - - const initSignal = new AbortController().signal; - const instance = await getAppleSimulatorPlatformInstance( - { - name: 'ios', - device: { - type: 'simulator', - name: 'iPhone 16 Pro', - systemVersion: '18.0', - }, - bundleId: 'com.harnessplayground', - }, - harnessConfigWithPermissionsEnabled, - { - signal: initSignal, - }, - ); - - await instance.dispose(); - - expect(mocks.createXCTestAgentController).toHaveBeenCalledWith({ - appBundleId: 'com.harnessplayground', - capabilities: [ - expect.objectContaining({ - getLaunchEnvironment: expect.any(Function), - }), - ], - target: { - kind: 'simulator', - id: 'sim-udid', - }, - }); - expect(mocks.prepare).toHaveBeenCalledWith(initSignal); - expect(mocks.ensureStarted).toHaveBeenCalledWith(initSignal); - expect(mocks.dispose).toHaveBeenCalledTimes(1); - }); - - it('starts the physical-device XCTest agent during platform initialization when permissions are enabled', async () => { - vi.spyOn(devicectl, 'getDevice').mockResolvedValue({ - identifier: 'device-udid', - deviceProperties: { - name: 'My iPhone', - osVersionNumber: '18.0', - }, - hardwareProperties: { - marketingName: 'iPhone', - productType: 'iPhone17,1', - udid: 'device-udid', - }, - }); - vi.spyOn(devicectl, 'isAppInstalled').mockResolvedValue(true); - vi.spyOn(devicectl, 'stopApp').mockResolvedValue(undefined); - - const instance = await getApplePhysicalDevicePlatformInstance( - { - name: 'ios-device', - device: { - type: 'physical', - name: 'My iPhone', - codeSign: { - teamId: 'TEAMID1234', - }, - }, - bundleId: 'com.harnessplayground', - }, - harnessConfigWithPermissionsEnabled, - ); - - await instance.dispose(); - - expect(mocks.createXCTestAgentController).toHaveBeenCalledWith({ - appBundleId: 'com.harnessplayground', - capabilities: [ - expect.objectContaining({ - getLaunchEnvironment: expect.any(Function), - }), - ], - target: { - kind: 'device', - id: 'device-udid', - codeSign: { - teamId: 'TEAMID1234', - }, - }, - }); - expect(mocks.prepare).not.toHaveBeenCalled(); - expect(mocks.ensureStarted).toHaveBeenCalledTimes(1); - expect(mocks.dispose).toHaveBeenCalledTimes(1); - }); - - it('does not start the simulator XCTest agent when permissions are disabled', async () => { - vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); - vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); - vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); - vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( - undefined, - ); - - await getAppleSimulatorPlatformInstance( - { - name: 'ios', - device: { - type: 'simulator', - name: 'iPhone 16 Pro', - systemVersion: '18.0', - }, - bundleId: 'com.harnessplayground', - }, - harnessConfig, - { - signal: new AbortController().signal, - }, - ); - - expect(mocks.createXCTestAgentController).not.toHaveBeenCalled(); - expect(mocks.ensureStarted).not.toHaveBeenCalled(); - }); - - it('builds the XCTest agent before simulator preparation in sequential mode', async () => { - let resolvePreparation: ((value: undefined) => void) | undefined; - mocks.prepare.mockImplementation( - () => - new Promise((resolve) => { - resolvePreparation = resolve; - }), - ); - const getSimulatorStatus = vi - .spyOn(simctl, 'getSimulatorStatus') - .mockResolvedValue('Booted'); - vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); - vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); - vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( - undefined, - ); - - const instancePromise = getAppleSimulatorPlatformInstance( - simulatorConfig, - harnessConfigWithPermissionsEnabled, - { signal: new AbortController().signal }, - ); - - await Promise.resolve(); - expect(mocks.prepare).toHaveBeenCalledTimes(1); - expect(getSimulatorStatus).not.toHaveBeenCalled(); - - resolvePreparation?.(undefined); - const instance = await instancePromise; - - expect(mocks.prepare).toHaveBeenCalledBefore(getSimulatorStatus); - expect(mocks.prepare).toHaveBeenCalledBefore(mocks.ensureStarted); - await instance.dispose(); - }); - - it('overlaps simulator preparation with the XCTest build in parallel mode', async () => { - startupStrategyMocks.shouldOverlapXCTestBuildAndSimulatorBoot.mockReturnValue( - true, - ); - let resolvePreparation: ((value: undefined) => void) | undefined; - mocks.prepare.mockImplementation( - () => - new Promise((resolve) => { - resolvePreparation = resolve; - }), - ); - const getSimulatorStatus = vi - .spyOn(simctl, 'getSimulatorStatus') - .mockResolvedValue('Booted'); - vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); - vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); - vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( - undefined, - ); - - const instancePromise = getAppleSimulatorPlatformInstance( - simulatorConfig, - harnessConfigWithPermissionsEnabled, - { signal: new AbortController().signal }, - ); - - await Promise.resolve(); - expect(mocks.prepare).toHaveBeenCalledTimes(1); - expect(getSimulatorStatus).toHaveBeenCalledTimes(1); - - resolvePreparation?.(undefined); - const instance = await instancePromise; - - expect(mocks.prepare).toHaveBeenCalledBefore(mocks.ensureStarted); - await instance.dispose(); - }); - - it('logs the selected XCTest startup strategy at debug level', async () => { - const wasVerbose = logger.isVerbose(); - logger.setVerbose(true); - const debug = vi.spyOn(console, 'debug').mockImplementation(() => undefined); - vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); - vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); - vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); - vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( - undefined, - ); - - try { - const instance = await getAppleSimulatorPlatformInstance( - simulatorConfig, - harnessConfigWithPermissionsEnabled, - { signal: new AbortController().signal }, - ); - - expect(debug).toHaveBeenCalledWith( - expect.stringContaining('selected sequential XCTest build'), - ); - await instance.dispose(); - } finally { - logger.setVerbose(wasVerbose); - debug.mockRestore(); - } - }); - - it('cleans up after a sequential XCTest build failure', async () => { - const buildError = new Error('build failed'); - mocks.prepare.mockRejectedValue(buildError); - const getSimulatorStatus = vi.spyOn(simctl, 'getSimulatorStatus'); - vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); - - await expect( - getAppleSimulatorPlatformInstance( - simulatorConfig, - harnessConfigWithPermissionsEnabled, - { signal: new AbortController().signal }, - ), - ).rejects.toThrow(buildError); - - expect(getSimulatorStatus).not.toHaveBeenCalled(); - expect(mocks.dispose).toHaveBeenCalledTimes(1); - }); - - it('waits for and cleans up a parallel XCTest build when simulator preparation fails', async () => { - startupStrategyMocks.shouldOverlapXCTestBuildAndSimulatorBoot.mockReturnValue( - true, - ); - let resolvePreparation: ((value: undefined) => void) | undefined; - mocks.prepare.mockImplementation( - () => - new Promise((resolve) => { - resolvePreparation = resolve; - }), - ); - const simulatorError = new Error('simulator failed'); - vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); - vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Creating'); - vi.spyOn(simctl, 'bootSimulator').mockResolvedValue(undefined); - vi.spyOn(simctl, 'waitForBoot').mockRejectedValue(simulatorError); - const shutdownSimulator = vi - .spyOn(simctl, 'shutdownSimulator') - .mockResolvedValue(undefined); - - const instancePromise = getAppleSimulatorPlatformInstance( - simulatorConfig, - harnessConfigWithPermissionsEnabled, - { signal: new AbortController().signal }, - ); - - await Promise.resolve(); - expect(shutdownSimulator).not.toHaveBeenCalled(); - resolvePreparation?.(undefined); - - await expect(instancePromise).rejects.toThrow(simulatorError); - expect(mocks.dispose).toHaveBeenCalledTimes(1); - expect(shutdownSimulator).toHaveBeenCalledWith('sim-udid'); - }); - - it('propagates a parallel XCTest build failure and cleans up simulator preparation', async () => { - startupStrategyMocks.shouldOverlapXCTestBuildAndSimulatorBoot.mockReturnValue( - true, - ); - const buildError = new Error('build failed'); - mocks.prepare.mockRejectedValue(buildError); - vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); - vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); - vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); - vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( - undefined, - ); - const clearHarnessJsLocationOverride = vi - .spyOn(simctl, 'clearHarnessJsLocationOverride') - .mockResolvedValue(undefined); - - await expect( - getAppleSimulatorPlatformInstance( - simulatorConfig, - harnessConfigWithPermissionsEnabled, - { signal: new AbortController().signal }, - ), - ).rejects.toThrow(buildError); - - expect(mocks.dispose).toHaveBeenCalledTimes(1); - expect(clearHarnessJsLocationOverride).toHaveBeenCalledWith( - 'sim-udid', - 'com.harnessplayground', - ); - }); -}); - -const simulatorConfig = { - name: 'ios', - device: { - type: 'simulator' as const, - name: 'iPhone 16 Pro', - systemVersion: '18.0', - }, - bundleId: 'com.harnessplayground', -}; diff --git a/packages/platform-ios/src/__tests__/instance.test.ts b/packages/platform-ios/src/__tests__/instance.test.ts index 4c7804a1..724f98d7 100644 --- a/packages/platform-ios/src/__tests__/instance.test.ts +++ b/packages/platform-ios/src/__tests__/instance.test.ts @@ -14,15 +14,15 @@ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -const xctestAgentMocks = vi.hoisted(() => ({ - createXCTestAgentController: vi.fn(), +const permissionAgentMocks = vi.hoisted(() => ({ + createIosPermissionAgent: vi.fn(), dispose: vi.fn(async () => undefined), - ensureStarted: vi.fn(async () => undefined), prepare: vi.fn(async () => undefined), + setAppRunning: vi.fn(), })); -vi.mock('../xctest-agent.js', () => ({ - createXCTestAgentController: xctestAgentMocks.createXCTestAgentController, +vi.mock('../permission-agent.js', () => ({ + createIosPermissionAgent: permissionAgentMocks.createIosPermissionAgent, })); const harnessConfig = { @@ -45,11 +45,11 @@ describe('iOS platform instance dependency validation', () => { beforeEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); - xctestAgentMocks.createXCTestAgentController.mockReturnValue({ - prepare: xctestAgentMocks.prepare, - ensureStarted: xctestAgentMocks.ensureStarted, - stop: vi.fn(async () => undefined), - dispose: xctestAgentMocks.dispose, + permissionAgentMocks.prepare.mockResolvedValue(undefined); + permissionAgentMocks.createIosPermissionAgent.mockReturnValue({ + prepare: permissionAgentMocks.prepare, + setAppRunning: permissionAgentMocks.setAppRunning, + dispose: permissionAgentMocks.dispose, }); }); @@ -76,7 +76,7 @@ describe('iOS platform instance dependency validation', () => { ).resolves.toBeDefined(); }); - it('does not start the simulator XCTest agent when permissions are disabled', async () => { + it('does not start iOS permission automation when permissions are disabled', async () => { vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); @@ -98,7 +98,9 @@ describe('iOS platform instance dependency validation', () => { init, ); - expect(xctestAgentMocks.createXCTestAgentController).not.toHaveBeenCalled(); + expect( + permissionAgentMocks.createIosPermissionAgent, + ).not.toHaveBeenCalled(); }); it('discovers the physical device directly through devicectl', async () => { @@ -125,10 +127,10 @@ describe('iOS platform instance dependency validation', () => { await expect( getApplePhysicalDevicePlatformInstance(config, harnessConfig), ).resolves.toBeDefined(); - expect(getDevice).toHaveBeenCalledWith('My iPhone'); + expect(getDevice).toHaveBeenCalledWith('My iPhone', undefined); }); - it('does not start the physical-device XCTest agent when permissions are disabled', async () => { + it('does not start physical-device permission automation when permissions are disabled', async () => { vi.spyOn(devicectl, 'getDevice').mockResolvedValue({ identifier: 'physical-device-id', deviceProperties: { @@ -156,7 +158,9 @@ describe('iOS platform instance dependency validation', () => { harnessConfig, ); - expect(xctestAgentMocks.createXCTestAgentController).not.toHaveBeenCalled(); + expect( + permissionAgentMocks.createIosPermissionAgent, + ).not.toHaveBeenCalled(); }); it('skips physical crash monitoring setup when native crash detection is disabled', async () => { diff --git a/packages/platform-ios/src/__tests__/launch-options.test.ts b/packages/platform-ios/src/__tests__/launch-options.test.ts index 971da54c..6916db2a 100644 --- a/packages/platform-ios/src/__tests__/launch-options.test.ts +++ b/packages/platform-ios/src/__tests__/launch-options.test.ts @@ -52,8 +52,8 @@ describe('Apple app launch options', () => { it('passes console mode to devicectl process launch streams', () => { const spawnSpy = vi - .spyOn(tools, 'spawn') - .mockResolvedValue({} as Awaited>); + .spyOn(tools, 'spawnOwnedProcess') + .mockReturnValue({} as ReturnType); launchDeviceAppProcess('device-id', 'com.example.app', { arguments: ['--mode=test'], @@ -90,6 +90,9 @@ describe('Apple app launch options', () => { const spawnSpy = vi .spyOn(tools, 'spawn') .mockResolvedValue({} as Awaited>); + const spawnOwnedProcessSpy = vi + .spyOn(tools, 'spawnOwnedProcess') + .mockReturnValue({} as ReturnType); await startApp('sim-udid', 'com.example.app', { arguments: ['--mode=test'], @@ -115,8 +118,8 @@ describe('Apple app launch options', () => { }, ); - expect(spawnSpy).toHaveBeenNthCalledWith( - 2, + expect(spawnOwnedProcessSpy).toHaveBeenNthCalledWith( + 1, 'xcrun', [ 'simctl', diff --git a/packages/platform-ios/src/__tests__/permission-agent.test.ts b/packages/platform-ios/src/__tests__/permission-agent.test.ts new file mode 100644 index 00000000..03ca682f --- /dev/null +++ b/packages/platform-ios/src/__tests__/permission-agent.test.ts @@ -0,0 +1,371 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const mocks = vi.hoisted(() => ({ + createAgentDeviceClient: vi.fn(), + alert: vi.fn(), + press: vi.fn(), + prepare: vi.fn(), + spawn: vi.fn(), +})); + +vi.mock('agent-device', () => ({ + createAgentDeviceClient: mocks.createAgentDeviceClient, +})); + +vi.mock('@react-native-harness/tools', async (importOriginal) => { + const actual = + await importOriginal(); + + return { ...actual, spawn: mocks.spawn }; +}); + +const { + assertSupportedNodeVersion, + copyAgentDeviceLogs, + createIosPermissionAgent, + getAgentDeviceStateDir, + getPermissionWatchdogIntervalMs, +} = await import('../permission-agent.js'); + +const createAgentDeviceError = ( + code: string, + message: string, + details?: Record +) => Object.assign(new Error(message), { code, details }); + +const alertNotFoundError = () => + createAgentDeviceError('COMMAND_FAILED', 'alert not found', { + runnerErrorCode: 'ALERT_NOT_FOUND', + }); + +let projectRoot: string; +let homeDir: string; + +const createAgent = ( + overrides: Partial[0]> = {} +) => + createIosPermissionAgent({ + appBundleId: 'com.harnessplayground', + target: { kind: 'simulator', udid: 'sim-udid' }, + projectRoot, + ...overrides, + }); + +describe('iOS permission agent', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-project-')); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + vi.spyOn(os, 'homedir').mockReturnValue(homeDir); + vi.spyOn(process, 'cwd').mockReturnValue(projectRoot); + + // Keep the watchdog fast; the default is one second between calls. + vi.stubEnv('HARNESS_PERMISSION_WATCHDOG_INTERVAL_MS', '1'); + vi.stubEnv('HARNESS_XCTEST_AGENT_TICK_INTERVAL_MS', ''); + vi.stubEnv('HARNESS_IOS_XCTESTRUN_FILE', ''); + vi.stubEnv('HARNESS_IOS_XCTEST_DERIVED_DATA_PATH', ''); + vi.stubEnv('AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH', ''); + vi.stubEnv('AGENT_DEVICE_IOS_TEAM_ID', ''); + vi.stubEnv('AGENT_DEVICE_IOS_SIGNING_IDENTITY', ''); + vi.stubEnv('AGENT_DEVICE_IOS_PROVISIONING_PROFILE', ''); + vi.stubEnv('AGENT_DEVICE_IOS_BUNDLE_ID', ''); + + mocks.prepare.mockResolvedValue({}); + mocks.alert.mockRejectedValue(alertNotFoundError()); + mocks.press.mockResolvedValue({}); + mocks.spawn.mockResolvedValue({ stdout: '', stderr: '' }); + mocks.createAgentDeviceClient.mockReturnValue({ + command: { alert: mocks.alert, prepare: mocks.prepare }, + interactions: { press: mocks.press }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + it('creates the client against a Harness-owned state dir and prepares the iOS runner', async () => { + const agent = createAgent(); + + await agent.prepare(); + + const stateDir = getAgentDeviceStateDir(projectRoot); + expect(stateDir.startsWith(path.join(homeDir, '.agent-device', 'harness'))) + .toBe(true); + expect(mocks.createAgentDeviceClient).toHaveBeenCalledWith( + expect.objectContaining({ stateDir, session: 'harness' }) + ); + expect(mocks.prepare).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'ios-runner', + platform: 'ios', + udid: 'sim-udid', + }) + ); + expect(process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH).toBe( + path.join(projectRoot, '.harness', 'cache', 'agent-device-runner') + ); + + await agent.dispose(); + }); + + it('leaves a user-provided runner derived path untouched', async () => { + vi.stubEnv('AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH', '/custom/derived'); + + const agent = createAgent(); + await agent.prepare(); + + expect(process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH).toBe( + '/custom/derived' + ); + + await agent.dispose(); + }); + + it('does not poll while the app under test is not running', async () => { + const agent = createAgent(); + await agent.prepare(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(mocks.alert).not.toHaveBeenCalled(); + + agent.setAppRunning(true); + await vi.waitFor(() => expect(mocks.alert).toHaveBeenCalled()); + + agent.setAppRunning(false); + const callsWhileStopped = mocks.alert.mock.calls.length; + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(mocks.alert.mock.calls.length).toBeLessThanOrEqual( + callsWhileStopped + 1 + ); + + await agent.dispose(); + }); + + it('taps the first known positive button on a prompt', async () => { + mocks.alert.mockResolvedValue({ + message: 'Allow “Playground” to use your location?', + items: ['Allow Once', 'Allow While Using App', 'Don’t Allow'], + }); + + const agent = createAgent(); + await agent.prepare(); + agent.setAppRunning(true); + + await vi.waitFor(() => expect(mocks.press).toHaveBeenCalled()); + + expect(mocks.press).toHaveBeenCalledWith({ + platform: 'ios', + udid: 'sim-udid', + selector: 'label="Allow Once"', + }); + + await agent.dispose(); + }); + + it('never taps a negative button', async () => { + mocks.alert.mockResolvedValue({ + message: 'Delete everything?', + items: ['Don’t Allow', 'Cancel'], + }); + + const agent = createAgent(); + await agent.prepare(); + agent.setAppRunning(true); + + await vi.waitFor(() => expect(mocks.alert).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(mocks.press).not.toHaveBeenCalled(); + + await agent.dispose(); + }); + + it.each([ + ['ALERT_NOT_FOUND', alertNotFoundError()], + [ + 'RUNNER_BUSY', + createAgentDeviceError('RUNNER_BUSY', 'The iOS runner is still busy'), + ], + [ + 'a timeout', + createAgentDeviceError('COMMAND_FAILED', 'main thread execution timed out'), + ], + ])('treats %s as transient and keeps polling', async (_label, error) => { + mocks.alert.mockRejectedValue(error); + + const agent = createAgent(); + await agent.prepare(); + agent.setAppRunning(true); + + await vi.waitFor(() => + expect(mocks.alert.mock.calls.length).toBeGreaterThan(1) + ); + expect(mocks.press).not.toHaveBeenCalled(); + + await agent.dispose(); + }); + + it('forwards external xctestrun artifacts to the client', async () => { + const xctestrunFile = path.join(projectRoot, 'Runner.xctestrun'); + const derivedDataPath = path.join(projectRoot, 'DerivedData'); + fs.writeFileSync(xctestrunFile, ''); + fs.mkdirSync(derivedDataPath); + vi.stubEnv('HARNESS_IOS_XCTESTRUN_FILE', xctestrunFile); + vi.stubEnv('HARNESS_IOS_XCTEST_DERIVED_DATA_PATH', derivedDataPath); + + const agent = createAgent(); + await agent.prepare(); + + expect(mocks.createAgentDeviceClient).toHaveBeenCalledWith( + expect.objectContaining({ + iosXctestrunFile: xctestrunFile, + iosXctestDerivedDataPath: derivedDataPath, + }) + ); + + await agent.dispose(); + }); + + it('fails when an external xctestrun artifact is missing', async () => { + vi.stubEnv('HARNESS_IOS_XCTESTRUN_FILE', path.join(projectRoot, 'nope')); + + const agent = createAgent(); + + await expect(agent.prepare()).rejects.toThrow( + 'HARNESS_IOS_XCTESTRUN_FILE' + ); + + await agent.dispose(); + }); + + it('maps physical device code signing onto the daemon environment', async () => { + const agent = createAgent({ + target: { + kind: 'device', + udid: 'device-udid', + codeSign: { + teamId: 'TEAMID1234', + signingIdentity: 'Apple Development: Someone', + provisioningProfile: 'Harness Profile', + runnerBundleId: 'com.example.runner', + }, + }, + }); + + await agent.prepare(); + + expect(process.env.AGENT_DEVICE_IOS_TEAM_ID).toBe('TEAMID1234'); + expect(process.env.AGENT_DEVICE_IOS_SIGNING_IDENTITY).toBe( + 'Apple Development: Someone' + ); + expect(process.env.AGENT_DEVICE_IOS_PROVISIONING_PROFILE).toBe( + 'Harness Profile' + ); + expect(process.env.AGENT_DEVICE_IOS_BUNDLE_ID).toBe('com.example.runner'); + + await agent.dispose(); + }); + + it('stops the daemon with --clean and copies the agent-device logs on dispose', async () => { + const agent = createAgent(); + await agent.prepare(); + + const stateDir = getAgentDeviceStateDir(projectRoot); + const sessionDir = path.join(stateDir, 'sessions', 'cwd_abc_harness'); + fs.mkdirSync(sessionDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, 'daemon.log'), 'daemon output'); + fs.writeFileSync(path.join(sessionDir, 'runner.log'), 'runner output'); + + await agent.dispose(); + + expect(mocks.spawn).toHaveBeenCalledWith( + process.execPath, + expect.arrayContaining([ + 'daemon', + 'stop', + '--state-dir', + stateDir, + '--clean', + '--json', + ]), + expect.anything() + ); + + const logsRoot = path.join(projectRoot, '.harness', 'logs'); + const runDirectories = fs.readdirSync(logsRoot); + expect(runDirectories).toHaveLength(1); + + const runDirectory = path.join(logsRoot, runDirectories[0] as string); + expect(fs.readFileSync(path.join(runDirectory, 'daemon.log'), 'utf8')).toBe( + 'daemon output' + ); + expect(fs.readFileSync(path.join(runDirectory, 'runner.log'), 'utf8')).toBe( + 'runner output' + ); + }); + + it('surfaces DEVICE_IN_USE verbatim with its hint and does not retry', async () => { + mocks.prepare.mockRejectedValue( + createAgentDeviceError( + 'DEVICE_IN_USE', + 'ios device sim-udid is owned by session "ios" in workspace "/other".', + { hint: 'Close the other session first.' } + ) + ); + + const agent = createAgent(); + + await expect(agent.prepare()).rejects.toThrow( + /is owned by session "ios" in workspace "\/other"\./ + ); + await expect(agent.prepare()).rejects.toThrow( + /Close the other session first\./ + ); + + // One attempt per prepare() call: the claim is never retried internally. + expect(mocks.prepare).toHaveBeenCalledTimes(2); + + await agent.dispose(); + }); + + it('rejects Node versions below the agent-device floor', () => { + expect(() => assertSupportedNodeVersion('22.11.0')).toThrow( + /requires Node\.js 22\.12 or newer/ + ); + expect(() => assertSupportedNodeVersion('20.19.0')).toThrow( + /requires Node\.js 22\.12 or newer/ + ); + expect(() => assertSupportedNodeVersion('22.12.0')).not.toThrow(); + expect(() => assertSupportedNodeVersion('24.1.0')).not.toThrow(); + }); + + it('reads the watchdog interval from either environment variable', () => { + vi.stubEnv('HARNESS_PERMISSION_WATCHDOG_INTERVAL_MS', ''); + vi.stubEnv('HARNESS_XCTEST_AGENT_TICK_INTERVAL_MS', ''); + expect(getPermissionWatchdogIntervalMs()).toBe(1000); + + vi.stubEnv('HARNESS_XCTEST_AGENT_TICK_INTERVAL_MS', '250'); + expect(getPermissionWatchdogIntervalMs()).toBe(250); + + vi.stubEnv('HARNESS_PERMISSION_WATCHDOG_INTERVAL_MS', '500'); + expect(getPermissionWatchdogIntervalMs()).toBe(500); + }); + + it('tolerates a state dir without any logs', () => { + const stateDir = path.join(projectRoot, 'empty-state'); + const targetDirectory = path.join(projectRoot, 'logs-out'); + fs.mkdirSync(stateDir); + fs.mkdirSync(targetDirectory); + + expect(copyAgentDeviceLogs({ stateDir, targetDirectory })).toEqual([]); + }); +}); diff --git a/packages/platform-ios/src/__tests__/simctl.test.ts b/packages/platform-ios/src/__tests__/simctl.test.ts index 5a59c0e8..fe057cca 100644 --- a/packages/platform-ios/src/__tests__/simctl.test.ts +++ b/packages/platform-ios/src/__tests__/simctl.test.ts @@ -46,15 +46,17 @@ describe('simctl startup', () => { }); it('starts simulator log streaming with the provided predicate', () => { - const subprocess = {} as ReturnType; - const spawnSpy = vi.spyOn(tools, 'spawn').mockReturnValue(subprocess); + const ownedProcess = {} as ReturnType; + const spawnSpy = vi + .spyOn(tools, 'spawnOwnedProcess') + .mockReturnValue(ownedProcess); expect( streamLogs( 'sim-udid', 'process == "HarnessPlayground" OR process == "com.harnessplayground"' ) - ).toBe(subprocess); + ).toBe(ownedProcess); expect(spawnSpy).toHaveBeenCalledWith( 'xcrun', diff --git a/packages/platform-ios/src/__tests__/startup-strategy.test.ts b/packages/platform-ios/src/__tests__/startup-strategy.test.ts deleted file mode 100644 index 5312504d..00000000 --- a/packages/platform-ios/src/__tests__/startup-strategy.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => ({ - getHostCapabilities: vi.fn(), -})); - -vi.mock('@react-native-harness/tools', async (importOriginal) => ({ - ...(await importOriginal()), - getHostCapabilities: mocks.getHostCapabilities, -})); - -import { shouldOverlapXCTestBuildAndSimulatorBoot } from '../startup-strategy.js'; - -describe('shouldOverlapXCTestBuildAndSimulatorBoot', () => { - it.each([ - [false, 8 * 1024 ** 3, 7], - [false, 9 * 1024 ** 3, 6], - [true, 9 * 1024 ** 3, 7], - ])( - 'returns %s when memory is %i bytes and CPU count is %i', - (expected, totalMemoryBytes, availableCpuCount) => { - mocks.getHostCapabilities.mockReturnValue({ - totalMemoryBytes, - availableCpuCount, - }); - - expect(shouldOverlapXCTestBuildAndSimulatorBoot()).toBe(expected); - }, - ); -}); diff --git a/packages/platform-ios/src/__tests__/xctest-agent-capabilities.test.ts b/packages/platform-ios/src/__tests__/xctest-agent-capabilities.test.ts deleted file mode 100644 index 0aceb42c..00000000 --- a/packages/platform-ios/src/__tests__/xctest-agent-capabilities.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createPermissionPromptAutoAcceptCapability } from '../xctest-agent-capabilities.js'; - -describe('xctest agent capabilities', () => { - it('enables best-effort permission prompt auto-accept through launch environment', () => { - const capability = createPermissionPromptAutoAcceptCapability(); - - expect(capability.getLaunchEnvironment?.()).toEqual({ - HARNESS_XCTEST_AGENT_AUTO_ACCEPT_PERMISSIONS: '1', - }); - }); - - it('enables permission auto-accept in the runtime configuration', () => { - const capability = createPermissionPromptAutoAcceptCapability(); - - expect( - capability.updateConfiguration?.({ - permissions: { - autoAcceptPermissions: false, - }, - }), - ).toEqual({ - permissions: { - autoAcceptPermissions: true, - }, - }); - }); -}); diff --git a/packages/platform-ios/src/__tests__/xctest-agent-client.test.ts b/packages/platform-ios/src/__tests__/xctest-agent-client.test.ts deleted file mode 100644 index b83af609..00000000 --- a/packages/platform-ios/src/__tests__/xctest-agent-client.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createXCTestAgentClient } from '../xctest-agent-client.js'; -import type { XCTestAgentTransport } from '../xctest-agent-transport.js'; - -describe('xctest-agent client', () => { - it('sends typed permission commands over the internal transport', async () => { - const request = vi - .fn>() - .mockResolvedValueOnce({ - body: JSON.stringify({ - permissions: { - autoAcceptPermissions: false, - }, - status: 'ok', - }), - headers: {}, - statusCode: 200, - }) - .mockResolvedValueOnce({ - body: JSON.stringify({ - permissions: { - autoAcceptPermissions: true, - }, - }), - headers: {}, - statusCode: 200, - }) - .mockResolvedValueOnce({ - body: JSON.stringify({ - permissions: { - autoAcceptPermissions: true, - }, - }), - headers: {}, - statusCode: 200, - }) - .mockResolvedValueOnce({ - body: JSON.stringify({ - permissions: { - autoAcceptPermissions: true, - }, - status: 'shutting-down', - }), - headers: {}, - statusCode: 200, - }); - const dispose = vi.fn(async () => undefined); - const client = createXCTestAgentClient({ - dispose, - request, - }); - - await expect(client.health()).resolves.toEqual({ - permissions: { - autoAcceptPermissions: false, - }, - status: 'ok', - }); - await expect( - client.configurePermissions({ - autoAcceptPermissions: true, - }), - ).resolves.toEqual({ - autoAcceptPermissions: true, - }); - await expect(client.getPermissionsConfig()).resolves.toEqual({ - autoAcceptPermissions: true, - }); - await expect(client.shutdown()).resolves.toBeUndefined(); - - expect(request).toHaveBeenNthCalledWith(1, { - method: 'GET', - path: '/health', - body: undefined, - }); - expect(request).toHaveBeenNthCalledWith(2, { - method: 'POST', - path: '/permissions/configure', - body: JSON.stringify({ - autoAcceptPermissions: true, - }), - }); - expect(request).toHaveBeenNthCalledWith(3, { - method: 'GET', - path: '/permissions', - body: undefined, - }); - expect(request).toHaveBeenNthCalledWith(4, { - method: 'POST', - path: '/shutdown', - body: undefined, - }); - await client.dispose(); - expect(dispose).toHaveBeenCalledTimes(1); - }); - - it('throws on non-success responses', async () => { - const client = createXCTestAgentClient({ - dispose: vi.fn(async () => undefined), - request: vi.fn(async () => ({ - body: '{"error":"bad request"}', - headers: {}, - statusCode: 400, - })), - }); - - await expect(client.health()).rejects.toThrow( - 'XCTest agent GET /health failed with status 400', - ); - }); -}); diff --git a/packages/platform-ios/src/__tests__/xctest-agent.test.ts b/packages/platform-ios/src/__tests__/xctest-agent.test.ts deleted file mode 100644 index 0b6605dc..00000000 --- a/packages/platform-ios/src/__tests__/xctest-agent.test.ts +++ /dev/null @@ -1,972 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { createHash } from 'node:crypto'; -import { PassThrough } from 'node:stream'; -import { fileURLToPath } from 'node:url'; - -const mocks = vi.hoisted(() => ({ - activeAgentStops: [] as Array<() => void>, - configurePermissions: vi.fn(async () => ({ autoAcceptPermissions: true })), - disposeClient: vi.fn(async () => undefined), - disposeTransport: vi.fn(async () => undefined), - health: vi.fn(async () => ({ - permissions: { - autoAcceptPermissions: false, - }, - status: 'ok', - })), - kill: vi.fn(), - shutdown: vi.fn(async () => undefined), - spawn: vi.fn(), -})); - -vi.mock('@react-native-harness/tools', async () => { - const actual = await vi.importActual< - typeof import('@react-native-harness/tools') - >('@react-native-harness/tools'); - - return { - ...actual, - spawn: mocks.spawn, - spawnOwnedProcess: ( - file: string, - args: string[], - options?: Record - ) => { - const subprocess = mocks.spawn(file, args, options); - let disposePromise: Promise | undefined; - - return { - subprocess, - dispose: () => - (disposePromise ??= actual.terminate(subprocess, { - forceAfterMs: 1_000, - })), - }; - }, - }; -}); - -vi.mock('../xctest-agent-client.js', () => ({ - createXCTestAgentClient: vi.fn(() => ({ - configurePermissions: mocks.configurePermissions, - dispose: mocks.disposeClient, - getPermissionsConfig: vi.fn(), - health: mocks.health, - shutdown: mocks.shutdown, - })), -})); - -vi.mock('../xctest-agent-transport-simulator.js', () => ({ - createSimulatorXCTestAgentTransport: vi.fn(() => ({ - dispose: mocks.disposeTransport, - request: vi.fn(), - })), -})); - -vi.mock('../xctest-agent-transport-device.js', () => ({ - createDeviceXCTestAgentTransport: vi.fn(() => ({ - dispose: mocks.disposeTransport, - request: vi.fn(), - })), -})); - -import { - buildXCTestAgent, - createXCTestAgentController, -} from '../xctest-agent.js'; -import { createDeviceXCTestAgentTransport } from '../xctest-agent-transport-device.js'; -import { createSimulatorXCTestAgentTransport } from '../xctest-agent-transport-simulator.js'; - -const projectRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', - '..', - 'xctest-agent' -); -let simulatorCacheRoot = ''; -let deviceBuildRoot = ''; -let tempProjectRoot = ''; -const originalCwd = process.cwd(); -const simulatorRuntime = 'com.apple.CoreSimulator.SimRuntime.iOS-26-0'; -const simulatorSdkVersion = '26.0'; -const xcodeVersion = 'Xcode 26.0\nBuild version 17A123'; -const originalExternalXCTestRunFile = process.env.HARNESS_IOS_XCTESTRUN_FILE; -const originalExternalDerivedDataPath = - process.env.HARNESS_IOS_XCTEST_DERIVED_DATA_PATH; - -const createLongRunningSubprocess = (options?: { - ignoreSignal?: NodeJS.Signals; -}) => { - let stopped = false; - const listeners = new Set<() => void>(); - - const stop = () => { - stopped = true; - childProcess.exitCode = 0; - for (const listener of listeners) { - listener(); - } - }; - - const childProcess = { - exitCode: null as number | null, - kill: vi.fn((signal?: NodeJS.Signals) => { - mocks.kill(signal); - - if (signal === options?.ignoreSignal) { - return; - } - - stop(); - }), - off: vi.fn((_event: string, listener: () => void) => { - listeners.delete(listener); - return childProcess; - }), - once: vi.fn((_event: string, listener: () => void) => { - listeners.add(listener); - return childProcess; - }), - signalCode: null, - stderr: new PassThrough(), - stdout: new PassThrough(), - }; - - const iterable = { - nodeChildProcess: Promise.resolve(childProcess), - [Symbol.asyncIterator]() { - return { - next: async () => { - while (!stopped) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - - return { done: true, value: undefined }; - }, - }; - }, - }; - - return { - stop, - subprocess: iterable, - }; -}; - -describe('xctest-agent orchestration', () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.health.mockResolvedValue({ - permissions: { - autoAcceptPermissions: false, - }, - status: 'ok', - }); - mocks.configurePermissions.mockResolvedValue({ - autoAcceptPermissions: true, - }); - mocks.disposeClient.mockResolvedValue(undefined); - mocks.disposeTransport.mockResolvedValue(undefined); - tempProjectRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'rn-harness-xctest-agent-') - ); - process.chdir(tempProjectRoot); - simulatorCacheRoot = path.join(tempProjectRoot, '.harness', 'cache'); - deviceBuildRoot = path.join(tempProjectRoot, '.harness', 'xctest-agent'); - rmBuildRoot(); - mocks.activeAgentStops.length = 0; - mocks.shutdown.mockImplementation(async () => { - for (const stop of mocks.activeAgentStops) { - stop(); - } - }); - mocks.spawn.mockImplementation((file: string, args?: string[]) => { - if (file === 'xcodebuild' && args?.join(' ') === '-version') { - return Promise.resolve({ stdout: xcodeVersion }); - } - - if ( - file === 'xcodebuild' && - args?.join(' ') === '-version -sdk iphonesimulator SDKVersion' - ) { - return Promise.resolve({ stdout: simulatorSdkVersion }); - } - - if ( - file === 'xcrun' && - args?.join(' ') === 'simctl list devices --json' - ) { - return Promise.resolve({ - stdout: JSON.stringify({ - devices: { - [simulatorRuntime]: [ - { - isAvailable: true, - name: 'iPhone 16', - state: 'Shutdown', - udid: 'sim-123', - }, - { - isAvailable: true, - name: 'iPhone 16 Pro', - state: 'Shutdown', - udid: 'sim-999', - }, - { - isAvailable: true, - name: 'iPhone 16 Plus', - state: 'Shutdown', - udid: 'sim-timeout', - }, - { - isAvailable: true, - name: 'iPhone 16 Mini', - state: 'Shutdown', - udid: 'sim-404', - }, - ], - }, - }), - }); - } - - if (file === 'xcodebuild' && args?.[0] === 'test-without-building') { - const process = createLongRunningSubprocess(); - mocks.activeAgentStops.push(process.stop); - return process.subprocess; - } - - if (file === 'xcodebuild' && args?.[0] === 'build-for-testing') { - const derivedDataIndex = args.indexOf('-derivedDataPath'); - const derivedDataPath = - derivedDataIndex === -1 ? undefined : args[derivedDataIndex + 1]; - - if (derivedDataPath) { - const buildProductsPath = path.join( - derivedDataPath, - 'Build', - 'Products' - ); - fs.mkdirSync(path.join(buildProductsPath, 'Debug-iphonesimulator'), { - recursive: true, - }); - fs.writeFileSync( - path.join( - buildProductsPath, - 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun' - ), - 'cached xctestrun' - ); - } - } - - return createLongRunningSubprocess().subprocess; - }); - }); - - afterEach(() => { - for (const stop of mocks.activeAgentStops) { - stop(); - } - mocks.activeAgentStops.length = 0; - restoreEnvVar( - 'HARNESS_IOS_XCTESTRUN_FILE', - originalExternalXCTestRunFile - ); - restoreEnvVar( - 'HARNESS_IOS_XCTEST_DERIVED_DATA_PATH', - originalExternalDerivedDataPath - ); - rmBuildRoot(); - process.chdir(originalCwd); - fs.rmSync(tempProjectRoot, { recursive: true, force: true }); - tempProjectRoot = ''; - }); - - it('builds the simulator agent artifacts and writes a cache manifest', async () => { - const controller = createXCTestAgentController({ - target: { - kind: 'simulator', - id: 'sim-123', - }, - }); - - await controller.prepare(); - - expect(mocks.spawn).toHaveBeenNthCalledWith( - 3, - 'xcodebuild', - expect.arrayContaining([ - 'build-for-testing', - '-destination', - 'generic/platform=iOS Simulator', - ]), - { cwd: projectRoot, signal: undefined } - ); - const cacheDirectories = fs.readdirSync(simulatorCacheRoot); - expect(cacheDirectories).toHaveLength(1); - const cacheDirectory = cacheDirectories[0]; - expect(cacheDirectory).toBeDefined(); - if (!cacheDirectory) throw new Error('Expected cached simulator directory'); - expect(cacheDirectory).toMatch(/^xctest-agent-simulator-/); - expect( - fs.existsSync( - path.join(simulatorCacheRoot, cacheDirectory, 'cache.json') - ) - ).toBe(true); - }); - - it('builds standalone simulator agent artifacts with the generic simulator destination', async () => { - const result = await buildXCTestAgent({ - destination: 'simulator', - }); - - expect(result.destination).toBe('simulator'); - expect(result.reused).toBe(false); - expect(result.derivedDataPath).toContain('xctest-agent-simulator-'); - expect(result.xctestrunPath).toContain('.xctestrun'); - expect(mocks.spawn).toHaveBeenNthCalledWith( - 3, - 'xcodebuild', - expect.arrayContaining([ - 'build-for-testing', - '-destination', - 'generic/platform=iOS Simulator', - ]), - { cwd: projectRoot, signal: undefined } - ); - }); - - it('reuses standalone simulator agent artifacts when cache metadata matches', async () => { - writeSimulatorCacheDirectory({ - buildInputsHash: getCurrentInputsHash(), - directoryName: 'xctest-agent-simulator-existing', - }); - - const result = await buildXCTestAgent({ - destination: 'simulator', - }); - - expect(result.destination).toBe('simulator'); - expect(result.reused).toBe(true); - expect(result.derivedDataPath).toContain('xctest-agent-simulator-existing'); - expect(mocks.spawn).toHaveBeenCalledTimes(2); - }); - - it('builds standalone device agent artifacts without signing when no signing options are provided', async () => { - await buildXCTestAgent({ - destination: 'device', - }); - - const buildCall = mocks.spawn.mock.calls[0]; - const buildArgs = buildCall?.[1] ?? []; - - expect(buildCall?.[0]).toBe('xcodebuild'); - expect(buildArgs).toEqual( - expect.arrayContaining([ - 'build-for-testing', - '-destination', - 'generic/platform=iOS', - 'CODE_SIGNING_ALLOWED=NO', - 'CODE_SIGNING_REQUIRED=NO', - ]) - ); - expect(buildArgs).not.toEqual( - expect.arrayContaining([expect.stringContaining('DEVELOPMENT_TEAM=')]) - ); - expect(buildArgs).not.toEqual( - expect.arrayContaining([ - expect.stringContaining('PROVISIONING_PROFILE_SPECIFIER='), - ]) - ); - }); - - it('builds standalone device agent artifacts with signing options when provided', async () => { - await buildXCTestAgent({ - destination: 'device', - signing: { - teamId: 'TESTTEAM01', - }, - }); - - const buildCall = mocks.spawn.mock.calls[0]; - const buildArgs = buildCall?.[1] ?? []; - - expect(buildCall?.[0]).toBe('xcodebuild'); - expect(buildArgs).toEqual( - expect.arrayContaining([ - 'build-for-testing', - '-destination', - 'generic/platform=iOS', - '-allowProvisioningUpdates', - 'CODE_SIGN_STYLE=Automatic', - 'DEVELOPMENT_TEAM=TESTTEAM01', - 'CODE_SIGN_IDENTITY=Apple Development', - ]) - ); - expect(buildArgs).not.toContain('CODE_SIGNING_ALLOWED=NO'); - }); - - it('reuses cached build artifacts for repeated prepares on the same destination kind', async () => { - fs.mkdirSync(path.join(deviceBuildRoot, 'device', 'Build', 'Products'), { - recursive: true, - }); - fs.writeFileSync( - path.join(deviceBuildRoot, 'device', 'build-manifest.json'), - JSON.stringify({ - buildInputsHash: getCurrentInputsHash(), - signing: { - teamId: 'TESTTEAM01', - }, - destinationKind: 'device', - }) - ); - - const controller = createXCTestAgentController({ - target: { - kind: 'device', - id: 'device-123', - codeSign: { teamId: 'TESTTEAM01' }, - }, - }); - - await controller.prepare(); - - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('starts the agent lazily, waits for readiness, and configures permissions', async () => { - const controller = createXCTestAgentController({ - port: 49152, - target: { - kind: 'simulator', - id: 'sim-999', - }, - capabilities: [ - { - getLaunchEnvironment: () => ({ - HARNESS_XCTEST_AGENT_MODE: 'test', - }), - updateConfiguration: (configuration) => ({ - ...configuration, - permissions: { - ...configuration.permissions, - autoAcceptPermissions: true, - }, - }), - }, - ], - }); - - await controller.ensureStarted(); - await controller.ensureStarted(); - - expect(mocks.spawn).toHaveBeenCalledTimes(4); - expect(mocks.spawn).toHaveBeenLastCalledWith( - 'xcodebuild', - expect.arrayContaining([ - 'test-without-building', - '-destination', - 'platform=iOS Simulator,id=sim-999', - ]), - expect.objectContaining({ - env: expect.objectContaining({ - TEST_RUNNER_HARNESS_XCTEST_AGENT_MODE: 'test', - TEST_RUNNER_HARNESS_XCTEST_AGENT_PORT: '49152', - }), - }) - ); - expect(createSimulatorXCTestAgentTransport).toHaveBeenCalledWith({ - port: 49152, - }); - expect(mocks.health).toHaveBeenCalledTimes(1); - expect(mocks.configurePermissions).toHaveBeenCalledWith({ - autoAcceptPermissions: true, - }); - const logDirectories = fs.readdirSync( - path.join(tempProjectRoot, '.harness', 'logs') - ); - expect(logDirectories).toHaveLength(1); - const logDirectory = logDirectories[0]; - expect(logDirectory).toBeDefined(); - if (!logDirectory) throw new Error('Expected xcodebuild log directory'); - const xcodebuildLogPath = path.join( - tempProjectRoot, - '.harness', - 'logs', - logDirectory, - 'xcodebuild.log' - ); - expect(fs.existsSync(xcodebuildLogPath)).toBe(true); - expect(fs.readFileSync(xcodebuildLogPath, 'utf8')).toContain( - 'command=xcodebuild test-without-building' - ); - - await controller.dispose(); - - expect(mocks.shutdown).toHaveBeenCalledTimes(1); - expect(mocks.kill).not.toHaveBeenCalled(); - expect(mocks.disposeClient).toHaveBeenCalledTimes(1); - }); - - it('aborts an in-flight readiness request when the session is cancelled', async () => { - const signal = new AbortController(); - mocks.health.mockImplementation(() => new Promise(() => undefined)); - const controller = createXCTestAgentController({ - port: 49152, - target: { - kind: 'simulator', - id: 'sim-999', - }, - }); - - const starting = controller.ensureStarted(signal.signal); - await vi.waitFor(() => expect(mocks.health).toHaveBeenCalledOnce()); - signal.abort(); - - await expect(starting).rejects.toMatchObject({ name: 'AbortError' }); - expect(mocks.disposeClient).toHaveBeenCalledOnce(); - }); - - it('aborts the finite XCTest build when preparation is cancelled', async () => { - const controller = new AbortController(); - let buildSignal: AbortSignal | undefined; - mocks.spawn.mockImplementation( - (file: string, args?: string[], options?: { signal?: AbortSignal }) => { - if (file === 'xcodebuild' && args?.[0] === 'build-for-testing') { - buildSignal = options?.signal; - return new Promise((_, reject) => { - buildSignal?.addEventListener( - 'abort', - () => reject(buildSignal?.reason), - { once: true } - ); - }); - } - - return createLongRunningSubprocess().subprocess; - } - ); - const xctest = createXCTestAgentController({ - target: { - kind: 'device', - id: 'device-555', - }, - }); - - const preparing = xctest.prepare(controller.signal); - await vi.waitFor(() => expect(buildSignal).toBeDefined()); - controller.abort(); - - await expect(preparing).rejects.toMatchObject({ name: 'AbortError' }); - expect(buildSignal?.aborted).toBe(true); - }); - - it('selects the device transport for physical devices', async () => { - const controller = createXCTestAgentController({ - port: 49153, - target: { - kind: 'device', - id: 'device-555', - codeSign: { teamId: 'TESTTEAM01' }, - }, - }); - - await controller.ensureStarted(); - - expect(createDeviceXCTestAgentTransport).toHaveBeenCalledWith({ - deviceId: 'device-555', - port: 49153, - }); - await controller.dispose(); - }); - - it('requests graceful shutdown during disposal', async () => { - const controller = createXCTestAgentController({ - port: 49154, - shutdownTimeoutMs: 100, - target: { - kind: 'simulator', - id: 'sim-timeout', - }, - }); - - await controller.ensureStarted(); - await controller.dispose(); - - expect(mocks.shutdown).toHaveBeenCalledTimes(1); - expect(mocks.disposeClient).toHaveBeenCalledTimes(1); - expect(mocks.kill).not.toHaveBeenCalled(); - }); - - it('clears the graceful-shutdown timers once shutdown succeeds, leaving no pending timer', async () => { - vi.useFakeTimers(); - - try { - const controller = createXCTestAgentController({ - port: 49154, - shutdownTimeoutMs: 30_000, - target: { - kind: 'simulator', - id: 'sim-timeout', - }, - }); - - await controller.ensureStarted(); - await controller.dispose(); - - expect(mocks.shutdown).toHaveBeenCalledTimes(1); - expect(mocks.kill).not.toHaveBeenCalled(); - // Regression coverage for the process lingering ~25s after a harness - // run completed: waitForShutdown/waitForGracefulShutdown each raced a - // 30s delay() against the real shutdown and never cleared it when the - // shutdown won, leaving two ref'd Timeouts alive in the event loop. - expect(vi.getTimerCount()).toBe(0); - } finally { - vi.useRealTimers(); - } - }); - - it('kills the agent process when graceful shutdown times out', async () => { - mocks.shutdown.mockResolvedValue(undefined); - - const controller = createXCTestAgentController({ - port: 49154, - shutdownTimeoutMs: 1, - target: { - kind: 'simulator', - id: 'sim-timeout', - }, - }); - - await controller.ensureStarted(); - await controller.dispose(); - - expect(mocks.kill).toHaveBeenCalledTimes(1); - expect(mocks.kill).toHaveBeenCalledWith('SIGTERM'); - }); - - it('kills the agent process when the graceful shutdown request hangs', async () => { - mocks.shutdown.mockImplementation( - () => new Promise(() => undefined) - ); - - const controller = createXCTestAgentController({ - port: 49154, - shutdownTimeoutMs: 1, - target: { - kind: 'simulator', - id: 'sim-timeout', - }, - }); - - await controller.ensureStarted(); - await controller.dispose(); - - expect(mocks.kill).toHaveBeenCalledTimes(1); - expect(mocks.kill).toHaveBeenCalledWith('SIGTERM'); - }); - - it('force kills the agent process when graceful shutdown times out', async () => { - mocks.shutdown.mockResolvedValue(undefined); - - mocks.spawn.mockImplementation((file: string, args?: string[]) => { - if (file === 'xcodebuild' && args?.join(' ') === '-version') { - return Promise.resolve({ stdout: xcodeVersion }); - } - - if ( - file === 'xcodebuild' && - args?.join(' ') === '-version -sdk iphonesimulator SDKVersion' - ) { - return Promise.resolve({ stdout: simulatorSdkVersion }); - } - - if ( - file === 'xcrun' && - args?.join(' ') === 'simctl list devices --json' - ) { - return Promise.resolve({ - stdout: JSON.stringify({ - devices: { - [simulatorRuntime]: [ - { - isAvailable: true, - name: 'iPhone 16 Plus', - state: 'Shutdown', - udid: 'sim-timeout', - }, - ], - }, - }), - }); - } - - if (file === 'xcodebuild' && args?.[0] === 'test-without-building') { - return createLongRunningSubprocess({ ignoreSignal: 'SIGTERM' }) - .subprocess; - } - - if (file === 'xcodebuild' && args?.[0] === 'build-for-testing') { - const derivedDataIndex = args.indexOf('-derivedDataPath'); - const derivedDataPath = - derivedDataIndex === -1 ? undefined : args[derivedDataIndex + 1]; - - if (derivedDataPath) { - fs.mkdirSync(path.join(derivedDataPath, 'Build', 'Products'), { - recursive: true, - }); - fs.writeFileSync( - path.join( - derivedDataPath, - 'Build', - 'Products', - 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun' - ), - 'cached xctestrun' - ); - } - } - - return createLongRunningSubprocess().subprocess; - }); - - const controller = createXCTestAgentController({ - port: 49155, - shutdownTimeoutMs: 1, - target: { - kind: 'simulator', - id: 'sim-timeout', - }, - }); - - await controller.ensureStarted(); - await controller.dispose(); - - expect(mocks.kill).toHaveBeenCalledTimes(2); - expect(mocks.kill).toHaveBeenNthCalledWith(1, 'SIGTERM'); - expect(mocks.kill).toHaveBeenNthCalledWith(2, 'SIGKILL'); - }); - - it('rebuilds when the cached build manifest no longer matches project inputs', async () => { - writeSimulatorCacheDirectory({ - buildInputsHash: 'stale-manifest-hash', - directoryName: 'xctest-agent-simulator-stale', - }); - - const controller = createXCTestAgentController({ - target: { - kind: 'simulator', - id: 'sim-123', - }, - }); - - await controller.prepare(); - - expect(mocks.spawn).toHaveBeenCalledTimes(3); - expect(mocks.spawn).toHaveBeenNthCalledWith( - 3, - 'xcodebuild', - expect.arrayContaining(['build-for-testing']), - { cwd: projectRoot, signal: undefined } - ); - }); - - it('reuses simulator build artifacts only when the cache metadata matches', async () => { - writeSimulatorCacheDirectory({ - buildInputsHash: getCurrentInputsHash(), - directoryName: 'xctest-agent-simulator-existing', - }); - - const controller = createXCTestAgentController({ - target: { - kind: 'simulator', - id: 'sim-123', - }, - }); - - await controller.prepare(); - - expect(mocks.spawn).toHaveBeenCalledTimes(2); - }); - - it('skips building when an external xctestrun file is provided', async () => { - const externalDerivedDataPath = path.join(tempProjectRoot, 'external-derived'); - const externalXCTestRunFilePath = path.join( - tempProjectRoot, - 'external.xctestrun' - ); - - fs.mkdirSync(externalDerivedDataPath, { recursive: true }); - fs.writeFileSync(externalXCTestRunFilePath, 'external xctestrun'); - process.env.HARNESS_IOS_XCTESTRUN_FILE = externalXCTestRunFilePath; - process.env.HARNESS_IOS_XCTEST_DERIVED_DATA_PATH = externalDerivedDataPath; - - const controller = createXCTestAgentController({ - port: 49152, - target: { - kind: 'simulator', - id: 'sim-123', - }, - }); - - await controller.prepare(); - await controller.ensureStarted(); - - expect(mocks.spawn).toHaveBeenCalledTimes(1); - expect(mocks.spawn).toHaveBeenNthCalledWith( - 1, - 'xcodebuild', - expect.arrayContaining([ - 'test-without-building', - '-xctestrun', - externalXCTestRunFilePath, - '-derivedDataPath', - externalDerivedDataPath, - ]), - expect.any(Object) - ); - }); - - it('fails fast when the checked-in xcode project is missing', async () => { - const projectPath = path.join(projectRoot, 'HarnessXCTestAgent.xcodeproj'); - const hiddenProjectPath = path.join( - projectRoot, - 'HarnessXCTestAgent.xcodeproj.test-hidden' - ); - - fs.renameSync(projectPath, hiddenProjectPath); - - try { - const controller = createXCTestAgentController({ - target: { - kind: 'simulator', - id: 'sim-404', - }, - }); - - await expect(controller.prepare()).rejects.toThrow( - 'Missing checked-in XCTest agent project' - ); - expect(mocks.spawn).not.toHaveBeenCalled(); - } finally { - fs.renameSync(hiddenProjectPath, projectPath); - } - }); - - it('skips killing the agent process when dispose is called before startup', async () => { - const controller = createXCTestAgentController({ - target: { - kind: 'device', - id: 'device-123', - codeSign: { teamId: 'TESTTEAM01' }, - }, - }); - - await controller.dispose(); - - expect(mocks.kill).not.toHaveBeenCalled(); - }); -}); - -const rmBuildRoot = () => { - fs.rmSync(simulatorCacheRoot, { - force: true, - recursive: true, - }); - fs.rmSync(deviceBuildRoot, { - force: true, - recursive: true, - }); -}; - -const restoreEnvVar = (name: string, value: string | undefined) => { - if (value === undefined) { - delete process.env[name]; - return; - } - - process.env[name] = value; -}; - -const getCurrentInputsHash = (): string => { - const hash = createHash('sha256'); - - for (const filePath of getInputFiles(projectRoot)) { - hash.update(path.relative(projectRoot, filePath)); - hash.update('\0'); - hash.update(fs.readFileSync(filePath)); - hash.update('\0'); - } - - const sourceFilePath = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', - 'xctest-agent.ts' - ); - hash.update(path.basename(sourceFilePath)); - hash.update('\0'); - hash.update(fs.readFileSync(sourceFilePath)); - hash.update('\0'); - - return hash.digest('hex'); -}; - -const writeSimulatorCacheDirectory = (options: { - buildInputsHash: string; - directoryName: string; -}) => { - const derivedDataPath = path.join(simulatorCacheRoot, options.directoryName); - - fs.mkdirSync(path.join(derivedDataPath, 'Build', 'Products'), { - recursive: true, - }); - fs.writeFileSync( - path.join( - derivedDataPath, - 'Build', - 'Products', - 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun' - ), - 'cached xctestrun' - ); - fs.writeFileSync( - path.join(derivedDataPath, 'cache.json'), - JSON.stringify({ - artifactName: 'xctest-agent-simulator', - buildInputsHash: options.buildInputsHash, - destinationKind: 'simulator', - hostArchitecture: process.arch, - schemaVersion: 1, - simulatorSdkVersion, - xcodeVersion, - xctestrunRelativePath: - 'HarnessXCTestAgent_HarnessXCTestAgent_iphonesimulator26.0-arm64.xctestrun', - }) - ); -}; - -const getInputFiles = (root: string): string[] => { - const entries = fs.readdirSync(root, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - if (entry.name === 'build' || entry.name === '.gitignore') { - continue; - } - - const entryPath = path.join(root, entry.name); - - if (entry.isDirectory()) { - files.push(...getInputFiles(entryPath)); - continue; - } - - files.push(entryPath); - } - - return files.sort(); -}; diff --git a/packages/platform-ios/src/__tests__/xctest-command.test.ts b/packages/platform-ios/src/__tests__/xctest-command.test.ts deleted file mode 100644 index b94547a9..00000000 --- a/packages/platform-ios/src/__tests__/xctest-command.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - parseXCTestBuildArgs, - runXCTestBuildCommand, - type XCTestBuildModule, -} from '../xctest-command.js'; - -describe('xctest build command', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('parses a simulator build destination', () => { - const args = parseXCTestBuildArgs(['--destination', 'simulator']); - - expect(args).toEqual({ - destination: 'simulator', - provisioningProfile: undefined, - signingIdentity: undefined, - teamId: undefined, - }); - }); - - it('runs a device build without signing options', async () => { - const buildXCTestAgent = vi.fn(async () => ({ - derivedDataPath: '/tmp/project/.harness/xctest-agent/device', - destination: 'device' as const, - reused: false, - xctestrunPath: '/tmp/project/.harness/xctest-agent/device/file.xctestrun', - })); - const xctest = { - buildXCTestAgent, - } satisfies XCTestBuildModule; - vi.spyOn(console, 'log').mockImplementation(() => undefined); - - await runXCTestBuildCommand({ - args: ['--destination', 'device'], - cwd: '/tmp/project', - xctest, - }); - - expect(buildXCTestAgent).toHaveBeenCalledWith({ - destination: 'device', - projectRoot: '/tmp/project', - signing: undefined, - }); - }); - - it('runs a signed device build when signing options are provided', async () => { - const buildXCTestAgent = vi.fn(async () => ({ - derivedDataPath: '/tmp/project/.harness/xctest-agent/device', - destination: 'device' as const, - reused: true, - })); - const xctest = { - buildXCTestAgent, - } satisfies XCTestBuildModule; - vi.spyOn(console, 'log').mockImplementation(() => undefined); - - await runXCTestBuildCommand({ - args: ['--destination', 'device', '--teamId', 'TESTTEAM01'], - cwd: '/tmp/project', - xctest, - }); - - expect(buildXCTestAgent).toHaveBeenCalledWith({ - destination: 'device', - projectRoot: '/tmp/project', - signing: { - provisioningProfile: undefined, - signingIdentity: undefined, - teamId: 'TESTTEAM01', - }, - }); - }); - - it('throws when destination is missing', () => { - expect(() => parseXCTestBuildArgs([])).toThrow( - 'Missing required argument: destination' - ); - }); - - it('throws when destination is invalid', () => { - expect(() => parseXCTestBuildArgs(['--destination', 'android'])).toThrow( - 'Invalid values:' - ); - }); -}); diff --git a/packages/platform-ios/src/app-session.ts b/packages/platform-ios/src/app-session.ts index 795f12db..e5667c85 100644 --- a/packages/platform-ios/src/app-session.ts +++ b/packages/platform-ios/src/app-session.ts @@ -21,6 +21,12 @@ type CreateIosAppSessionOptions = { stopApp: () => Promise; isAppRunning: () => Promise; crashReporter?: IosCrashReporter; + /** + * Called whenever the observed run state of the app under test changes. + * Consumers that must not act while the app is down (the iOS permission + * watchdog) gate themselves on this instead of polling the device again. + */ + onAppRunningChange?: (running: boolean) => void; }; export const createIosAppSession = async ({ @@ -28,6 +34,7 @@ export const createIosAppSession = async ({ stopApp, isAppRunning, crashReporter, + onAppRunningChange, }: CreateIosAppSessionOptions): Promise => { const emitter = createAppSessionEmitter(); const logBuffer = createBoundedLogBuffer(); @@ -67,7 +74,24 @@ export const createIosAppSession = async ({ resolvePollDelay?.(); }; + let reportedRunning: boolean | null = null; + const reportRunning = (running: boolean) => { + if (reportedRunning === running) { + return; + } + + reportedRunning = running; + + try { + onAppRunningChange?.(running); + } catch (error) { + iosAppSessionLogger.debug('app running listener failed', error); + } + }; + const setExited = (reason: 'observed-exit' | 'process-gone') => { + reportRunning(false); + if (disposed || state.status !== 'running') { return; } @@ -109,9 +133,12 @@ export const createIosAppSession = async ({ try { if (await isAppRunning()) { hasObservedRunning = true; + reportRunning(true); } else if (hasObservedRunning) { setExited('process-gone'); return; + } else { + reportRunning(false); } } catch (error) { iosAppSessionLogger.debug('iOS app session poll failed', error); @@ -143,6 +170,7 @@ export const createIosAppSession = async ({ disposed = true; stopPolling = true; cancelPendingPollDelay(); + reportRunning(false); emitter.clear(); await Promise.allSettled([logTask, exitTask, pollTask]); await launchProcess; @@ -157,6 +185,7 @@ export const createIosAppSession = async ({ disposed = true; stopPolling = true; cancelPendingPollDelay(); + reportRunning(false); state = { status: 'disposed', occurredAt: Date.now() }; emitter.clear(); diff --git a/packages/platform-ios/src/cli.ts b/packages/platform-ios/src/cli.ts deleted file mode 100644 index c7b5ca97..00000000 --- a/packages/platform-ios/src/cli.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { HarnessCliModule } from '@react-native-harness/platforms'; -import { runXCTestCommand } from './xctest-command.js'; - -const cli = { - commands: [ - { - name: 'xctest', - run: async (args, context) => { - const { buildXCTestAgent } = await import('./xctest-agent.js'); - - await runXCTestCommand({ - args, - cwd: context.cwd, - xctest: { buildXCTestAgent }, - }); - }, - }, - ], -} satisfies HarnessCliModule; - -export const commands = cli.commands; - -export default cli; diff --git a/packages/platform-ios/src/config.ts b/packages/platform-ios/src/config.ts index 56490f86..343a9b7b 100644 --- a/packages/platform-ios/src/config.ts +++ b/packages/platform-ios/src/config.ts @@ -15,6 +15,12 @@ export const ApplePhysicalDeviceCodeSignSchema = z.object({ teamId: z.string().min(1, 'Team ID is required'), signingIdentity: z.string().optional(), provisioningProfile: z.string().optional(), + /** + * Bundle id used for the agent-device XCUITest runner on physical devices. + * Personal teams cannot sign the runner's default id; set an id covered by + * your provisioning profile instead. + */ + runnerBundleId: z.string().optional(), }); export const ApplePhysicalDeviceSchema = z.object({ diff --git a/packages/platform-ios/src/factory.ts b/packages/platform-ios/src/factory.ts index 56fcad91..a73af977 100644 --- a/packages/platform-ios/src/factory.ts +++ b/packages/platform-ios/src/factory.ts @@ -30,7 +30,6 @@ export const applePlatform = ( name: config.name, config, runner: import.meta.resolve('./runner.js'), - cli: import.meta.resolve('./cli.js'), platformId: 'ios', getResourceLockKey: () => config.device.type === 'simulator' diff --git a/packages/platform-ios/src/index.ts b/packages/platform-ios/src/index.ts index 6f35f792..555b3e00 100644 --- a/packages/platform-ios/src/index.ts +++ b/packages/platform-ios/src/index.ts @@ -6,10 +6,3 @@ export { export type { ApplePlatformConfig } from './config.js'; export { HarnessAppPathError } from './errors.js'; export { getRunTargets } from './targets.js'; -export { buildXCTestAgent } from './xctest-agent.js'; -export type { - BuildXCTestAgentOptions, - BuildXCTestAgentResult, - XCTestAgentBuildDestination, - XCTestAgentBuildSigning, -} from './xctest-agent.js'; diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 429e9cf0..e1222dd8 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -20,8 +20,7 @@ import { getDeviceName } from './utils.js'; import { HarnessAppPathError } from './errors.js'; import { instrumented, logger, noopDiagnostics } from '@react-native-harness/tools'; import fs from 'node:fs'; -import { createXCTestAgentController } from './xctest-agent.js'; -import { createPermissionPromptAutoAcceptCapability } from './xctest-agent-capabilities.js'; +import { createIosPermissionAgent } from './permission-agent.js'; import { collectNativeCoverage, cleanProfrawDir, @@ -31,7 +30,6 @@ import { createIosCrashReporter, getIosProcessNames, } from './crash-reporter.js'; -import { shouldOverlapXCTestBuildAndSimulatorBoot } from './startup-strategy.js'; const iosInstanceLogger = logger.child('ios-instance'); @@ -78,14 +76,13 @@ export const getAppleSimulatorPlatformInstance = async ( let startedByHarness = false; let harnessJsLocationOverrideApplied = false; - const xctestAgent = permissionsEnabled - ? createXCTestAgentController({ + const permissionAgent = permissionsEnabled + ? createIosPermissionAgent({ appBundleId: config.bundleId, target: { kind: 'simulator', - id: udid, + udid, }, - capabilities: [createPermissionPromptAutoAcceptCapability()], }) : null; @@ -144,36 +141,18 @@ export const getAppleSimulatorPlatformInstance = async ( harnessJsLocationOverrideApplied = true; }; - if (xctestAgent) { - const overlapStartup = shouldOverlapXCTestBuildAndSimulatorBoot(); - iosInstanceLogger.debug( - 'selected %s XCTest build and simulator startup strategy', - overlapStartup ? 'parallel' : 'sequential' - ); - - const xctestPreparation = overlapStartup - ? xctestAgent.prepare(init.signal) - : undefined; - // Observe an early build rejection while preparing the simulator; the - // original promise is still awaited below so its error is propagated. - void xctestPreparation?.catch(() => undefined); + await prepareSimulator(); + if (permissionAgent) { + // agent-device needs a booted target, so the runner is prepared after the + // simulator is up; its build is cached between runs. let agentStarted = false; try { - if (!overlapStartup) { - await xctestAgent.prepare(init.signal); - } - - await prepareSimulator(); - await xctestPreparation; - await xctestAgent.ensureStarted(init.signal); + await permissionAgent.prepare(init.signal); agentStarted = true; - } catch (error) { - await xctestPreparation?.catch(() => undefined); - throw error; } finally { if (!agentStarted) { - await xctestAgent.dispose(); + await permissionAgent.dispose(); if (harnessJsLocationOverrideApplied) { await simctl.clearHarnessJsLocationOverride(udid, config.bundleId); } @@ -182,8 +161,6 @@ export const getAppleSimulatorPlatformInstance = async ( } } } - } else { - await prepareSimulator(); } return { @@ -214,10 +191,11 @@ export const getAppleSimulatorPlatformInstance = async ( stopApp: () => simctl.stopApp(udid, config.bundleId), isAppRunning: () => simctl.isAppRunning(udid, config.bundleId), crashReporter, + onAppRunningChange: (running) => permissionAgent?.setAppRunning(running), }); }, dispose: async () => { - await xctestAgent?.dispose(); + await permissionAgent?.dispose(); await simctl.stopApp(udid, config.bundleId); await simctl.clearHarnessJsLocationOverride(udid, config.bundleId); @@ -273,32 +251,31 @@ export const getApplePhysicalDevicePlatformInstance = async ( ); } - const xctestAgent = + const permissionAgent = permissionsEnabled && config.device.codeSign - ? createXCTestAgentController({ + ? createIosPermissionAgent({ appBundleId: config.bundleId, target: { kind: 'device', - id: device.hardwareProperties.udid, + udid: device.hardwareProperties.udid, codeSign: config.device.codeSign, }, - capabilities: [createPermissionPromptAutoAcceptCapability()], }) : null; - if (xctestAgent) { + if (permissionAgent) { let agentStarted = false; try { - await xctestAgent.ensureStarted(init?.signal); + await permissionAgent.prepare(init?.signal); agentStarted = true; } finally { if (!agentStarted) { - await xctestAgent.dispose(); + await permissionAgent.dispose(); } } } else if (permissionsEnabled) { iosInstanceLogger.info( - 'Skipping XCTest agent for physical device (no codeSign config provided)' + 'Skipping iOS permission automation for physical device (no codeSign config provided)' ); } @@ -325,10 +302,11 @@ export const getApplePhysicalDevicePlatformInstance = async ( stopApp: () => devicectl.stopApp(deviceId, config.bundleId), isAppRunning: () => devicectl.isAppRunning(deviceId, config.bundleId), crashReporter, + onAppRunningChange: (running) => permissionAgent?.setAppRunning(running), }); }, dispose: async () => { - await xctestAgent?.dispose(); + await permissionAgent?.dispose(); await devicectl.stopApp(deviceId, config.bundleId); }, }; diff --git a/packages/platform-ios/src/permission-agent.ts b/packages/platform-ios/src/permission-agent.ts new file mode 100644 index 00000000..3696172b --- /dev/null +++ b/packages/platform-ios/src/permission-agent.ts @@ -0,0 +1,665 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + createHarnessArtifactDirectory, + delay as cancellableDelay, + getHarnessCacheArtifactPath, + logger, + spawn, + waitForAbort, +} from '@react-native-harness/tools'; +import type { ApplePhysicalDeviceCodeSign } from './config.js'; + +const permissionAgentLogger = logger.child('ios-permission-agent'); + +type AgentDeviceModule = typeof import('agent-device'); +type AgentDeviceClient = ReturnType< + AgentDeviceModule['createAgentDeviceClient'] +>; + +/** agent-device 0.21.0 declares `engines.node: ">=22.12"`. */ +const MINIMUM_NODE_MAJOR = 22; +const MINIMUM_NODE_MINOR = 12; + +const WATCHDOG_INTERVAL_MS_ENV = 'HARNESS_PERMISSION_WATCHDOG_INTERVAL_MS'; +/** Accepted alias kept from the removed in-process XCTest agent. */ +const LEGACY_WATCHDOG_INTERVAL_MS_ENV = 'HARNESS_XCTEST_AGENT_TICK_INTERVAL_MS'; +const XCTESTRUN_FILE_ENV = 'HARNESS_IOS_XCTESTRUN_FILE'; +const XCTEST_DERIVED_DATA_PATH_ENV = 'HARNESS_IOS_XCTEST_DERIVED_DATA_PATH'; +const RUNNER_DERIVED_PATH_ENV = 'AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH'; +const IOS_TEAM_ID_ENV = 'AGENT_DEVICE_IOS_TEAM_ID'; +const IOS_SIGNING_IDENTITY_ENV = 'AGENT_DEVICE_IOS_SIGNING_IDENTITY'; +const IOS_PROVISIONING_PROFILE_ENV = 'AGENT_DEVICE_IOS_PROVISIONING_PROFILE'; +const IOS_BUNDLE_ID_ENV = 'AGENT_DEVICE_IOS_BUNDLE_ID'; + +const RUNNER_CACHE_ARTIFACT_NAME = 'agent-device-runner'; +const AGENT_DEVICE_SESSION_NAME = 'harness'; +const DEFAULT_WATCHDOG_INTERVAL_MS = 1000; +/** + * One `alert get` round trip costs ~4 s against a normal app screen, so the + * per-command budget has to leave generous headroom above that before a poll + * is treated as a (transient) timeout. + */ +const ALERT_COMMAND_TIMEOUT_MS = 20_000; +const PREPARE_COMMAND_TIMEOUT_MS = 600_000; +const DAEMON_STOP_TIMEOUT_MS = 60_000; + +/** + * Buttons the watchdog is allowed to tap, in priority order. Carried over + * verbatim from the removed in-process XCTest watchdog, which tapped the first + * of these labels present on the prompt. + */ +const POSITIVE_BUTTON_LABELS = [ + 'Allow', + 'OK', + 'Continue', + 'Next', + 'While Using App', + 'While Using the App', + 'Always Allow', + 'Allow Once', + 'Allow While Using App', + 'Join', + 'Pair', + 'Allow Full Access', +] as const; + +const DEVICE_IN_USE_ERROR_CODE = 'DEVICE_IN_USE'; +const RUNNER_BUSY_ERROR_CODE = 'RUNNER_BUSY'; +const ALERT_NOT_FOUND_RUNNER_ERROR_CODE = 'ALERT_NOT_FOUND'; + +export type IosPermissionAgentTarget = + | { kind: 'simulator'; udid: string } + | { kind: 'device'; udid: string; codeSign?: ApplePhysicalDeviceCodeSign }; + +export type IosPermissionAgentOptions = { + appBundleId?: string; + target: IosPermissionAgentTarget; + projectRoot?: string; +}; + +export type IosPermissionAgent = { + /** + * Builds/attaches the agent-device iOS runner. Must be called after the + * device is booted and the app under test is installed. + */ + prepare: (signal?: AbortSignal) => Promise; + /** + * Gates the watchdog. Polling with SpringBoard's home screen foregrounded + * walks the whole icon grid and trips the runner's main-thread watchdog, so + * the loop only runs while the app under test is up. + */ + setAppRunning: (running: boolean) => void; + dispose: () => Promise; +}; + +export const assertSupportedNodeVersion = ( + nodeVersion = process.versions.node +): void => { + const [major, minor] = nodeVersion + .split('.') + .map((part) => Number.parseInt(part, 10)); + + if ( + Number.isFinite(major) && + Number.isFinite(minor) && + ((major as number) > MINIMUM_NODE_MAJOR || + ((major as number) === MINIMUM_NODE_MAJOR && + (minor as number) >= MINIMUM_NODE_MINOR)) + ) { + return; + } + + throw new Error( + `iOS permission automation requires Node.js ${MINIMUM_NODE_MAJOR}.${MINIMUM_NODE_MINOR} or newer, but this process runs Node.js ${nodeVersion}. Upgrade Node.js, or set \`permissions: false\` for this runner.` + ); +}; + +const getTrimmedEnvironmentValue = (name: string): string | undefined => { + const value = process.env[name]?.trim(); + + return value ? value : undefined; +}; + +export const getPermissionWatchdogIntervalMs = (): number => { + const rawValue = + getTrimmedEnvironmentValue(WATCHDOG_INTERVAL_MS_ENV) ?? + getTrimmedEnvironmentValue(LEGACY_WATCHDOG_INTERVAL_MS_ENV); + + if (rawValue === undefined) { + return DEFAULT_WATCHDOG_INTERVAL_MS; + } + + const parsed = Number.parseInt(rawValue, 10); + + if (!Number.isFinite(parsed) || parsed < 0) { + permissionAgentLogger.debug( + 'ignoring invalid permission watchdog interval %s', + rawValue + ); + + return DEFAULT_WATCHDOG_INTERVAL_MS; + } + + return parsed; +}; + +/** + * Harness runs its own agent-device daemon so it never replaces, or inherits + * stale signing environment from, a developer's own daemon. Device claims are + * host-global, so arbitration between the two stays correct. + */ +export const getAgentDeviceStateDir = (projectRoot: string): string => { + const projectHash = createHash('sha256') + .update(path.resolve(projectRoot)) + .digest('hex') + .slice(0, 16); + + return path.join(os.homedir(), '.agent-device', 'harness', projectHash); +}; + +export const getRunnerDerivedDataPath = (projectRoot: string): string => + getHarnessCacheArtifactPath(RUNNER_CACHE_ARTIFACT_NAME, projectRoot); + +const getExistingPath = (name: string): string | undefined => { + const value = getTrimmedEnvironmentValue(name); + + if (value === undefined) { + return undefined; + } + + if (!fs.existsSync(value)) { + throw new Error( + `Missing external XCTest artifact at ${value}. Check ${name}.` + ); + } + + return value; +}; + +/** + * The agent-device daemon is spawned lazily by the first client request and + * inherits that process's environment. agent-device 0.21.0 exposes no + * `daemon start` subcommand that could be handed a dedicated environment + * (`agent-device help daemon` documents `daemon stop` only), so the variables + * the daemon needs are written to `process.env` before the client is created. + */ +const applyDaemonEnvironment = ({ + runnerDerivedDataPath, + target, +}: { + runnerDerivedDataPath: string; + target: IosPermissionAgentTarget; +}): void => { + if (getTrimmedEnvironmentValue(RUNNER_DERIVED_PATH_ENV) === undefined) { + process.env[RUNNER_DERIVED_PATH_ENV] = runnerDerivedDataPath; + } + + if (target.kind !== 'device' || !target.codeSign) { + return; + } + + const { teamId, signingIdentity, provisioningProfile, runnerBundleId } = + target.codeSign; + + process.env[IOS_TEAM_ID_ENV] = teamId; + + if (signingIdentity) { + process.env[IOS_SIGNING_IDENTITY_ENV] = signingIdentity; + } + + if (provisioningProfile) { + process.env[IOS_PROVISIONING_PROFILE_ENV] = provisioningProfile; + } + + if (runnerBundleId) { + process.env[IOS_BUNDLE_ID_ENV] = runnerBundleId; + } +}; + +const getErrorCode = (error: unknown): string | undefined => { + if (typeof error === 'object' && error !== null && 'code' in error) { + const { code } = error as { code?: unknown }; + + return typeof code === 'string' ? code : undefined; + } + + return undefined; +}; + +const getErrorDetails = (error: unknown): Record => { + if (typeof error === 'object' && error !== null && 'details' in error) { + const { details } = error as { details?: unknown }; + + if (typeof details === 'object' && details !== null) { + return details as Record; + } + } + + return {}; +}; + +const isAlertNotFoundError = (error: unknown): boolean => + getErrorDetails(error).runnerErrorCode === ALERT_NOT_FOUND_RUNNER_ERROR_CODE; + +const isTransientWatchdogError = (error: unknown): boolean => { + if (isAlertNotFoundError(error)) { + return true; + } + + const code = getErrorCode(error); + + if (code === RUNNER_BUSY_ERROR_CODE) { + return true; + } + + if (typeof code === 'string' && code.includes('TIMEOUT')) { + return true; + } + + const message = error instanceof Error ? error.message : String(error); + + return /timed out|timeout/i.test(message); +}; + +export const isDeviceInUseError = (error: unknown): boolean => + getErrorCode(error) === DEVICE_IN_USE_ERROR_CODE; + +/** + * A claimed device is never retried: the owner is a live agent-device session, + * usually the developer's own, and quietly taking it over would break it. + */ +const createDeviceInUseError = (error: unknown): Error => { + const message = error instanceof Error ? error.message : String(error); + const hint = getErrorDetails(error).hint; + const lines = [ + `agent-device cannot claim this iOS device for permission automation: ${message}`, + ]; + + if (typeof hint === 'string' && hint.length > 0) { + lines.push(hint); + } + + lines.push( + 'Close the agent-device session that owns the device, or run Harness against a different device.' + ); + + return new Error(lines.join('\n')); +}; + +const findLabelToTap = (items: readonly string[]): string | undefined => { + for (const positiveLabel of POSITIVE_BUTTON_LABELS) { + const match = items.find((item) => item.trim() === positiveLabel); + + if (match !== undefined) { + return match; + } + } + + return undefined; +}; + +const getAlertItems = (result: unknown): string[] => { + if (typeof result !== 'object' || result === null) { + return []; + } + + const { items } = result as { items?: unknown }; + + if (!Array.isArray(items)) { + return []; + } + + return items.filter((item): item is string => typeof item === 'string'); +}; + +const getAlertMessage = (result: unknown): string => { + if (typeof result === 'object' && result !== null) { + const { message } = result as { message?: unknown }; + + if (typeof message === 'string') { + return message; + } + } + + return ''; +}; + +/** + * Resolves the package's own CLI entry point, which is the only way to stop + * the daemon: the Node client exposes no daemon-stop method. `agent-device`'s + * `exports` map covers neither `./package.json` nor `./bin/*`, so the package + * root is found by walking `node_modules` upwards from this module instead of + * through the resolver. + */ +export const resolveAgentDeviceBinPath = ( + startDirectory = path.dirname(fileURLToPath(import.meta.url)) +): string => { + let directory = startDirectory; + + while (true) { + const packageRoot = path.join(directory, 'node_modules', 'agent-device'); + + if (fs.existsSync(path.join(packageRoot, 'package.json'))) { + return path.join(packageRoot, 'bin', 'agent-device.mjs'); + } + + const parent = path.dirname(directory); + + if (parent === directory) { + throw new Error( + 'Could not locate the agent-device package; reinstall the project dependencies.' + ); + } + + directory = parent; + } +}; + +const copyIfExists = (sourcePath: string, targetPath: string): boolean => { + if (!fs.existsSync(sourcePath)) { + return false; + } + + fs.copyFileSync(sourcePath, targetPath); + + return true; +}; + +/** + * xcodebuild output no longer lands in the Harness logs directory; it lives in + * the agent-device session's `runner.log`. Copy both daemon-owned logs into the + * Harness logs artifact directory so CI keeps uploading them. + */ +export const copyAgentDeviceLogs = ({ + stateDir, + targetDirectory, +}: { + stateDir: string; + targetDirectory: string; +}): string[] => { + const copied: string[] = []; + + if (copyIfExists(path.join(stateDir, 'daemon.log'), path.join(targetDirectory, 'daemon.log'))) { + copied.push('daemon.log'); + } + + const sessionsDir = path.join(stateDir, 'sessions'); + + if (!fs.existsSync(sessionsDir)) { + return copied; + } + + const sessionDirectories = fs + .readdirSync(sessionsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + for (const [index, sessionDirectory] of sessionDirectories.entries()) { + const fileName = index === 0 ? 'runner.log' : `runner-${index}.log`; + + if ( + copyIfExists( + path.join(sessionsDir, sessionDirectory, 'runner.log'), + path.join(targetDirectory, fileName) + ) + ) { + copied.push(fileName); + } + } + + return copied; +}; + +export const createIosPermissionAgent = ( + options: IosPermissionAgentOptions +): IosPermissionAgent => { + const projectRoot = options.projectRoot ?? process.cwd(); + const { target } = options; + const stateDir = getAgentDeviceStateDir(projectRoot); + const intervalMs = getPermissionWatchdogIntervalMs(); + + const watchdogAbortController = new AbortController(); + let client: AgentDeviceClient | null = null; + let watchdogTask: Promise | null = null; + let appRunning = false; + let disposed = false; + let disposePromise: Promise | undefined; + + const pollOnce = async (): Promise => { + if (!client) { + return; + } + + let alertResult: unknown; + + try { + alertResult = await client.command.alert({ + action: 'get', + platform: 'ios', + udid: target.udid, + timeoutMs: ALERT_COMMAND_TIMEOUT_MS, + }); + } catch (error) { + if (isDeviceInUseError(error)) { + throw createDeviceInUseError(error); + } + + if (isTransientWatchdogError(error)) { + permissionAgentLogger.debug( + 'permission watchdog poll skipped: %s', + error instanceof Error ? error.message : String(error) + ); + + return; + } + + permissionAgentLogger.debug('permission watchdog poll failed', error); + + return; + } + + const items = getAlertItems(alertResult); + const label = findLabelToTap(items); + + if (label === undefined) { + permissionAgentLogger.debug( + 'no known positive button on the current prompt (items: %s)', + items.join(', ') + ); + + return; + } + + permissionAgentLogger.debug( + 'tapping "%s" on permission prompt "%s"', + label, + getAlertMessage(alertResult) + ); + + try { + await client.interactions.press({ + platform: 'ios', + udid: target.udid, + selector: `label="${label}"`, + }); + } catch (error) { + if (isDeviceInUseError(error)) { + throw createDeviceInUseError(error); + } + + permissionAgentLogger.debug( + 'failed to tap "%s" on the permission prompt', + label, + error + ); + } + }; + + // Serialised on purpose: the runner handles one command at a time, and + // overlapping requests are what produce RUNNER_BUSY storms. + const runWatchdog = async (): Promise => { + const { signal } = watchdogAbortController; + + while (!signal.aborted) { + if (appRunning) { + try { + await pollOnce(); + } catch (error) { + permissionAgentLogger.error( + 'stopping the iOS permission watchdog: %s', + error instanceof Error ? error.message : String(error) + ); + + return; + } + } + + if (signal.aborted) { + return; + } + + // The interval is the gap between calls, not a fixed period, so a slow + // round trip never queues another poll behind itself. + const tick = cancellableDelay(intervalMs); + const abortWait = waitForAbort(signal); + + try { + await Promise.race([tick.promise, abortWait.promise]); + } catch { + return; + } finally { + tick.cancel(); + abortWait.cancel(); + } + } + }; + + const stopDaemon = async (): Promise => { + let binPath: string; + + try { + binPath = resolveAgentDeviceBinPath(); + } catch (error) { + permissionAgentLogger.debug('could not resolve the agent-device CLI', error); + + return; + } + + try { + await spawn( + process.execPath, + [ + binPath, + 'daemon', + 'stop', + '--state-dir', + stateDir, + '--clean', + '--json', + ], + { timeout: DAEMON_STOP_TIMEOUT_MS } + ); + } catch (error) { + permissionAgentLogger.debug('failed to stop the agent-device daemon', error); + } + }; + + return { + prepare: async (signal) => { + assertSupportedNodeVersion(); + + const runnerDerivedDataPath = getRunnerDerivedDataPath(projectRoot); + applyDaemonEnvironment({ runnerDerivedDataPath, target }); + fs.mkdirSync(stateDir, { recursive: true }); + + const { createAgentDeviceClient } = await import('agent-device'); + + client = createAgentDeviceClient({ + stateDir, + session: AGENT_DEVICE_SESSION_NAME, + iosXctestrunFile: getExistingPath(XCTESTRUN_FILE_ENV), + iosXctestDerivedDataPath: getExistingPath( + XCTEST_DERIVED_DATA_PATH_ENV + ), + }); + + permissionAgentLogger.debug( + 'preparing the agent-device iOS runner for %s (state dir %s)', + target.udid, + stateDir + ); + + try { + await client.command.prepare({ + action: 'ios-runner', + platform: 'ios', + udid: target.udid, + timeoutMs: PREPARE_COMMAND_TIMEOUT_MS, + }); + } catch (error) { + if (isDeviceInUseError(error)) { + throw createDeviceInUseError(error); + } + + throw error; + } + + signal?.throwIfAborted(); + + watchdogTask = runWatchdog(); + }, + setAppRunning: (running) => { + appRunning = running; + }, + dispose: () => + (disposePromise ??= (async () => { + if (disposed) { + return; + } + + disposed = true; + appRunning = false; + watchdogAbortController.abort(); + + if (watchdogTask) { + await watchdogTask.catch(() => undefined); + watchdogTask = null; + } + + client = null; + + await stopDaemon(); + + try { + const logArtifacts = createHarnessArtifactDirectory({ + artifactType: 'logs', + bundleId: options.appBundleId, + platformId: 'ios', + runnerName: `permission-agent-${target.kind}`, + }); + const copied = copyAgentDeviceLogs({ + stateDir, + targetDirectory: logArtifacts.directoryPath, + }); + + if (copied.length > 0) { + permissionAgentLogger.debug( + 'copied agent-device logs (%s) to %s', + copied.join(', '), + logArtifacts.directoryPath + ); + } + } catch (error) { + permissionAgentLogger.debug( + 'failed to copy agent-device logs', + error + ); + } + })()), + }; +}; diff --git a/packages/platform-ios/src/startup-strategy.ts b/packages/platform-ios/src/startup-strategy.ts deleted file mode 100644 index 680b34ad..00000000 --- a/packages/platform-ios/src/startup-strategy.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getHostCapabilities } from '@react-native-harness/tools'; - -const MINIMUM_MEMORY_BYTES_FOR_PARALLEL_STARTUP = 8 * 1024 ** 3; -const MINIMUM_CPU_COUNT_FOR_PARALLEL_STARTUP = 6; - -export const shouldOverlapXCTestBuildAndSimulatorBoot = (): boolean => { - const { totalMemoryBytes, availableCpuCount } = getHostCapabilities(); - - return ( - totalMemoryBytes > MINIMUM_MEMORY_BYTES_FOR_PARALLEL_STARTUP && - availableCpuCount > MINIMUM_CPU_COUNT_FOR_PARALLEL_STARTUP - ); -}; diff --git a/packages/platform-ios/src/xctest-agent-capabilities.ts b/packages/platform-ios/src/xctest-agent-capabilities.ts deleted file mode 100644 index 38e270c1..00000000 --- a/packages/platform-ios/src/xctest-agent-capabilities.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { XCTestAgentCapability } from './xctest-agent.js'; - -const ENABLE_PERMISSION_PROMPT_AUTO_ACCEPT = - 'HARNESS_XCTEST_AGENT_AUTO_ACCEPT_PERMISSIONS'; - -export const createPermissionPromptAutoAcceptCapability = - (): XCTestAgentCapability => { - return { - getLaunchEnvironment: () => ({ - [ENABLE_PERMISSION_PROMPT_AUTO_ACCEPT]: '1', - }), - updateConfiguration: (configuration) => ({ - ...configuration, - permissions: { - ...configuration.permissions, - autoAcceptPermissions: true, - }, - }), - }; - }; diff --git a/packages/platform-ios/src/xctest-agent-client.ts b/packages/platform-ios/src/xctest-agent-client.ts deleted file mode 100644 index ca53d4c3..00000000 --- a/packages/platform-ios/src/xctest-agent-client.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { - XCTestAgentTransport, - XCTestAgentTransportResponse, -} from './xctest-agent-transport.js'; - -export type XCTestAgentPermissionsConfiguration = { - autoAcceptPermissions: boolean; -}; - -type XCTestAgentHealthResponse = { - permissions: XCTestAgentPermissionsConfiguration; - status: 'ok' | 'shutting-down'; -}; - -type XCTestAgentPermissionsResponse = { - permissions: XCTestAgentPermissionsConfiguration; -}; - -export type XCTestAgentClient = { - configurePermissions: ( - permissions: XCTestAgentPermissionsConfiguration, - ) => Promise; - dispose: () => Promise; - getPermissionsConfig: () => Promise; - health: () => Promise; - shutdown: () => Promise; -}; - -export const createXCTestAgentClient = ( - transport: XCTestAgentTransport, -): XCTestAgentClient => { - const requestJson = async (options: { - body?: unknown; - method: 'GET' | 'POST'; - path: string; - }): Promise => { - const response = await transport.request({ - method: options.method, - path: options.path, - body: - options.body === undefined ? undefined : JSON.stringify(options.body), - }); - - return parseJsonResponse(response, `${options.method} ${options.path}`); - }; - - return { - health: () => { - return requestJson({ - method: 'GET', - path: '/health', - }); - }, - configurePermissions: async (permissions) => { - const response = await requestJson({ - method: 'POST', - path: '/permissions/configure', - body: permissions, - }); - - return response.permissions; - }, - getPermissionsConfig: async () => { - const response = await requestJson({ - method: 'GET', - path: '/permissions', - }); - - return response.permissions; - }, - shutdown: async () => { - await requestJson({ - method: 'POST', - path: '/shutdown', - }); - }, - dispose: async () => { - await transport.dispose(); - }, - }; -}; - -const parseJsonResponse = ( - response: XCTestAgentTransportResponse, - operation: string, -): T => { - if (response.statusCode < 200 || response.statusCode >= 300) { - throw new Error( - `XCTest agent ${operation} failed with status ${response.statusCode}: ${response.body}`, - ); - } - - try { - return JSON.parse(response.body) as T; - } catch (error) { - throw new Error( - `XCTest agent ${operation} returned invalid JSON: ${getErrorMessage(error)}`, - ); - } -}; - -const getErrorMessage = (error: unknown): string => { - return error instanceof Error ? error.message : String(error); -}; diff --git a/packages/platform-ios/src/xctest-agent-transport-device.ts b/packages/platform-ios/src/xctest-agent-transport-device.ts deleted file mode 100644 index 218def13..00000000 --- a/packages/platform-ios/src/xctest-agent-transport-device.ts +++ /dev/null @@ -1,123 +0,0 @@ -import http from 'node:http'; -import * as devicectl from './xcrun/devicectl.js'; -import type { - XCTestAgentTransport, - XCTestAgentTransportRequest, - XCTestAgentTransportResponse, -} from './xctest-agent-transport.js'; - -export const createDeviceXCTestAgentTransport = (options: { - deviceId: string; - port: number; - timeoutMs?: number; -}): XCTestAgentTransport => { - const timeoutMs = options.timeoutMs ?? 5000; - const agent = new http.Agent({ keepAlive: false }); - let hostPromise: Promise | null = null; - - const getHost = (): Promise => { - if (!hostPromise) { - hostPromise = devicectl.getDeviceHostname(options.deviceId); - } - return hostPromise; - }; - - return { - request: async ( - request: XCTestAgentTransportRequest - ): Promise => { - return await performHttpRequest({ - agent, - body: request.body, - host: await getHost(), - method: request.method, - path: request.path, - port: options.port, - timeoutMs, - }); - }, - dispose: async () => { - agent.destroy(); - }, - }; -}; - -const performHttpRequest = async (options: { - agent: http.Agent; - body?: string; - host: string; - method: 'GET' | 'POST'; - path: string; - port: number; - timeoutMs: number; -}): Promise => { - return await new Promise((resolve, reject) => { - const request = http.request( - { - agent: options.agent, - host: options.host, - method: options.method, - path: options.path, - port: options.port, - timeout: options.timeoutMs, - headers: { - ...(options.body === undefined - ? {} - : { - 'content-type': 'application/json', - 'content-length': Buffer.byteLength(options.body, 'utf8'), - }), - connection: 'close', - }, - }, - (response) => { - const chunks: Buffer[] = []; - - response.on('data', (chunk: Buffer | string) => { - chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); - }); - - response.on('end', () => { - resolve({ - statusCode: response.statusCode ?? 0, - body: Buffer.concat(chunks).toString('utf8'), - headers: getResponseHeaders(response.headers), - }); - }); - - response.on('error', reject); - } - ); - - request.on('timeout', () => { - request.destroy( - new Error( - `Timed out waiting for XCTest agent response after ${options.timeoutMs}ms` - ) - ); - }); - request.on('error', reject); - - if (options.body !== undefined) { - request.write(options.body); - } - - request.end(); - }); -}; - -const getResponseHeaders = ( - headers: http.IncomingHttpHeaders -): Record => { - const values: Record = {}; - - for (const [key, value] of Object.entries(headers)) { - if (value === undefined) { - continue; - } - - values[key] = Array.isArray(value) ? value.join(', ') : value; - } - - return values; -}; diff --git a/packages/platform-ios/src/xctest-agent-transport-simulator.ts b/packages/platform-ios/src/xctest-agent-transport-simulator.ts deleted file mode 100644 index 28be6742..00000000 --- a/packages/platform-ios/src/xctest-agent-transport-simulator.ts +++ /dev/null @@ -1,103 +0,0 @@ -import http from 'node:http'; -import type { - XCTestAgentTransport, - XCTestAgentTransportRequest, - XCTestAgentTransportResponse, -} from './xctest-agent-transport.js'; - -export const createSimulatorXCTestAgentTransport = (options: { - host?: string; - port: number; -}): XCTestAgentTransport => { - const host = options.host ?? '127.0.0.1'; - const agent = new http.Agent({ keepAlive: false }); - - return { - request: async ( - request: XCTestAgentTransportRequest, - ): Promise => { - return await performHttpRequest({ - agent, - body: request.body, - host, - method: request.method, - path: request.path, - port: options.port, - }); - }, - dispose: async () => { - agent.destroy(); - }, - }; -}; - -const performHttpRequest = async (options: { - agent: http.Agent; - body?: string; - host: string; - method: 'GET' | 'POST'; - path: string; - port: number; -}): Promise => { - return await new Promise((resolve, reject) => { - const request = http.request( - { - agent: options.agent, - host: options.host, - method: options.method, - path: options.path, - port: options.port, - headers: { - ...(options.body === undefined - ? {} - : { - 'content-type': 'application/json', - 'content-length': Buffer.byteLength(options.body, 'utf8'), - }), - connection: 'close', - }, - }, - (response) => { - const chunks: Buffer[] = []; - - response.on('data', (chunk: Buffer | string) => { - chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); - }); - - response.on('end', () => { - resolve({ - statusCode: response.statusCode ?? 0, - body: Buffer.concat(chunks).toString('utf8'), - headers: getResponseHeaders(response.headers), - }); - }); - - response.on('error', reject); - }, - ); - - request.on('error', reject); - - if (options.body !== undefined) { - request.write(options.body); - } - - request.end(); - }); -}; - -const getResponseHeaders = ( - headers: http.IncomingHttpHeaders, -): Record => { - const values: Record = {}; - - for (const [key, value] of Object.entries(headers)) { - if (value === undefined) { - continue; - } - - values[key] = Array.isArray(value) ? value.join(', ') : value; - } - - return values; -}; diff --git a/packages/platform-ios/src/xctest-agent-transport.ts b/packages/platform-ios/src/xctest-agent-transport.ts deleted file mode 100644 index a0780b02..00000000 --- a/packages/platform-ios/src/xctest-agent-transport.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type XCTestAgentTransportRequest = { - method: 'GET' | 'POST'; - path: string; - body?: string; -}; - -export type XCTestAgentTransportResponse = { - body: string; - headers: Record; - statusCode: number; -}; - -export type XCTestAgentTransport = { - dispose: () => Promise; - request: ( - request: XCTestAgentTransportRequest, - ) => Promise; -}; diff --git a/packages/platform-ios/src/xctest-agent.ts b/packages/platform-ios/src/xctest-agent.ts deleted file mode 100644 index dd437620..00000000 --- a/packages/platform-ios/src/xctest-agent.ts +++ /dev/null @@ -1,1149 +0,0 @@ -import { - createHarnessArtifactDirectory, - getHarnessCacheRootPath, - getAvailablePort, - delay as cancellableDelay, - logger, - spawn, - spawnOwnedProcess, - waitForAbort, - type OwnedProcess, - type Subprocess, -} from '@react-native-harness/tools'; -import fs from 'node:fs'; -import { createHash } from 'node:crypto'; -import { PassThrough, pipeline } from 'node:stream'; -import { promisify } from 'node:util'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - createXCTestAgentClient, - type XCTestAgentPermissionsConfiguration, -} from './xctest-agent-client.js'; -import type { XCTestAgentTransport } from './xctest-agent-transport.js'; -import { createDeviceXCTestAgentTransport } from './xctest-agent-transport-device.js'; -import { createSimulatorXCTestAgentTransport } from './xctest-agent-transport-simulator.js'; - -const xctestAgentLogger = logger.child('ios-xctest-agent'); - -const XCTEST_AGENT_PROJECT_NAME = 'HarnessXCTestAgent'; -const XCTEST_AGENT_SCHEME_NAME = 'HarnessXCTestAgent'; -const XCTEST_AGENT_PORT_ENV = 'HARNESS_XCTEST_AGENT_PORT'; -const XCTEST_AGENT_TICK_INTERVAL_MS_ENV = - 'HARNESS_XCTEST_AGENT_TICK_INTERVAL_MS'; -const XCTEST_AGENT_TARGET_BUNDLE_ID_ENV = - 'HARNESS_XCTEST_AGENT_TARGET_BUNDLE_ID'; -const XCTEST_AGENT_XCTESTRUN_FILE_ENV = 'HARNESS_IOS_XCTESTRUN_FILE'; -const XCTEST_AGENT_DERIVED_DATA_PATH_ENV = - 'HARNESS_IOS_XCTEST_DERIVED_DATA_PATH'; -const XCTEST_AGENT_STARTUP_TIMEOUT_MS = 120_000; -const XCTEST_AGENT_STARTUP_POLL_INTERVAL_MS = 250; -const HARNESS_DIRNAME = '.harness'; -const XCTEST_AGENT_BUILD_DIRNAME = 'xctest-agent'; -const XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT = 'xctest-agent-simulator'; -const XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION = 1; -const pipelineAsync = promisify(pipeline); - -export type XCTestAgentBuildDestination = 'simulator' | 'device'; - -export type XCTestAgentBuildSigning = { - teamId?: string; - signingIdentity?: string; - provisioningProfile?: string; -}; - -export type BuildXCTestAgentOptions = { - destination: XCTestAgentBuildDestination; - signing?: XCTestAgentBuildSigning; - projectRoot?: string; - signal?: AbortSignal; -}; - -export type BuildXCTestAgentResult = { - destination: XCTestAgentBuildDestination; - derivedDataPath: string; - reused: boolean; - xctestrunPath?: string; -}; - -type XCTestAgentTarget = - | { - kind: 'simulator'; - id: string; - } - | { - kind: 'device'; - id: string; - codeSign?: XCTestAgentBuildSigning; - }; - -export type XCTestAgentCapability = { - getLaunchEnvironment?: () => Record; - updateConfiguration?: ( - configuration: XCTestAgentRuntimeConfiguration - ) => XCTestAgentRuntimeConfiguration; -}; - -export type XCTestAgentRuntimeConfiguration = { - permissions: XCTestAgentPermissionsConfiguration; -}; - -type XCTestAgentBuildManifest = { - buildInputsHash: string; - destinationKind: XCTestAgentBuildDestination; - signing?: XCTestAgentBuildSigning; -}; - -type SimulatorXCTestAgentCacheManifest = { - artifactName: typeof XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT; - buildInputsHash: string; - destinationKind: 'simulator'; - hostArchitecture: string; - schemaVersion: typeof XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION; - simulatorSdkVersion: string; - xcodeVersion: string; - xctestrunRelativePath: string; -}; - -type SimulatorXCTestAgentCacheContext = Omit< - SimulatorXCTestAgentCacheManifest, - 'artifactName' | 'destinationKind' | 'schemaVersion' | 'xctestrunRelativePath' ->; - -type ExternalXCTestConfiguration = { - derivedDataPath: string; - xctestrunFilePath: string; -}; - -export type XCTestAgentController = { - prepare: (signal?: AbortSignal) => Promise; - ensureStarted: (signal?: AbortSignal) => Promise; - stop: () => Promise; - dispose: () => Promise; -}; - -const getXCTestAgentProjectRoot = (): string => { - return fileURLToPath(new URL('../xctest-agent', import.meta.url)); -}; - -const getXCTestAgentProjectFilePath = (): string => { - return path.join( - getXCTestAgentProjectRoot(), - `${XCTEST_AGENT_PROJECT_NAME}.xcodeproj` - ); -}; - -const assertXCTestAgentProjectExists = () => { - const projectFilePath = getXCTestAgentProjectFilePath(); - - if (fs.existsSync(projectFilePath)) { - return; - } - - throw new Error( - `Missing checked-in XCTest agent project at ${projectFilePath}. Include the checked-in project in the package artifact.` - ); -}; - -const getXCTestAgentBuildRoot = (projectRoot = process.cwd()): string => { - return path.join(projectRoot, HARNESS_DIRNAME, XCTEST_AGENT_BUILD_DIRNAME); -}; - -const getXCTestAgentCacheRoot = (projectRoot = process.cwd()): string => { - return getHarnessCacheRootPath(projectRoot); -}; - -const getXCTestAgentDerivedDataPath = ( - destination: XCTestAgentBuildDestination, - projectRoot = process.cwd() -): string => { - return path.join(getXCTestAgentBuildRoot(projectRoot), destination); -}; - -const getEnvironmentPath = (name: string): string | null => { - const value = process.env[name]?.trim(); - - if (!value) { - return null; - } - - return value; -}; - -const getExternalXCTestRunFilePath = (): string | null => { - return getEnvironmentPath(XCTEST_AGENT_XCTESTRUN_FILE_ENV); -}; - -const getExternalDerivedDataPath = (): string | null => { - return getEnvironmentPath(XCTEST_AGENT_DERIVED_DATA_PATH_ENV); -}; - -const assertExternalXCTestRunFileExists = (filePath: string) => { - if (fs.existsSync(filePath)) { - return; - } - - throw new Error( - `Missing external XCTest run file at ${filePath}. Check ${XCTEST_AGENT_XCTESTRUN_FILE_ENV}.` - ); -}; - -const getExternalXCTestConfiguration = ( - fallbackDerivedDataPath: string -): ExternalXCTestConfiguration | null => { - const xctestrunFilePath = getExternalXCTestRunFilePath(); - - if (!xctestrunFilePath) { - return null; - } - - assertExternalXCTestRunFileExists(xctestrunFilePath); - - return { - derivedDataPath: getExternalDerivedDataPath() ?? fallbackDerivedDataPath, - xctestrunFilePath, - }; -}; - -const getXCTestAgentBuildManifestPath = (derivedDataPath: string): string => - path.join(derivedDataPath, 'build-manifest.json'); - -const getXCTestAgentCacheManifestPath = (derivedDataPath: string): string => - path.join(derivedDataPath, 'cache.json'); - -const getXCTestAgentBuildDestination = ( - destination: XCTestAgentBuildDestination -): string => { - if (destination === 'simulator') { - return 'generic/platform=iOS Simulator'; - } - - return 'generic/platform=iOS'; -}; - -const getXCTestAgentRunDestination = (target: XCTestAgentTarget): string => { - if (target.kind === 'simulator') { - return `platform=iOS Simulator,id=${target.id}`; - } - - return `platform=iOS,id=${target.id}`; -}; - -const getXCTestAgentBuildSigningArgs = ( - destination: XCTestAgentBuildDestination, - signing?: XCTestAgentBuildSigning -): string[] => { - if (destination === 'simulator') { - return [ - 'CODE_SIGNING_ALLOWED=NO', - 'CODE_SIGNING_REQUIRED=NO', - 'CODE_SIGN_IDENTITY=', - 'DEVELOPMENT_TEAM=', - ]; - } - - if (!signing) { - return ['CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO']; - } - - const args: string[] = []; - - if (signing.teamId) { - args.push('CODE_SIGN_STYLE=Automatic'); - args.push(`DEVELOPMENT_TEAM=${signing.teamId}`); - - if (signing.signingIdentity) { - args.push(`CODE_SIGN_IDENTITY=${signing.signingIdentity}`); - } else { - args.push('CODE_SIGN_IDENTITY=Apple Development'); - } - } else if (signing.signingIdentity) { - args.push(`CODE_SIGN_IDENTITY=${signing.signingIdentity}`); - } - - if (signing.provisioningProfile) { - args.push(`PROVISIONING_PROFILE_SPECIFIER=${signing.provisioningProfile}`); - } - - return args; -}; - -const getXCTestAgentBuildProductsPath = (derivedDataPath: string): string => - path.join(derivedDataPath, 'Build', 'Products'); - -const getXCTestAgentSourceFilePath = (): string => { - return fileURLToPath(import.meta.url); -}; - -const readBuildManifest = ( - derivedDataPath: string -): XCTestAgentBuildManifest | null => { - const manifestPath = getXCTestAgentBuildManifestPath(derivedDataPath); - - if (!fs.existsSync(manifestPath)) { - return null; - } - - return JSON.parse( - fs.readFileSync(manifestPath, 'utf8') - ) as XCTestAgentBuildManifest; -}; - -const writeBuildManifest = ( - derivedDataPath: string, - manifest: XCTestAgentBuildManifest -) => { - fs.mkdirSync(derivedDataPath, { recursive: true }); - fs.writeFileSync( - getXCTestAgentBuildManifestPath(derivedDataPath), - JSON.stringify(manifest, null, 2) - ); -}; - -const getProjectInputFilePaths = (root: string): string[] => { - const entries = fs.readdirSync(root, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - if (entry.name === 'build' || entry.name === '.gitignore') { - continue; - } - - const entryPath = path.join(root, entry.name); - - if (entry.isDirectory()) { - files.push(...getProjectInputFilePaths(entryPath)); - continue; - } - - files.push(entryPath); - } - - return files.sort(); -}; - -const getProjectInputsHash = (): string => { - const projectRoot = getXCTestAgentProjectRoot(); - const hash = createHash('sha256'); - - for (const filePath of getProjectInputFilePaths(projectRoot)) { - hash.update(path.relative(projectRoot, filePath)); - hash.update('\0'); - hash.update(fs.readFileSync(filePath)); - hash.update('\0'); - } - - const sourceFilePath = getXCTestAgentSourceFilePath(); - hash.update(path.basename(sourceFilePath)); - hash.update('\0'); - hash.update(fs.readFileSync(sourceFilePath)); - hash.update('\0'); - - return hash.digest('hex'); -}; - -const getXCTestRunRelativePath = (derivedDataPath: string): string | null => { - const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); - - if (!fs.existsSync(buildProductsPath)) { - return null; - } - - const entries = fs.readdirSync(buildProductsPath, { recursive: true }); - const xctestrunEntry = entries.find( - (entry) => typeof entry === 'string' && entry.endsWith('.xctestrun') - ); - - return typeof xctestrunEntry === 'string' ? xctestrunEntry : null; -}; - -const readSimulatorBuildManifest = ( - derivedDataPath: string -): SimulatorXCTestAgentCacheManifest | null => { - const manifestPath = getXCTestAgentCacheManifestPath(derivedDataPath); - - if (!fs.existsSync(manifestPath)) { - return null; - } - - const manifest = JSON.parse( - fs.readFileSync(manifestPath, 'utf8') - ) as Partial; - - if ( - manifest.schemaVersion !== XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION || - manifest.artifactName !== XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT || - manifest.destinationKind !== 'simulator' || - typeof manifest.buildInputsHash !== 'string' || - typeof manifest.hostArchitecture !== 'string' || - typeof manifest.simulatorSdkVersion !== 'string' || - typeof manifest.xcodeVersion !== 'string' || - typeof manifest.xctestrunRelativePath !== 'string' - ) { - return null; - } - - return manifest as SimulatorXCTestAgentCacheManifest; -}; - -const writeSimulatorBuildManifest = ( - derivedDataPath: string, - manifest: SimulatorXCTestAgentCacheManifest -) => { - fs.mkdirSync(derivedDataPath, { recursive: true }); - fs.writeFileSync( - getXCTestAgentCacheManifestPath(derivedDataPath), - JSON.stringify(manifest, null, 2) - ); -}; - -const getSimulatorCacheDirectoryName = ( - context: SimulatorXCTestAgentCacheContext -): string => { - const hash = createHash('sha256'); - hash.update(XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT); - hash.update('\0'); - hash.update(String(XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION)); - hash.update('\0'); - hash.update(context.buildInputsHash); - hash.update('\0'); - hash.update(context.hostArchitecture); - hash.update('\0'); - hash.update(context.simulatorSdkVersion); - hash.update('\0'); - hash.update(context.xcodeVersion); - - return `${XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT}-${hash - .digest('hex') - .slice(0, 12)}`; -}; - -const getSimulatorCacheDerivedDataPath = ( - context: SimulatorXCTestAgentCacheContext, - projectRoot = process.cwd() -): string => { - return path.join( - getXCTestAgentCacheRoot(projectRoot), - getSimulatorCacheDirectoryName(context) - ); -}; - -const getHarnessCacheDirectories = (projectRoot = process.cwd()): string[] => { - const cacheRoot = getXCTestAgentCacheRoot(projectRoot); - - if (!fs.existsSync(cacheRoot)) { - return []; - } - - return fs - .readdirSync(cacheRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(cacheRoot, entry.name)) - .sort(); -}; - -const isCompatibleSimulatorBuildManifest = ( - manifest: SimulatorXCTestAgentCacheManifest, - context: SimulatorXCTestAgentCacheContext -): boolean => { - return ( - manifest.buildInputsHash === context.buildInputsHash && - manifest.hostArchitecture === context.hostArchitecture && - manifest.simulatorSdkVersion === context.simulatorSdkVersion && - manifest.xcodeVersion === context.xcodeVersion - ); -}; - -const findReusableSimulatorBuildArtifacts = ( - context: SimulatorXCTestAgentCacheContext, - projectRoot = process.cwd() -): string | null => { - for (const derivedDataPath of getHarnessCacheDirectories(projectRoot)) { - const manifest = readSimulatorBuildManifest(derivedDataPath); - - if (!manifest || !isCompatibleSimulatorBuildManifest(manifest, context)) { - continue; - } - - const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); - - if ( - fs.existsSync(buildProductsPath) && - fs.existsSync( - path.join(buildProductsPath, manifest.xctestrunRelativePath) - ) - ) { - return derivedDataPath; - } - } - - return null; -}; - -const getCurrentXcodeVersion = async ( - signal?: AbortSignal -): Promise => { - const { stdout } = await spawn('xcodebuild', ['-version'], { signal }); - return stdout.trim(); -}; - -const getCurrentSimulatorSdkVersion = async ( - signal?: AbortSignal -): Promise => { - const { stdout } = await spawn( - 'xcodebuild', - ['-version', '-sdk', 'iphonesimulator', 'SDKVersion'], - { signal } - ); - return stdout.trim(); -}; - -const getSimulatorCacheContext = async ( - buildInputsHash: string, - signal?: AbortSignal -): Promise => { - const [xcodeVersion, simulatorSdkVersion] = await Promise.all([ - getCurrentXcodeVersion(signal), - getCurrentSimulatorSdkVersion(signal), - ]); - - return { - buildInputsHash, - hostArchitecture: process.arch, - simulatorSdkVersion, - xcodeVersion, - }; -}; - -const shouldReuseBuildArtifacts = ( - options: { - buildInputsHash: string; - derivedDataPath: string; - destination: XCTestAgentBuildDestination; - signing?: XCTestAgentBuildSigning; - } -): boolean => { - if (options.destination === 'simulator') { - throw new Error( - 'Simulator build reuse must be validated with cache compatibility metadata' - ); - } - - const manifest = readBuildManifest(options.derivedDataPath); - - if (!manifest) { - return false; - } - - if ( - manifest.buildInputsHash !== options.buildInputsHash || - manifest.destinationKind !== options.destination - ) { - return false; - } - - if ( - manifest.signing?.teamId !== options.signing?.teamId || - manifest.signing?.signingIdentity !== options.signing?.signingIdentity || - manifest.signing?.provisioningProfile !== options.signing?.provisioningProfile - ) { - return false; - } - - const buildProductsPath = getXCTestAgentBuildProductsPath( - options.derivedDataPath - ); - - return fs.existsSync(buildProductsPath); -}; - -const getXCTestRunPath = (derivedDataPath: string): string | undefined => { - const xctestrunRelativePath = getXCTestRunRelativePath(derivedDataPath); - - if (!xctestrunRelativePath) { - return undefined; - } - - const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); - - return path.join(buildProductsPath, xctestrunRelativePath); -}; - -export const buildXCTestAgent = async ( - options: BuildXCTestAgentOptions -): Promise => { - const projectRoot = options.projectRoot ?? process.cwd(); - const buildInputsHash = getProjectInputsHash(); - let derivedDataPath = getXCTestAgentDerivedDataPath( - options.destination, - projectRoot - ); - let simulatorCacheContext: SimulatorXCTestAgentCacheContext | undefined; - - xctestAgentLogger.debug( - 'verifying checked-in XCTest agent project for %s', - options.destination - ); - xctestAgentLogger.info( - 'Using checked-in XCTest agent project for %s target', - options.destination - ); - assertXCTestAgentProjectExists(); - - if (options.destination === 'simulator') { - simulatorCacheContext = await getSimulatorCacheContext( - buildInputsHash, - options.signal - ); - const reusableDerivedDataPath = findReusableSimulatorBuildArtifacts( - simulatorCacheContext, - projectRoot - ); - - if (reusableDerivedDataPath) { - xctestAgentLogger.info( - 'Reusing cached XCTest agent build for %s target', - options.destination - ); - xctestAgentLogger.debug( - 'reusing cached XCTest agent build for %s', - options.destination - ); - - return { - destination: options.destination, - derivedDataPath: reusableDerivedDataPath, - reused: true, - xctestrunPath: getXCTestRunPath(reusableDerivedDataPath), - }; - } - - derivedDataPath = getSimulatorCacheDerivedDataPath( - simulatorCacheContext, - projectRoot - ); - } else { - const canReuseBuild = shouldReuseBuildArtifacts({ - buildInputsHash, - derivedDataPath, - destination: options.destination, - signing: options.signing, - }); - - if (canReuseBuild) { - xctestAgentLogger.info( - 'Reusing cached XCTest agent build for %s target', - options.destination - ); - xctestAgentLogger.debug( - 'reusing cached XCTest agent build for %s', - options.destination - ); - - return { - destination: options.destination, - derivedDataPath, - reused: true, - xctestrunPath: getXCTestRunPath(derivedDataPath), - }; - } - } - - fs.mkdirSync(derivedDataPath, { recursive: true }); - - xctestAgentLogger.debug('building XCTest agent for %s', options.destination); - xctestAgentLogger.info( - 'Building XCTest agent for %s target', - options.destination - ); - - const signingArgs = getXCTestAgentBuildSigningArgs( - options.destination, - options.signing - ); - const provisioningArgs: string[] = []; - - if (options.destination === 'device' && options.signing?.teamId) { - provisioningArgs.push('-allowProvisioningUpdates'); - } - - const buildArgs = [ - 'build-for-testing', - '-project', - getXCTestAgentProjectFilePath(), - '-scheme', - XCTEST_AGENT_SCHEME_NAME, - '-destination', - getXCTestAgentBuildDestination(options.destination), - '-derivedDataPath', - derivedDataPath, - ...provisioningArgs, - ...signingArgs, - ]; - - await spawn('xcodebuild', buildArgs, { - cwd: getXCTestAgentProjectRoot(), - signal: options.signal, - }); - - const xctestrunRelativePath = getXCTestRunRelativePath(derivedDataPath); - - if (!xctestrunRelativePath) { - throw new Error( - `Missing generated .xctestrun file in ${getXCTestAgentBuildProductsPath( - derivedDataPath - )}` - ); - } - - if (options.destination === 'simulator') { - if (!simulatorCacheContext) { - throw new Error('Missing simulator cache context for XCTest agent build'); - } - - writeSimulatorBuildManifest(derivedDataPath, { - artifactName: XCTEST_AGENT_SIMULATOR_CACHE_ARTIFACT, - destinationKind: 'simulator', - schemaVersion: XCTEST_AGENT_SIMULATOR_CACHE_SCHEMA_VERSION, - xctestrunRelativePath, - ...simulatorCacheContext, - }); - } else { - writeBuildManifest(derivedDataPath, { - buildInputsHash, - destinationKind: options.destination, - signing: options.signing, - }); - } - - xctestAgentLogger.info( - 'Built XCTest agent for %s target', - options.destination - ); - - const buildProductsPath = getXCTestAgentBuildProductsPath(derivedDataPath); - - return { - destination: options.destination, - derivedDataPath, - reused: false, - xctestrunPath: path.join(buildProductsPath, xctestrunRelativePath), - }; -}; - -const getDefaultRuntimeConfiguration = (): XCTestAgentRuntimeConfiguration => { - return { - permissions: { - autoAcceptPermissions: false, - }, - }; -}; - -const getRuntimeConfiguration = ( - capabilities: XCTestAgentCapability[] -): XCTestAgentRuntimeConfiguration => { - return capabilities.reduce((configuration, capability) => { - return capability.updateConfiguration?.(configuration) ?? configuration; - }, getDefaultRuntimeConfiguration()); -}; - -const delay = async (ms: number, signal?: AbortSignal) => { - if (!signal) { - await cancellableDelay(ms).promise; - return; - } - - signal.throwIfAborted(); - const abortWait = waitForAbort(signal); - const timeout = cancellableDelay(ms); - try { - await Promise.race([timeout.promise, abortWait.promise]); - } finally { - abortWait.cancel(); - timeout.cancel(); - } -}; - -const waitForAbortable = async (promise: Promise, signal?: AbortSignal): Promise => { - if (!signal) { - return await promise; - } - - signal.throwIfAborted(); - const abortWait = waitForAbort(signal); - try { - return await Promise.race([promise, abortWait.promise]); - } finally { - abortWait.cancel(); - } -}; - -const waitForAgentReady = async (options: { - client: ReturnType; - startupTimeoutMs: number; - signal?: AbortSignal; -}) => { - const deadline = Date.now() + options.startupTimeoutMs; - let lastError: unknown = null; - - while (Date.now() < deadline) { - options.signal?.throwIfAborted(); - try { - await waitForAbortable(options.client.health(), options.signal); - options.signal?.throwIfAborted(); - return; - } catch (error) { - lastError = error; - await delay(XCTEST_AGENT_STARTUP_POLL_INTERVAL_MS, options.signal); - } - } - - throw new Error( - `Timed out waiting for XCTest agent readiness: ${getErrorMessage( - lastError - )}` - ); -}; - -const waitForChildProcessExit = async (subprocess: Subprocess) => { - const childProcess = await subprocess.nodeChildProcess; - - if (childProcess.exitCode !== null || childProcess.signalCode !== null) { - return; - } - - await new Promise((resolve) => { - const cleanup = () => { - childProcess.off('close', finish); - childProcess.off('error', finish); - }; - - const finish = () => { - cleanup(); - resolve(); - }; - - childProcess.once('close', finish); - childProcess.once('error', finish); - }); -}; - -const stopProcess = async (options: { - process: OwnedProcess | null; -}) => { - if (!options.process) { - return; - } - - await options.process.dispose(); -}; - -const toTestRunnerEnv = (env: Record): Record => - Object.fromEntries( - Object.entries(env).map(([key, value]) => [`TEST_RUNNER_${key}`, value]) - ); - -const getErrorMessage = (error: unknown): string => { - if (!error) { - return 'unknown error'; - } - - return error instanceof Error ? error.message : String(error); -}; - -const attachProcessOutputLog = async (options: { - command: string; - logFilePath: string; - process: Subprocess; -}) => { - fs.mkdirSync(path.dirname(options.logFilePath), { recursive: true }); - fs.writeFileSync( - options.logFilePath, - [ - `timestamp=${new Date().toISOString()}`, - `command=${options.command}`, - '', - ].join('\n'), - 'utf8' - ); - const output = fs.createWriteStream(options.logFilePath, { flags: 'a' }); - - const childProcess = await options.process.nodeChildProcess; - const mergedOutput = new PassThrough(); - const forwardStream = async ( - stream: NodeJS.ReadableStream | null | undefined, - label: 'stdout' | 'stderr' - ) => { - if (!stream) { - return; - } - - for await (const chunk of stream) { - mergedOutput.write(`[${label}] `); - mergedOutput.write(chunk); - if ( - Buffer.isBuffer(chunk) - ? !chunk.includes(0x0a) - : !String(chunk).endsWith('\n') - ) { - mergedOutput.write('\n'); - } - } - }; - - const pipeTask = pipelineAsync(mergedOutput, output); - const forwardTask = Promise.all([ - forwardStream(childProcess.stdout, 'stdout'), - forwardStream(childProcess.stderr, 'stderr'), - ]).finally(() => { - mergedOutput.end(); - }); - - void Promise.allSettled([pipeTask, forwardTask]); -}; - -export const createXCTestAgentController = (options: { - appBundleId?: string; - target: XCTestAgentTarget; - capabilities?: XCTestAgentCapability[]; - port?: number; - startupTimeoutMs?: number; - /** Maximum time for the XCTest client to close gracefully. */ - shutdownTimeoutMs?: number; -}): XCTestAgentController => { - const { target } = options; - const capabilities = options.capabilities ?? []; - const startupTimeoutMs = - options.startupTimeoutMs ?? XCTEST_AGENT_STARTUP_TIMEOUT_MS; - const logArtifacts = createHarnessArtifactDirectory({ - artifactType: 'logs', - bundleId: options.appBundleId, - platformId: 'ios', - runnerName: `xctest-agent-${target.kind}`, - }); - const xcodebuildLogPath = path.join( - logArtifacts.directoryPath, - 'xcodebuild.log' - ); - let preparedDerivedDataPath = getXCTestAgentDerivedDataPath(target.kind); - let preparedXCTestRunFilePath: string | null = null; - let prepared = false; - let agentProcess: OwnedProcess | null = null; - let agentClient: ReturnType | null = null; - - const getLaunchEnvironment = (): Record => { - const tickIntervalMs = getEnvironmentPath(XCTEST_AGENT_TICK_INTERVAL_MS_ENV); - - return Object.assign( - {}, - options.appBundleId - ? { - [XCTEST_AGENT_TARGET_BUNDLE_ID_ENV]: options.appBundleId, - } - : {}, - tickIntervalMs - ? { - [XCTEST_AGENT_TICK_INTERVAL_MS_ENV]: tickIntervalMs, - } - : {}, - ...capabilities.map( - (capability) => capability.getLaunchEnvironment?.() ?? {} - ) - ); - }; - - const createTransport = (port: number): XCTestAgentTransport => { - if (target.kind === 'simulator') { - return createSimulatorXCTestAgentTransport({ port }); - } - - return createDeviceXCTestAgentTransport({ - deviceId: target.id, - port, - }); - }; - - const prepare = async (signal?: AbortSignal) => { - if (prepared) { - return; - } - - const externalXCTestConfiguration = getExternalXCTestConfiguration( - preparedDerivedDataPath - ); - - if (externalXCTestConfiguration) { - preparedDerivedDataPath = externalXCTestConfiguration.derivedDataPath; - preparedXCTestRunFilePath = - externalXCTestConfiguration.xctestrunFilePath; - - prepared = true; - xctestAgentLogger.info( - 'Using external XCTest run file for %s target: %s', - target.kind, - preparedXCTestRunFilePath - ); - return; - } - - let signing: XCTestAgentBuildSigning | undefined; - - if (target.kind === 'device') { - signing = target.codeSign; - } - - const buildResult = await buildXCTestAgent({ - destination: target.kind, - signing, - signal, - }); - - preparedDerivedDataPath = buildResult.derivedDataPath; - preparedXCTestRunFilePath = null; - prepared = true; - }; - - const ensureStarted = async (signal?: AbortSignal) => { - await prepare(signal); - - if (agentProcess && agentClient) { - return; - } - - const port = options.port ?? (await getAvailablePort()); - const runtimeConfiguration = getRuntimeConfiguration(capabilities); - - xctestAgentLogger.debug('starting XCTest agent for %s', target.kind); - xctestAgentLogger.info( - 'Starting XCTest agent session for %s target', - target.kind - ); - xctestAgentLogger.debug('Using XCTest agent port %d', port); - const xcodebuildRunInputArgs = preparedXCTestRunFilePath - ? ['-xctestrun', preparedXCTestRunFilePath] - : [ - '-project', - getXCTestAgentProjectFilePath(), - '-scheme', - XCTEST_AGENT_SCHEME_NAME, - ]; - - const xcodebuildArgs = [ - 'test-without-building', - ...xcodebuildRunInputArgs, - '-destination', - getXCTestAgentRunDestination(target), - '-parallel-testing-enabled', - 'NO', - '-maximum-parallel-testing-workers', - '1', - '-derivedDataPath', - preparedDerivedDataPath, - ]; - agentProcess = spawnOwnedProcess('xcodebuild', xcodebuildArgs, { - cwd: getXCTestAgentProjectRoot(), - env: { - ...process.env, - ...toTestRunnerEnv({ - [XCTEST_AGENT_PORT_ENV]: String(port), - ...getLaunchEnvironment(), - }), - }, - }); - void attachProcessOutputLog({ - command: ['xcodebuild', ...xcodebuildArgs].join(' '), - logFilePath: xcodebuildLogPath, - process: agentProcess.subprocess, - }); - xctestAgentLogger.info( - 'Saving XCTest agent xcodebuild logs to %s', - xcodebuildLogPath - ); - - const currentProcess = agentProcess; - if (typeof currentProcess.subprocess.catch === 'function') { - void currentProcess.subprocess.catch((error) => { - xctestAgentLogger.debug('XCTest agent process stopped', error); - }); - } - const transport = createTransport(port); - const client = createXCTestAgentClient(transport); - agentClient = client; - - void waitForChildProcessExit(currentProcess.subprocess).finally(() => { - if (agentProcess === currentProcess) { - agentProcess = null; - agentClient = null; - } - }); - - try { - await waitForAgentReady({ - client, - startupTimeoutMs, - signal, - }); - await client.configurePermissions(runtimeConfiguration.permissions); - } catch (error) { - xctestAgentLogger.warn( - 'XCTest agent startup failed for %s: %s (logs: %s)', - target.kind, - getErrorMessage(error), - xcodebuildLogPath - ); - await client.dispose(); - agentClient = null; - await stopProcess({ process: currentProcess }); - throw error; - } - }; - - const stop = async () => { - const currentProcess = agentProcess; - const currentClient = agentClient; - agentProcess = null; - agentClient = null; - - xctestAgentLogger.info( - 'Stopping XCTest agent session for %s target', - target.kind - ); - - const shutdownTimeout = cancellableDelay(options.shutdownTimeoutMs ?? 5_000); - let gracefulShutdownCompleted = false; - try { - if (currentClient) { - const gracefulExit = currentProcess - ? waitForChildProcessExit(currentProcess.subprocess) - : Promise.resolve(); - await Promise.race([ - (async () => { - await currentClient.shutdown(); - await gracefulExit; - gracefulShutdownCompleted = true; - })(), - shutdownTimeout.promise, - ]); - } - } finally { - shutdownTimeout.cancel(); - try { - await currentClient?.dispose(); - } finally { - if (!gracefulShutdownCompleted) { - await stopProcess({ process: currentProcess }); - } - } - } - }; - - let disposePromise: Promise | undefined; - const dispose = () => (disposePromise ??= stop()); - - return { - prepare, - ensureStarted, - stop, - dispose, - }; -}; diff --git a/packages/platform-ios/src/xctest-command.ts b/packages/platform-ios/src/xctest-command.ts deleted file mode 100644 index d49827f1..00000000 --- a/packages/platform-ios/src/xctest-command.ts +++ /dev/null @@ -1,177 +0,0 @@ -import yargs from 'yargs'; - -export type XCTestBuildDestination = 'simulator' | 'device'; - -export type XCTestBuildArgs = { - destination: XCTestBuildDestination; - provisioningProfile?: string; - signingIdentity?: string; - teamId?: string; -}; - -type XCTestBuildResult = { - destination: XCTestBuildDestination; - derivedDataPath: string; - reused: boolean; - xctestrunPath?: string; -}; - -export type XCTestBuildModule = { - buildXCTestAgent: (options: { - destination: XCTestBuildDestination; - projectRoot: string; - signing?: { - provisioningProfile?: string; - signingIdentity?: string; - teamId?: string; - }; - }) => Promise; -}; - -const createXCTestYargs = (args: string[]) => - yargs(args) - .scriptName('harness xctest') - .command( - 'build', - 'Build the bundled iOS XCTest agent', - (command) => - command - .option('destination', { - choices: ['simulator', 'device'] as const, - demandOption: true, - describe: 'Build destination', - type: 'string', - }) - .option('teamId', { - describe: 'Apple development team ID for signed device builds', - type: 'string', - }) - .option('signingIdentity', { - describe: 'Code signing identity for signed device builds', - type: 'string', - }) - .option('provisioningProfile', { - describe: - 'Provisioning profile specifier for signed device builds', - type: 'string', - }) - .example( - '$0 build --destination simulator', - 'Build unsigned simulator artifacts' - ) - .example( - '$0 build --destination device', - 'Build unsigned device artifacts' - ) - .example( - '$0 build --destination device --teamId BAJL5U28HC', - 'Build signed device artifacts' - ), - () => undefined - ) - .demandCommand(1, 'Missing xctest command.') - .strict() - .help() - .version(false) - .exitProcess(false) - .fail((message, error) => { - throw error ?? new Error(message); - }); - -const getParsedXCTestBuildArgv = (args: string[]) => { - const parser = createXCTestYargs(['build', ...args]); - const argv = parser.parseSync(); - const destination = argv.destination; - const provisioningProfile = argv.provisioningProfile; - const signingIdentity = argv.signingIdentity; - const teamId = argv.teamId; - - return { - destination: destination as XCTestBuildDestination, - provisioningProfile: - typeof provisioningProfile === 'string' ? provisioningProfile : undefined, - signingIdentity: - typeof signingIdentity === 'string' ? signingIdentity : undefined, - teamId: typeof teamId === 'string' ? teamId : undefined, - }; -}; - -export const parseXCTestBuildArgs = (args: string[]): XCTestBuildArgs => { - const argv = getParsedXCTestBuildArgv(args); - - return { - destination: argv.destination, - provisioningProfile: argv.provisioningProfile, - signingIdentity: argv.signingIdentity, - teamId: argv.teamId, - }; -}; - -export const runXCTestBuildCommand = async (options: { - args: string[]; - cwd: string; - xctest: XCTestBuildModule; -}) => { - const buildArgs = parseXCTestBuildArgs(options.args); - const signing = { - provisioningProfile: buildArgs.provisioningProfile, - signingIdentity: buildArgs.signingIdentity, - teamId: buildArgs.teamId, - }; - const hasSigning = - signing.provisioningProfile !== undefined || - signing.signingIdentity !== undefined || - signing.teamId !== undefined; - const buildOptions = { - destination: buildArgs.destination, - projectRoot: options.cwd, - signing: undefined as typeof signing | undefined, - }; - - if (hasSigning) { - buildOptions.signing = signing; - } - - const result = await options.xctest.buildXCTestAgent(buildOptions); - - console.log('Built XCTest agent'); - console.log(`Destination: ${result.destination}`); - console.log(`DerivedData: ${result.derivedDataPath}`); - - if (result.xctestrunPath) { - console.log(`XCTestRun: ${result.xctestrunPath}`); - } - - let reusedLabel = 'no'; - - if (result.reused) { - reusedLabel = 'yes'; - } - - console.log(`Reused: ${reusedLabel}`); -}; - -export const runXCTestCommand = async (options: { - args: string[]; - cwd: string; - xctest: XCTestBuildModule; -}) => { - const parser = createXCTestYargs(options.args); - const argv = parser.parseSync(); - const commandName = String(argv._[0] ?? ''); - - if (commandName !== 'build') { - return; - } - - try { - await runXCTestBuildCommand({ - args: options.args.slice(1), - cwd: options.cwd, - xctest: options.xctest, - }); - } catch (error) { - parser.showHelp(); - throw error; - } -}; diff --git a/packages/platform-ios/vite.config.ts b/packages/platform-ios/vite.config.ts new file mode 100644 index 00000000..fcd4e906 --- /dev/null +++ b/packages/platform-ios/vite.config.ts @@ -0,0 +1,18 @@ +/// +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/platform-ios', + test: { + watch: false, + globals: true, + environment: 'node', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: './test-output/vitest/coverage', + provider: 'v8' as const, + }, + }, +})); diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent.xcodeproj/project.pbxproj b/packages/platform-ios/xctest-agent/HarnessXCTestAgent.xcodeproj/project.pbxproj deleted file mode 100644 index eb9e0956..00000000 --- a/packages/platform-ios/xctest-agent/HarnessXCTestAgent.xcodeproj/project.pbxproj +++ /dev/null @@ -1,455 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXContainerItemProxy section */ - 79A245FD2F99756A0071600E /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 79A245DD2F9975690071600E /* Project object */; - proxyType = 1; - remoteGlobalIDString = 79A245E42F9975690071600E; - remoteInfo = HarnessXCTestAgent; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXFileReference section */ - 79A245E52F9975690071600E /* HarnessXCTestAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HarnessXCTestAgent.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 79A245FC2F99756A0071600E /* HarnessXCTestAgentUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HarnessXCTestAgentUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFileSystemSynchronizedRootGroup section */ - 79A245E72F9975690071600E /* HarnessXCTestAgent */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = HarnessXCTestAgent; - sourceTree = ""; - }; - 79A245FF2F99756A0071600E /* HarnessXCTestAgentUITests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = HarnessXCTestAgentUITests; - sourceTree = ""; - }; -/* End PBXFileSystemSynchronizedRootGroup section */ - -/* Begin PBXFrameworksBuildPhase section */ - 79A245E22F9975690071600E /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 79A245F92F99756A0071600E /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 79A245DC2F9975690071600E = { - isa = PBXGroup; - children = ( - 79A245E72F9975690071600E /* HarnessXCTestAgent */, - 79A245FF2F99756A0071600E /* HarnessXCTestAgentUITests */, - 79A245E62F9975690071600E /* Products */, - ); - sourceTree = ""; - }; - 79A245E62F9975690071600E /* Products */ = { - isa = PBXGroup; - children = ( - 79A245E52F9975690071600E /* HarnessXCTestAgent.app */, - 79A245FC2F99756A0071600E /* HarnessXCTestAgentUITests.xctest */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 79A245E42F9975690071600E /* HarnessXCTestAgent */ = { - isa = PBXNativeTarget; - buildConfigurationList = 79A246062F99756A0071600E /* Build configuration list for PBXNativeTarget "HarnessXCTestAgent" */; - buildPhases = ( - 79A245E12F9975690071600E /* Sources */, - 79A245E22F9975690071600E /* Frameworks */, - 79A245E32F9975690071600E /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 79A245E72F9975690071600E /* HarnessXCTestAgent */, - ); - name = HarnessXCTestAgent; - packageProductDependencies = ( - ); - productName = HarnessXCTestAgent; - productReference = 79A245E52F9975690071600E /* HarnessXCTestAgent.app */; - productType = "com.apple.product-type.application"; - }; - 79A245FB2F99756A0071600E /* HarnessXCTestAgentUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 79A2460C2F99756A0071600E /* Build configuration list for PBXNativeTarget "HarnessXCTestAgentUITests" */; - buildPhases = ( - 79A245F82F99756A0071600E /* Sources */, - 79A245F92F99756A0071600E /* Frameworks */, - 79A245FA2F99756A0071600E /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 79A245FE2F99756A0071600E /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 79A245FF2F99756A0071600E /* HarnessXCTestAgentUITests */, - ); - name = HarnessXCTestAgentUITests; - packageProductDependencies = ( - ); - productName = HarnessXCTestAgentUITests; - productReference = 79A245FC2F99756A0071600E /* HarnessXCTestAgentUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 79A245DD2F9975690071600E /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2620; - LastUpgradeCheck = 2620; - TargetAttributes = { - 79A245E42F9975690071600E = { - CreatedOnToolsVersion = 26.2; - }; - 79A245FB2F99756A0071600E = { - CreatedOnToolsVersion = 26.2; - TestTargetID = 79A245E42F9975690071600E; - }; - }; - }; - buildConfigurationList = 79A245E02F9975690071600E /* Build configuration list for PBXProject "HarnessXCTestAgent" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 79A245DC2F9975690071600E; - minimizedProjectReferenceProxies = 1; - preferredProjectObjectVersion = 77; - productRefGroup = 79A245E62F9975690071600E /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 79A245E42F9975690071600E /* HarnessXCTestAgent */, - 79A245FB2F99756A0071600E /* HarnessXCTestAgentUITests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 79A245E32F9975690071600E /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 79A245FA2F99756A0071600E /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 79A245E12F9975690071600E /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 79A245F82F99756A0071600E /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 79A245FE2F99756A0071600E /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 79A245E42F9975690071600E /* HarnessXCTestAgent */; - targetProxy = 79A245FD2F99756A0071600E /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin XCBuildConfiguration section */ - 79A246042F99756A0071600E /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 79A246052F99756A0071600E /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 79A246072F99756A0071600E /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.callstackincubator.HarnessXCTestAgent; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 79A246082F99756A0071600E /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.callstackincubator.HarnessXCTestAgent; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - 79A2460D2F99756A0071600E /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.callstackincubator.HarnessXCTestAgentUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = NO; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = HarnessXCTestAgent; - }; - name = Debug; - }; - 79A2460E2F99756A0071600E /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.callstackincubator.HarnessXCTestAgentUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = NO; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = HarnessXCTestAgent; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 79A245E02F9975690071600E /* Build configuration list for PBXProject "HarnessXCTestAgent" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 79A246042F99756A0071600E /* Debug */, - 79A246052F99756A0071600E /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 79A246062F99756A0071600E /* Build configuration list for PBXNativeTarget "HarnessXCTestAgent" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 79A246072F99756A0071600E /* Debug */, - 79A246082F99756A0071600E /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 79A2460C2F99756A0071600E /* Build configuration list for PBXNativeTarget "HarnessXCTestAgentUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 79A2460D2F99756A0071600E /* Debug */, - 79A2460E2F99756A0071600E /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 79A245DD2F9975690071600E /* Project object */; -} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/platform-ios/xctest-agent/HarnessXCTestAgent.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a6..00000000 --- a/packages/platform-ios/xctest-agent/HarnessXCTestAgent.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AccentColor.colorset/Contents.json b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb878970..00000000 --- a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index c027ad00..00000000 --- a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "images" : [ - { - "filename" : "logo.jpg", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AppIcon.appiconset/logo.jpg b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AppIcon.appiconset/logo.jpg deleted file mode 100644 index fbdfc3f1daca6b9571602dec984b5a3f9e361fb7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 107504 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 z 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

5LRMdWdYtg6B4Y5s+UYf+sj7mzF<0 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 b9STyse&*r%ZeP21;k<7I@KF6b^?(1_G%aEc diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Contents.json b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Logo.imageset/Contents.json b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Logo.imageset/Contents.json deleted file mode 100644 index ccc58691..00000000 --- a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Logo.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "logo.jpg", - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Logo.imageset/logo.jpg b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Logo.imageset/logo.jpg deleted file mode 100644 index fbdfc3f1daca6b9571602dec984b5a3f9e361fb7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 107504 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 z zglPoaFFd9?aiqV53UZG1{mI-IKPhb^QS21g0p2I4IyNoki+?-sJGO~$gWrkr;La~qVI>6B{b zGDfb$FhRyr9ErqR0XsCWcj76mgdsGuL>B~e!&u|=Z34*-K z<-{it+TiYTT0OZ#aB7N!6Nu-OLHV~#7dXtmNtr}-Y*%K2G+S8l2cEA^-iD(OnR1YcxwV?eQx<7VyqK+hfH6 zHdbL+I%QP`{Ko}WPb53#cs!G_8cCMC4%p;8j5ycgw!O}H!i+(NEq5x(iiHihom{ThUXM!He4i+k9 zhyLy^CULR@i!j2P^2ZqCxKJYF&HTVAyx9bDZc~+PUZ(JG!oq_)sByvtgf>1PSOnuk z@UVMfhQ?Vh3sy_4-P9Jc}$y7^WwNFDeEI{{Bd0q8P6P5*eIn9wV8Yd4gDU zI%l;i;u$+wm7hISz9_y8lF@6o^x|1Ypr5i$*ldS|ppD%)zM*A=_a^iZDwN9)b)Zo{ z7W{sP7M#u{ip1ss;Yo~|K1rJ>vK*QDKH$=2mQFQUGvad6bsjms&mXiYW~%UnFBHzf z!ab2P0c57BDmq)C@=Ab=URZjlQ^)bmn`SFL#e7lWhe+UKoZYj?!E`txl|CXWD?Sf9{?h#FaTfTSU^Ih|EL|aj zc{I#M<{4FC`VFPPjCvij6;&Q4lV-+J;Nyu#D3o2K%7OZOo=tWUI(6lRn7k~^Qyivi z2kBgk1~?4nH?v7LAt{-1YB-?WXi}(v=AYBrW{Bevc+gz#2<8PeZIzMj@<#CYU64z| zVD~wANE<6~U+hnN=K-R7P`4!GMfoD9_KA2qAmFBTyxhh>jRNf*M6kZE5TUxZ6FTa@ z-dT?X2WHc`(3u{XTssE?DZFY@-X@A6*;f2MCLp#33fvAb^ zf??WA_JOfCa-FPvIa5~#*W`+!T}~<)(a{iA;wBRbvN*g~%Nyb@?3R0eMq@Rxi)BE= z6bHc@74Yr}VbcoO<%5e@nt+wzB{jKrJ{C3^Va3!9frcL2Rwj;#NENmlIE(`g?k!jw zdu6y@p|Z^QA;V_iR^W~DOJNbywj!w2F!uidOdHFnS0$*~zPcqEIL<$fwV6mJ zk`O3-uqCP{k_Q-DR>r%-a_GY>DlW4S0lYHDD}|Q+T#c{^bW?RgF(+ztGQ~mvUE`l_kup`a=C$) zzmQ@V;(MxMDJrH3LuG0hA0&Cunh(=8X&T63nxvHPqGW)ycpxyQk=zX{xVEW_hD?*) z;i{Z{u2R-NJ;1No^A6sj?JLZn1;Hq@70D%RmS}YVgT^?o&`TbZxKXUI;$ghAl0VW~ zFyyT*Q~@e{j8`cP1IU4HAzST-6BQ2^nBtZc0{0WaIQhDiE=Uol6T6?j$dF404qW1D znuZIW(@P35$G+v!OB|?71PZF#IJqTp&tnhsSSa1i#nDuUY+g01{f`hvki>5(vT?A^ ziHroRrsM2#;-$K|VG6rg+~aL2c6Tn)uzZuDHo(&EU@y1@p_GNVG7l)mLOKnj4h2J&hh4l--6IU)77Z6$Ej0QjEn)Z&1&;a*ir1k|!)w zD0qg`F&w;%F}f_H;qx(qshoZ=zDdaGWr*UfVPBEdF%qmG)=d#jZDE>1#v)S=SDgef zaq`5v8NTE0$e&bp;&iXA;sff0&-I>&oKa^W`E6t=Lg9b zK*d^ae^Pj2JO2RMgUPz?Z}DXBOaNWf*QC#`JeEi7?S^74*oyiM0j=4)DFJ9f*5A3Z|L4b0)K#0w&FElMRMX{kcjah(jM zzD}PLc$m~LzK9-7G2!z(qYznuQ*6)?tQ|**dBw-He`xbcVS!d#lT1Owk(t++V>0~Y zi(cOn>oUA$i0BakH#^lN<3`oSV}gb0$qiG7vbI`cvF< zWo4X-r$G-PKYWBc2CXxosk?$ z3rM?aUo26%dmiVU@5vro7CpC9{RSg3%99Lh9}wi`@J?8^4-K1#jjS8GYNEa9h2RXa z7bc_QqEUuE8Tfy4d#;g~Y4Ixiwq>#vF6>=PmKcl*LRw|+KGLANV69e3V|dFPM*yZ` zUDQJZxT|B-8nRi7azXPB&e(VfhRTl{l;)Zb2kG-T@DIe?9LUzSJVUM>9 z%Pc9$h9_4ms)Ocnys(6uMlW&p{{V6TTJ;V(@Ofr4$cN)z>@kJ3++E%VUH2F?iyLO) z$HrEByvlG^32qY=4BvI&fL9Tjz)D=XA~3eRF-*NyBGyjlxys;_=`8qTMbmc<#Ic^` zzFkG+Wrdj_-cwQ4yNSiJ9JtO;RhW248wfRmJkR)uTOY>BX?_@-a85o}mKM_NfpNW# zBSQHh(WQy+C}6rZ9}u#`WN*C^!l(6FumHbdqR630RsB(c+J`~+O5j0I*C*ohZ^@AXC!I75U~<_j#)NC6m-E#Icbz1h}y@M zW1@1+DTSAGjaQa-8m`tBO5%$i3!a!;b%Z1CaOjI;VGwL~&LaLNQyJxVeoqpFw95|A z%1d6TA*2i>d3IqVM&gq5>ZY3e+5jjR$G zwJjoa!qK=(Q5}Il5{GcVHWT5F6y$CNYecKE4Os>jl@1+r&Rz{M?BrGWgB-$(5~5XR zR|%EyOLS~`tA~rn>4Dxz8hc`yB`B`qR4=_i`TY*=K1QlG zZM2G8ZPXnMQYI9fZ&I%Y+0o&a>1bvSsgzinnY5@0ikx`}Cvg;SX$zOhvY}`La)Q2m z#**(OpvenL2#aK^$3y^Vjg*q`cVo@X#W8I&vgPrmfM)^jh5EU1Sk7Ia0LVg z$EFvN*%4kiNu3W^)NPQ{R~7bys$=BBo8ooej}yREEcyho9OCx+k~IFm}={B zL8lD;izTHzvP|{5n1v0xB^qTnZUzT_BG3}jio-DHre_$;|I*o1F36z{vd<;WAVvum(=|~WykB3lEKT9EmW5m&xYpgN% zZG#k)Wp==h}PZSzqA`IFhJj#YE%OxFC&47>@)I0 zu7)bL(-rX!?6L}!mvZbESEvZ90~}cNh6WA3UjY=f3Su43CeYf)H_H0xg|~|mA%)Gc z4me@%Kw;$E>}3zR3t4Uhq7C8YuE+wF#{=8u4O$r^`{G!jVI>hb>D1%yMa);Y)~1Sk zAg#y7h*jbRzHV7^xlE)VI0<`tCTQ{!>c4o~aKixX-()!QRWyup*vcD=&-*5rs8gtM z<53d$qy*`jQtSGY{l zlg1)bD6A5T)Vwzj9Z$k<1opd$WDTPo`LcE)`cmCRzy(ZSm5(B=m7}$sdFjoQDMnw= zIEFQQej`_Pmq7;M*#;ZX8ytY`jr1Wz3@(+GlmB7ipq+4TNYIrWaAP0Gdj0%;FX?r@c?#EFO(i z7G@>c9@8~4Hq0{sw{qPW0%*x^#1het^2XR&p)VB0O_+^_3L}FWjppZircq@vAOcpr zN8f71ZC$+({{TW9PwKpUz8Lk({Ro9ywpJ0BfH3*Ec&fNNt&zKUoiXBMdy4V^jt3c- z04AnHVyG1dic`3{2Pc*XTy((xmD<9KSS=xtmkR#z_h1Yp3&f&mm5bY3KWO`#J795H zUy;YOhOY8;Q44z+iqvZfxKa6Dd_TN!4`)Zg6HVeC0}#$2Ik1EDHuS~i(T_LtFt-cMn4gl@WKyWXdr3oK zIPw*FY?vw>=*GyUm6i?B1j}-$-c~LSsvgpZ7uykQIyA(h#}9MB>6CyGzMyhcbt!G> zH8)5`;$Pw!8PZ2dwmfTkSg^DcaD;02`gu}5MpJhL&FW@d?|vEa)OOBTDJpSB^B8$d zFt8}u0*dK3`XB2kEp|-yOyG^>XH^7n%vKG$XM?gek|pa-1KjT5_5$vo6{q<2oh#aAAvzbUKF{1=*mDmtDoQ_evbz zoipO9p1w(HzTFY`Q*G46kAeT;!t+{VU?GI|cVL?fV z+^_-jvU2dl+$HV}ls7peV+VNL6?Gb>QZ`_KQ^?yb{^ZB>yYn0Z5lkk@j@-xRl|RY6!IOZh;g^25ua)NNoZcUM@B+G@T$ka*Ge94)cLJI3S)3z z(Qo%B_Qd?Jdp8$8+Qb~6GTfdelDLtzfJOGW*&bdDEO`yJnPF{0mnEbV_Xu*MKk&lU zvrxfZJ+qIx-!sVQo?i}$X_G|vD2Ch1Fx8T`jE=vwS_CT_PcaJ$m+TvgavkC(pcl*vu zs#rCPwxi0gI3_pZ0b|Dp8pL!4$nsWaPNB;Ww`5%#Pl&ko^gz??$BLOP;?Q+c#;lJm zmyw^1qpu{lT{Bj&QCowjqo~vSogY7^wP@KqJ@|ils=lHiuKY8IAF#|C+uUhXn8jg# zOFfQZc%v3)ttSc+CreYPEl!4llYj`Id0(ad<8QsgbS)T4Q1iCET z4xOgR5pG(w-2EEMkkiYx;lxc zsoM0+YvLJ1z?+G#L%HE#al$~~h9jrH5vO{5%W-8y!^CZ$v50?}6uj*5 z4BO+Pa^uQ1$xbl9o+HRxHx#R=DQkiWLwS3i*8`kp=gMH2@4F+13bZmlM_Rq%;Qy_#F3$Tb;N!ZB;9h2VdD;5RrgD_oyB8$Lgkri zt?}Yt90@5dpoWZ*Z51jxX^k0+0z8S3IHlRRJTN6ne|CQPxm!CUm*Jlsu`>C%)@w{N zbihIVENYmwj$2?AuhUYa7k(~Xz80e#R2X1Jc^wg_Bri?P>q)r7&A}U3Az;%QiCSld z@)~CHKx~5N%-#F8%DQ58=2GO*3&I%rd`0AbOspBSi^Q%kQw8z=0GsAM=J=D961(#o za|%`$ly0TaaxyUu)Hm@9nMuXYl2grTRs>p?_RNnF2rGZi|+KO8;J1g*CW{8xy3in9@3 zHoe@};zsji#;+3VCdmHTBZ{)G$qLv<&E$nR34CEMPl%9qL0qn*P9@wtT_kC+fmAt^ zcbbSr@wu);wY_mq78Msz_d`?Ff_9b8S6c8&W*+WUZY?bY%=jWHiC8dtY zaZEWFU-ZPR^%k;A8yL}$&&SMsnHlc8R^T~q?ltBY$Ebz{E)L$we6sS{JdaGiC+6k)=^7-u6WC62I@6~>AU*)x=bRIFOnT+X80FZ8{W z1_JH}lHwjR{(x+nuOzn%+{Q>|stVcSFCN)N+_0oG5A=UP3Uh45+p;EuWGtB5vQSWN zJ%a?E0xY-m-4l&$%|+r;ZgJ!`E~2^}61!vIA%Bh{r8)^l&9LtF$2sVfl)GU2Q5qH7 z9#0|`Sm{4c6y=Yx5pclefFVshwoW`$uMjKT^vu|iZ+K_A>2XW|rGePxiOU0~Cixm+ zShI4CcR15Q9_Jiih>j4;4u^1~8W<<#MI^qR5gxEW{{V)tz>g|V1OY8_z(1<<9|ZR} zLO#=ZSZ3;eS!Qy&mw%Cjhqoszj0*vPwA+YrybMaQoP3xF33;`OQCJ98O3NDBzy^qx z3#K0$VhseY`+Snq-9?qux4n@+qzp5SVR*$d8({U^1lHmZ^K*&wxb=#LZk)#%S{r~m zDyEw|xN?JZuoBPH-Bsj=wG}h^6~7TQ`IWTl=Bvc>ZW#FN^9CSTLgn^kW$E!KZJD`) z5J&?=9wvKd8`HVPtP{&Ahwm)V#ZKnT#H*+)SJW{CZHo+MrS{BkNe?Wqj6&>c9A*ph z{)51=(+f;xO@DaiyuBYVOy6Qj9F|jI#}wm%-v3 zmiU+;^5huHA8vS>W%2(2_=p^e;%njgh2Hc|E$yU-6I@%rF}*#+Oqzxh$vj>{H0xsu z+mwT1n}AgllJUGym2P&D`i~U9r4k z;FWB6U$*6c#T#JpXz%r-jm}GNiO1aAvZa#^H<jWW4- zi_%f>`H#nBEOJ>sR)%KZi1NMv0FgbXdHv!LSfPgr`VcR?k?b8!`&Sn~s6!VJ%X8fR zCu16x3Y&qvzy%x;(xoS7=p#_B%VrXdk+Fk$JC78xp6%9;()(p-sJCaI485GAMpz*D zsj>KoS`!n(;-*5_wF-sV7t98+wl49LT*x^zJ^2VOT3Uqo0f;f zebz?BmHtdyq=nN^;1q71#$JDDCGTB~-lf=hCqhQj)+H=d-sN^nZy2x9%mRzq0zD>Q z6aHf`+Y#cC;$d{eM*4&~8(3Aj--ca{$CS4K~R;a zI6p-&G0k3}aVR=_V^kMP!FQq@%PW?jGqG~7XT-$-VU@{aPI{TUJL~(!Ah?`ua^fj> zJggogdRP>QSqB$aOnEjgf*26n4E{t#8PdhTB}=)p&UgKz?+Qq9C$*q6Zk9RxsuqKs zx?@`9wpNA2tmi`nv^iupe8som9WvS)sC#KcD=z6-bZb*O_=hJqu$aO}j$bpZXoK;n zx+pviIZkqMr~PVh(APTEg8B2-9Wdfzt&Y&lrc1%P+wXZtlqYlIt?eVmDJ0#p(SNEFk9M`GJSPO>YSTbyZ2- z!ImUZFR=drY5C8{Pj&&^EfCx|w_6!^GT$Y#r;_P}Tmdb$>~=$?=+Q2Ohe7%XxG?#A zK~P>)vz78FYTx#l6iXu7XHJ;7w48;(-+$UEXNl*LsI~x0RBJ>V#npIx%IP)JIE|%B zgGUmowQ$c9!SVABDaj3{*ouT2-Wt$E$rA8n7#zrE&gFZv(|8CS*3B`!GZn?-fu2?# z+#QT8gJSZ*H&vEB9WxNOF?t%ecM;E&{{U%)c(Tp*KkY9T`HuXo-kcGtm&q(FozPma z=A|VEaB`Iz!uG(WP5ge-!5@PV_K)tx7v?;L0( zY(mq>L!~bI{?L4xhQD?%jm_YQSk@*t-OIrEnK$1Rgvb=#!TkoUW$NM;D#0WEtQOf`Vrrm!0 z7xvFX8xRTaXT%@?x!lS!;BSYI^%a?oBzX(Zub4H-Y;6XZ64jne|HJFFBuJx}X|~Cr zmqnlrHkd=XBo$)Fc!vAfAO3M9v~&}$(37}%^^-SyhP<(zbUIFWiC#E~D}z7(9q&;~ z+=oCJFvPDZhEmVyAKv5Kn9mk6K96k0`M$`$5;v@4B2%MP0Y^F~C33|~bn8CRoTGg@KEK6OS|-M`z6 zwf2l*OWQ4G`{etL>Y6*%%<$kNFi)Zlm7QMq!6}&?_e+F|fblV{2jStL=Oy0v8T^c| zcn*JD01plu+|BQQs2$mf5#*WUpK=}6WG0V_Ff)r4!*dmz3JmX|(bCDA8}v!ds3`+x z+sRP zPlUgP#Aqy$p(Wi=1IKsu8LNc67c^EO@1Fo-f&cLM3Z1bl7!u3fLSOe?R&qg}<@IRQ zwjTpry9feLpQkmL#F>+YN93gqn>L0|`&uX(8Tz$<6XLfE3O0?4PBAl+8P?a`n{R&2 zP>GiA>4fw z3nJ-7(UBic=!ZxF0R#RTtgI=&ei~(G2?WwEJGMtgccxU|IVCDYn?C>YNS#!w{*hCk z44bLfzz0#F9$Eg9d-1q9PWDEtYdcBCU`UuX%W@|vZKBqWuC~cjf@OS?j++d% zejcFoj2H7FYOMT=XW#4-47yO(+&!%U(jwvc8}b5R)c|--9U`ES-XvTrCEvL6!b(F= z|FV<-{diH~5#zm>#$M@57dbtNN9$3J>jx(9*Ag2OX;B}w&m&yr$=v@ylsaQl0xlr} z19^Lzqeco~&U3ZqJOh-?jzs{NLo0MdtI}pA)0A1YIKR}#YaUoFC1XbLsI6X}E#Oav zbJ?PLS~fOT5wl2KE=wbE-AvcUHj!nH@$Bz!p`g^wZ%ssdvR5*((aBQo$4C1fFj#uJ z(x2z%E<&Q@Mj2v=l^@W$%hB`r4nBQjYv3?q=@9fxx;F*M^J-V0TZJU+~n zOswh-$FxQO!tH6Zbhbs1R3Ig^ne&QYTkBo)kE(Xjcb|KRi#xFQo7XM)Gha$k#HGi) zrEChq%CrwO*71bsAvx}C|ef}c)^0%GjHeSKQ?#ObJzl3b`mmX=8VlC<(u!3T-f z_iG6wLIe9G^xXHOc)DsaZ-9La;J*y;MTt^xXS;XRZ2`I+PjY;IX#?h$9f*E;X5B+L zh3_iWScSS|QX-*x3e*Qr6DAP4<+>U|67nC`^yrCZy=m9K{lklm`dw>H5cSY`V$5{f z_Cv3Fhj`_t$OqxLQF2dG8-x1;66`KN!Fq2BOSwjMmosL| z-qrUMj&u$d6Ez@{VIW9!Ibd4v9c1u|Yx3fD)2oqgbGGi(nI@k-S#h>q58_8z`w?p_^L~(o%gmb3VNL!Ct zs9ugH@P15+%@0f>!C>y2PDjhJ*aN_3p^{g7H0C|cV9F{j(@=1I>}6Y+1z8RN=&0vFwsO>kB8{ccI{eH2JapwvgmcY zaFmfp?H+8Qw%g;(4&L$Pu>R=M#tHL5i{Z?vP{W@cePklgu;Mmue>qTL);53QWmx}b z-5TzABT#%v4F)0#^zTiS4D?z{?lINdT$NUx33SebZWm*zc> zlZFB9A=aTsPR$Azaxo-cYrq;$DRrjra_oN+K)-{C`>95{&5r{ZU)vuIXI>sLC$;u+ zbN-2KesgIrS~xgG0#A8u3<^rwyXk9ZMGVYarLmk)-9W(J1x^kEM; zG}`kFZCqUbw38m~o{;yU_|>_V64wgo(1b;h=a)ldaHWO5aQ%VmAvnS@DuDos zdaqnyvPAanfLcsBrHEuv4VO+l&!f>21Af7MBG!6#F;+v^){xvvQOa%}{2@>7^G13E zL55{x45(CZa&=1lvaA316Ta6~%C#F_B5k<|=%KC4ft zM9;PSF&G-YE9UGpCiiJp@p+R|iLB{kh9$)k7RQ}VwE))=*)syP4+k-mLj}}#LjHMB zhE`=EYg}oWR(ko#5~p&+hvW8~;srSl1G0o)>j@^9Jo~Fz4ug6n|Ee+3pcO;BD5nRQ zLEraeW32q;>gpobwxjQcQmx`MBVU9zZ_%)|$GRT&`4U={79XMH`UF4{_@6Z?SV_g0 zmmO7)h>OKIdZzha%!s~`Rj!9`W;(xv)78qPA``nwIkH2tZ@;FpZ@baU>q^CkBIFNL@+~d1xRO{($hPq9>rs}6Qfuj z?g~uC#WxUo5Oj4i{Dg>&8sZ}N0h?^VOM?}BL^SDojs&Q5% z;VQEJ>@4CL43Hi#&B-(F$P4!G|I>QT29^!AXHiv$yz?Snt{Pv`r){Ca@N*`w#}N0$ zSztc>;Scb@Bp^77mkGKLltt?ZtI( zRSosHt&3Hh_Mgu|<ZD)!nlM>exY*}VDK7nimV1A38HyASBTFX1td z2kVA>bXX4NC9+`d)1$rn(O8zNyMgO%LrHo~PHxg(aW~Eqh-7ZydWUl&2Og(Ko1c$0 z-|E%>xzLx$+#l#79zGR)dd6X5Ume>cy+buJ%*S%LKrs%rC=WJ>cCK`@@Z>EGj(JES zYK1quoOCB3w}cYXWqf9&UYF43!uL=zP`Uh?rnS<&g!sNkiiJCEE$_kVR8_S_GIQB+ zn~MfFn`FAEjJ8ufly}i0J3aTG>|;43^ElKJ`iVQrb7! zjwj8`J)FiBnv3Ke>^O)cb0Pjq)`0FLC^2zG)jJEB^w~&q9x(T4I^94n*6}u|Cv&Z< zrzOsfTf%c$rO3VfR4mnk=$(sj`9){~S{T=H?N zde*lO8UV|~{SJx_J#XO|d)_7}1=_T-<&XTRD}Zvy%-*wKB_!zYP!ON^8c=#r-%J^k@C1E+b_w4Gi^6`S`` z!`+X!67Nk&j83$4Ldkt_vt_!J-X%ur%O@V+A^V8u`z?|)YzPHnafe}N!|mv0Q+tp4 zFuL&v>>REDR!t*k#=7m;FWpX`HB9C6W;=ZIl%BN~(`Im#Od-zN_JJnWr9k%;CwZH(xLYEMc5wVTgJYLCPjtu^C8cM|BwnKrKY3oe+!k$c}oXRONG0OObg*i84;AruFd4R55A zHCENn6VjG6_#F1v&sLBxSa3^vEa#QlHMWp$2wqcYIHhL{XGs1G#um)XkWM)h8Va!F zz3N#}c8oESUrRKv>X3>Fw652cxj~FwL7RNsePG+>iWA#U7;x^{vJhxlg|-C)U+Vo@1_(T6d?TyNn8WIFqI+YHg zS>{d11a&7z0<`j;mpynoDK+Y59msBRu%Bc?-gafHL&uPPH!s#6plAn)J*Zyg`W>ce z-5zpxxZW|+|3^>7O<=p%{#j_BZo zWX1P6P|!0Zp>rQ7cN+8Va`c9j?I&*H{Q9yOUT+_ugFNV#Lu$6JgTcD=2?^k~ z*D*=@UuhKMCL)yRUy9RlVyb98Q29G zY%GJE#$wj(Nt_K6GR)au)(Pin7HBm6IqWwy@xn6*`mUXt#Pga=wE48RiZfY(to?JR z9p}+|x=$8=FxMmi#PjK&pj4E-Xg_@Miwv1ryt6}En}fDKuw6;Hfj|SsLqlqVhrn0< zb;7%lG5)6ikwU}ULIAX3q5?)Pi3%xdKB@e$|8rm7fxl&`Iy~{!!i^w zvOLg%YSC*@a-`*bm$%8>a@+4Z25R77%H)ThP<+m>NY`LTEY&wmkdCdN@vwAhmN zZ)GTu20U?4*WP{&aqh;Ee!{evK)neZ!>}JK#Z>A1)(P<9U=GwK4mmdmDmc9vfIR5j z6x&uZNf_mMLL!Oc{ zxU?2^wul)!^;Jr%Yj>LRc%yxH(|gqlZ?X)VB<(OB3Pu){9|H8fpA-^pH6=b&eBrK6 zWx+GLP`3GLe6VelI+3u?qW_gRqvUNEyx)q<-RqZ_NO8>eqO|)czBt}Idu~|+LWM~Y zKhqZzWx~cYHg*Jjd^9DO>crGI4h=3}@G2Ui?bMxFy5c~P9<{st$YWh1M|uWahB^c( zT_~?Id^u!PVic=+wCe~Tn6hpnBL>cQl#q;jhf7)~P(L`1fHtT<;(&?`iMnx-bME1+ zfL*C)T28xfLKE)^c%Kg8T(1}x$xSGX0hu(LYiVeN)O)R3iq6c$;_V+p@z2(5sNVAS z)CoyU^rw@ybjc+5(#Y1Qwdm2C=~7fQ4pkp0*c0h+GL6sq2lF1{1?b_!OBn9-8e&U+ zQDux&H96dU+7=E~OAmJH>)>tfR zbxgGirfiz%rH#z7r)~)~IavwG&Ov+LSj+gMTd9rXq72e#f{$Al+2Zv&>LE@#zK%5* z*(e)hN$1fJ7x^fnAPD5DxARo0!3xIA*dPza-MRlDR0>MhPO*IzMa}YiJhLo8Jw@)a zB!yhdODJcTnmy=(D#_ZjI@ax+KYwL(AEnPnM8pzwvYw#!o#OBcMneuBuxhCnKnDpZ zZPFgmkCuOGPb@ptCf;O!B0zo_We493!k|xCD~`1v6KIs3Wkw|JED&{$w+c+Tm<$ta zWcJFaEiaLfrz?V<*7~dY=7bF)efxanSp&No^n`NV8^eYb%QT^x?5Vl@y!KD51oor>o0x!%Qe;MRPL|5m6EOrSs10 z49t6}Q4i_X+`Z40ZUVZqEDl=)=ZYtL|r*P>ocEhqBh3oh9FO zTHm$(r9T=(_!w6p_&mm0nu)YJGau5C+osfDevrIVOvFYY6ClOcz*vxiV zWCJd6B3`D97nNgi?Pa46d;6CJNF6gIITiT>P^fp5h$1#G>cBFm$9Uqgx&a506P&|6 z6jw*G1ycEW8#RyzL)B~k`VXF^&)kny$4bFOaV59h4m1q?H!oO9}+Bjd=?e*AK7`zVoE zzMJ8(wb(nu-FfH3r}czI_Y9Fag`~^t379li-k$^%b{WbP#xSLVvF2%Yu2D>CAHd90 zsW??NLAb6E@Tws30w8*I#F&oeQPEoplHVLpwA+)reDT5m$f`hPqIQn-oMwl9eF zA#^j7H1tlpq1)Z5KhLgC-pC)h=hFYNttr}W-)z8m0Qzo5j97F#lz|L|M2cf zP@}2=6_=+h-QXpN-P*dcT^knk<3t4l<_@k!BS~NsCbv)`eL^;k38c4DkQU$c|uoG^l3uDTYV1oQLN_U7oTOyq+SeKL8qHBW8z=87T2YlEO z)3?g>K&IY0*uLl}15i}_^Eg8X_fJU+@VVe^+?t#ylnFy+Tf(OIU~)pLA=K&Xn-d}( zbRoyr^Dxbg-)p>~pVP*QDPO&eZ~yq0@bN}stbDn-{MRQ@@_g1ie>eHM6%&J+A}z<% z8z@&HvE|(|#|~){QYVzD zGuIMDrwZ41J>?_vM<~mN9h@h)1M-%u#23r-$`3ppUb7zop-*b_Tl2%*xl<`=#J(Y; z#$4}pUa#cpvYQ*^Nq?0@oAhRS`g}$FHJc8CIOKpGHAZH{;%CKrP5LCiN_}+c(&S{S zuXxkf)^$eUviOkDUg5r3l9bd}DQ|%}j;aPt64_zYYea>A17mq)Uj}PM9JMEs<&z-q zIE|h~#{IN|M=rj|2WS_@*v%mO>6E`@*4KPEigW9>24Jljpul&!3cT?y7hzjR80?1emrwUCK%_`2IDPpwY@Rj70+t?p zOFq=s$6olGH!v7%s(+tTj%gquK;u}VwaD*rplFj!xV7m$Z+s>HCFR1ds#%v~jfsmJ zZEKGb7lO}YHvQK>pXGSvJ5$v8rvCUZ0#|-#UAAFCxlfOqlvO*idq39quru>0Un;F! zezrSMRnG=I14G#(wZ8wuO9Q?{ zlx`dt>JoFiL^`rQui+MXo$k+O|8}DH4}`&CP!%-&tNOkt@ro`Lzn3s-=QFbnUAd7v zwe(bVob_j^K@{aC8dc?5-c~hqQ4#IJ6aP|K&n4HSs##r6OdltHIpFsXuk5UQ{~V@y z+P$@X6l_M9`o&hhmEzj+3c$o?s=!W!V12h_J;CiA;T1I1$4H zX5hrW@9pd?&@!Qp=?7@~Ds$_C8cCI{gBR9F&8L5-_NGzox>tDS;;fP*FxwF77u3ZH zOnxjRSnWC3`AV+3+|$6Xlh&4?i&#U5r{pco-<0X$0Qa33ufsXa^n#hVLqVcd<{YVc zs$ABGhO!%7o8gQo+J`UubU3MFAC%?RepZk>Y-`(Eu4Pw!gI9D2cQ}^x&w;0eT7CQD zg;x_mwZ<^p>`5F%>ihcQ{ld-INpXy!TqMsB;=PaoTbsr~A<^=Lh~gxAf8?#Ia!LZy z6WxVtdV7u=6kvy9^{G)k&DW3ojCQfbc-qf1g1JqaJ|x8*_f~Y(>_prjGRRW!n(?N!FYz-Zhju+a!PG zSNJ_eiwZBRhUzYU@iN1{F=F5H}c(pKw{LLJ_GwVYRi6Ii6P!Z%taDpQ&jMa;W zT&<34y>36L!kr z*5KIUF#S24L2TDESY{_8;UEG`U2#gEeYkl)%I@yzwZ=a@3_Y&V04s>Yrm$#mDX~p$8?yg+H@bkd467H%$oNASvSKJ2=TSC$<#5yF6C#jW@AGtbC81dhu2!nwA zZ%u-mi4Bs5cmciV&d-3$RADEZdaEufwa6G(p?H_w!cFtI|vOqI=aU z$KR%H^#1reF%}n-qm;T2(nUstixG3!JR06Jx!p0M zfTuwcR3M|&ubBpXILo?$RUhpzJByFQmyiRcV6nK}hMzby)b5Kjkkx7DR8UBF(MZDc zB)-wehQ=huM@al{ao$a{3PNJ2Y8moP$+;*kDiMOTn5c=P;w)2|+ zmQxyXT?Ea=>oHw%Ax=A^?Xzmo#F^cN-W^|9IH=rN{MZq@SrY#`dfZH^ zP|OCu7BqSvob}_Sa8`HL#?E`r(9hI*5+ZZ}o4O9F+euZ=o4^&7${iJ0RHwdfHlGCa zNOj3ry1RkeZ03&l1}q6Gf>z94gPK6}hswEEx$+@vWvz^EMQ2?4NQkQL?3(ZTxTkjA zof#w<=6h|+L8TOYZ5=$dl~%T+O^>)Hmywk0sG+IQj<5($RPfi`Fm4uQjpzG(m;Snb zPWb`v=4s!A)MzV#mUALd6lx+)rZUZ#Gb3p%jIC^+=>L6Bq44m0!u(l9E{yPr;;}k> zOx7h0>dFmV)h(L|9wp&czzvC*b6iH|GG?XMD{8;K06vscBn9@3tsN6L4^zn^h0+xT zCBEt+TiwCJO0-p5t+j-%0oMs3!i+Q$E zj)iVoy_x7|FL1t(x|Eq_Gqz-OwRyigcFNJ<;UJT>iuJ%F$&o5?F;dSMqMbhU5^L~ULI6UycX8=99rOwpEC@D7H5Q?9_jOlPDjv>7;`*!U z1OFeNJ&$m9Ph*z6!8%|1F~!iWr?;nHZyWUK`ZvQr$A+R-F({vkcH=6zIWc0-Z+pH3`Pbd2`kh$L?iG;X2`FbO^xOxwLJ-iw zN3ccv-1a6nybb59s@xTkFouxQ>`WVDH1g@}wl}yoj2gE#5|M7+2TxM}SjY-1Cl*TP z_NSfgoi$&HHKRWoQ|B8&3I7=LZFjZ2hksLbYy*z6X>4^A*Q}WBk6R+77L~Z6Co1{% zqm-Adf+Z0H^rVP(N%!Ii`=YDSIvrOQ?5J>f2?1EAlcEV3Z7s>B*nQcf%+PTuF*rFl zdA~dFM$l@qmd%5rSBAE_j*J@)!54DA=Q)b}6uR~-W%zlct|Gt-=7ZUv#P=voWwoo# zoCuI-lHxUbBs|4SO*!h3L60WBucsAfvRLST+mHXv;zImHsUaz3R3-JQ`+MLWyJ~FD z(aN`qTl1rtg_(P^M}XH5kvg7Vt|+mmE^Vnxw)~A%42dLMLNbwm5^b`<7HJ|vJ%O6V ziOk2yw%d@JlMsM%C(q58rPrwUHoZKv-{Hi5r0^dY1vNjtLaAL*gv0$*;=;C&gxOI| zmO`BG+I=^d2dgFq%{}^zEdw;*k3JaxcBoy$dZO#tb2fBnp8Jw!#gx$c^J$H^0TR%E zLrb7PM<*m1LI^7DKXf(A1K@<5{sG_bFhXxU-AdvNyLNC7jq=|CDhov=?L?Ou2C?UT z8bEpf-v~X)pS4_;BC8lybkLx64 zD1&N1*Rtz_pK>Wg*i8k?bNh<)YT4|>)k)SO+k6J@z6q|k&<PSs?l241{ny{)0OF}1kKP7jbd_zFX*PHbPE`(O$Dbt;AThR6LP zAqY1At<$#keK2jqxP=3Uty{XZ20QxUTS}XENpD;_aTDjx?seSNfUQrTdz5v5J!T6) znf4pBXyVB3{$07YXWq^tl$b;P(bl5q+4DaM{zwU*!O!21{GZOG(IDy-`c1~9rJhu{ ztr=N_jk}gmBK$%H6)Wo3A%oG{sW%U_8NYxZ90*DOY*kA6Txzsyg@D6O!mAX|p5wlk*$|tY5(WhC<}-+v|E?c#Ifq$G zsV}+L_;<&CJw5>1%0Q8#gGZ5IF>$Z^s0^p{+)%JN>;+8q|8w6zyy1`pQHi*OR@s&B(&8)`W8(fJyND=H?= z^PgfJ`CJ{YeltuN>whSS-%9<2r6%4?f6@x{_}%I-Y?6~3RAd(TaN;pAnpD6d**0q* zy%Unw1+)Uy$sb>HHH2$d4nt3yl(!C%HND40L^s-HGC9NhfKpjsoVo3#$1cId!1X`8 z=Ic9XgLe~XLt_;V<1p_uy7R66-jQW&b^o;;&0eYslspNSzdU*l7j89|1?R6L*;t_d zn^d!-3v7=|0MjW%OToa?wt}eE@2bD?x3A-6yXW|JuA`lA%^{@|bbG)0vEud~NA}&Y zaxaL7DweD4_#fVeA3$~enAu}cb&HJM(Nw{)v_Y2(pJU2GAnmz$R806(=$YN-DOFdl z>Ql4VLyu@n3Q)y16HluP6-Y0i&oS9%L!}jCWn!5=jR&2-OOgT@-$|o>HkI99LU_m7 zf0eSKc~z(*U!=gQ7X8jAKzj*Hr@WxnzpS_qg%4%^`j`%GsxLAgj?dNB%3=z#mLK?T^!5jF%ax?3XPJ1P`6-9r#66cdq9QWQx)(IR)T~}Pvo{I zr42~mZ>?{(4|_3rad6s-Tfr=a=JoL11Z+TO85>v@`GjX!-;?>c+;y@fHOiF`VY?}gbPdf5AUZf_#d9S#6LX#|DId+ zBmR3-Ju@r#hxg+Z$Y2lK@?(AH+S=CoDj&}UyaPq=FC_4n%S~b#>%kFu(O_pBN%Z$}s zsJCF+f*Q(%mI})k0nZmH_>;Wg^^&-QSH}5ayCo! zU+`h814b<*MvoR!Y)7c%hpHJrt;Voj*ghjua4~&hPMQ?jT1W!+?Ci9m-Ql;eQC4m2 zAnKQ*t?2K4`Ia#klj-`}kQ^fDv2mH#gZwY#$eU@4Hj7!|m1i^u!cHdZS*UJ{7rGf# zM9~`o^s6mugC`xi%G8bFg5|?MF6JdFwAP10xU4R0N#?_cW0lf-UVw{mPSZQrfUIf& zj6N3RbXT(%gms$^QE3g~INJ450HyDS?@gb+QVR09?sfyrxqv$p_FDRQz zh%Bt{->m=&0Y;xLlu@HCZ6r0kT>n(kvA*3xA6z-a%MarZ9e4E56by+oMa-oC{&F=% z5#cP~5<}ez&vJO}Ar~bk+jgkwm%|kP3ie+HDnoc>jVepDg|C8Dzuo8)TG2zxl_m0T^ru52a2W592tB~mv5^DXi|D|)-rel8 zU$3}GSN{I0<+Gwx3(DE!x{(FZ&f`d`kd$L>1%8(AOX%LFU_5q{f%RL9TdmWrE-Jeh z+kVnI4+*ZiKA@Jd!TZ0WVk3Mhhh2+&p7|otBL&dFg%rNn3=NhU0Zv@C* zT|1Ie7{7tEHI%W`(;qhf|wK)oLGB!oo{M0BKuPAjQ5i_>?*(L)T)T|eF z--)}#y&3Pk^RJCk!>Bl-49$4@-){B9D|`zC(T=D9Vu@#b3XD8TEM9u@z~1SXTyyD0 zg$q)@jwqTv)GxlVTkN9NpE~X0(vRC;Gk6cZ0PWrV?*`aQS82#qG?GrJ2i|$(TuIyA zj&K~(uwUluMM6DH9$2;VRN=pn9&?#}-1 z{Ts~gwA&lEqg!^%<*#!mIV{3=?wbRl^~57_x?~%Oc59q2U0F3Cc8Q^Cu!Dy^#@I2) zD?q1?W7f;eH50~qJnnY-_5m$y#Z{3z+S-P=^UFWHl>-$FJU+1n@7KUPhYevSYPP!# zsW1(CNV74Zv1?3Zk-=RqsNPm5tx=)O5I4pD=Q+Vg{Ee$@Y!m}4LitXSJC?%E1wV~% z{|$z25$aE*`oM$iKb}mSTEZxWBj`xXOzV}Z$(c1k)!@S0tp*9^Q&P>jOW2XyzE*v) zqqgIVr~-Pv`gP2;qR3v5R&_yX+LtR&bi9Rt(2sqm-|4R04Hv$F&K>obylMtLL8RbW zeka`8zFsvAH~TpaT;HCT43mKtpRX34sz9ea*LM$%auts51h4<9y*oeLB7q*+Ed^fz z{66ktjm}_z8U;9bRb}S%7QZ?SD`J_eETOTdy4_tk9j@jhu2M4{ZdSa9^MpK!R?6U>}=(mXycE!r3E+@q?T(r_^tX`TPqqKi^QFuKSEqW ztO;P{YB=n?8?kq`QcAx7PlZFmu88l2z;GGNeVZ%<@TJiwCcs3eg0jY^IfgoM{Fh?< z@|o}li-Gl&owVqfU$2#{i?+tSU&V$?sJKz#Uds=Z1D?e)7ymu${&jMuh7>nAy*dgy zLEQu&>1ZyGbuKS82vJ#SxtX-bGheio$@`tV*nfxWC1@TzF~>G#m6_W2C< zODxwLV6=1KCfd81p>aP`$~UEyk?g*|owu_GK8u+MCB^oOs8To>aYWX_dQXrkr_IaM zgb}Y6lk(6xamEr_LkO^Nk^ipar?sF|P{P$TvnhgWY)h&2zpSo_X;!kxkej7& zesaHc`!stKShRahQhBNZQ4OpKp?`4NCH@F_dI?-JQ!}_-DgC~S6T~{b>}>P(!EX-N zKKy$KsXkoq3kui%c?3VEpTDlDIJ*kzu2C^VLitS`XvO^NueA0*;7TSK4~J*z%`2`` zyVj5%gVRpjc7L{oICT4d%H>vhC#xQ@i~?W)Cverrf+>2|o> zx0B5U{~*CiI9P7_#;ssNnff_!EZ6tB-p;HdTKQM>$Jg3cPEbtdLQb@&?|5d_&`*oL`?)iaKHm^cT8 zpT+n!!4B5m=j7}u=oKTz4X1f$;3nHdSlM5koB)0bq z23Y1aJU%&bIRy@jQjv@e9{(Ib7m^4r5op@EhZW~@4pd}%r^c8tWqE}L-QQ%`=-07P zOi&Nu?w0N&qwK2x+(n^akxR1A1q>Sa?ijv(a>{k4)bptuai*@LTpA7-#aNYhO`>`JN4_Z^0o z&{>WB*%B@d_f{Du=t?9}bj z@ik_znmSfzzzi!Hm6pRVo&Ite1Fw5Zs8nB_yxHpghgW`sqgQ&zPmw@K>~}tq0S~=u zb64&p{tM~{7VFzkEvX_uw@lN5K1(Z=b7)lr$_)RvuGU)Bz{>ey3+ zHH^Lc%uHd`K6^8Rdxw8`s#uAGl^O}%X)XGX2&11jW`{n9j3})0SwzilSA;dz+bk6U z71ya~J|N!7aO+!#4AH43j)CYiUZLaN0Bpg&ct?JJ@*s`M+EK@0i?^$OH&7Kf`kjaz zSouw-F6{kHi-Z}JZ%?HNrXrWktU1Iiq`++VSh>Bku2WgERE0lLe_(8iF&4v}ST2OQ z`4Zm9wStNfq~5&#AORJpr2pG9xH_QnA@{B!=d?TFd4iifo|76#1epRvv0CFRd#~%S zua?)SiR^ZHQJ<{J&@u zwQ&t;XbZ>8C)_`)Lcx-Y29xJHkB;k=yzV(AVZbeYexplPNQiV&!PUYRT;z_~=iKEn z*YL5GRUgB$Z1B~`VQP@5ZqtH6XA9F=2`gMdaH~lvdSRR*-i4tNzy!w&2+i;ZVc;Bk6U%1h0(8i&O%oV=38JFpZ(z zyV4o1oDKb^5Dwrlq-aoTgKP6F_;B+mq6mi&2%!5Tv4-#Rtl%^cF&W$-kmNJCGp_z{ z-QQ0S)LPP(bpS_+54=d3ZzZ==Q4tgt1mF-AjBDaTweUuJ78Hs5g7WjAe13b~sRv;S zFcbLPreCnw_rEGmZWb=v=EUfCj4H%2m?YcVFBHiX$>@Cq7jcQ)YwZhxJ432@y#ly0 zSN;vC2r++#)x>o?*t959xBbIo=0b-o!BbD$gEV#`7PbM9Z}|Hi^C#F`N-QDO=!WbV zIj1&KFVsfzBWB?HmMArwOO~GCb~hXK3}blR-$kZzmJd(7N?Y91Bk3vdc019fY-?U zwUe`LWcdIe9L5|kn>2EpM*lMNMW;aN1F_c1uy(=4chp#!siJ>)D8a>EYAhEnn8~-R znv_2O@WzyQ2JdPFDhHkvy;h=mZ_a0OIjQ_aQ2a|>9{Ohds`2uK#%EVxVm^j)hVd@F zbw*eI2$#&xx^TeqG}sq?Wc1h0{s#8_KhCW1IxkFFTpoA!;dZ|&s;41b_3rDoPfr=T zcNH%et7`NS4h1|v(!*&l4c1L+P$(6zrl>3}k|Sm%gzcq-2H(0t{#bX>Z+NBVZfr6IA z^Uzp~;+>q#;PFe5yGVvWi3kmBu~pKwfKIDW;D;lz8lSDTSfhi-OZcMiE!v)!%(Q0V z_rgaR0+yeB;R+4o?U{#$kpLx=izxQju3jwb=qUK zqYTm$hG?>$;-2q#PVEZLu$I&nn_!Zh4}#QY(;I&4$k?L|S-#uup6QS~7?;uXS&%P! zT(?7T`3^kG43Q{5Q?|u30dDlRo3j(A#R{yE5}TjYg}^jB4OB{}-@SjK@^d%0YXwI> zBzgC8O;}T0bD4%9j)p8s$uZgA7Af@KN1~b}TV@nYHM554b!qaK$3VOdRv8T;kIdg4QvaaCw*Vv$Pf!K(hA4`%O19%j_*2hVm_ z&4Cr~9w`8~1{Hw1w8#2pe^=d46;!>ht3Af0ftwgtKSX(kotd7<1?j*BkI*yJ!n2V! z?%9on6w{oJq^7p>n*4L6%e)qq}%$~y_=`rM};gpXk&-1yk37(*MZjG;zwBO`5#Yo4SPoJT~49ef~ zYOBm0(bVahVe_jR-@M6f`1Wmz2r*`$O)okar+7hSzIw&pN~J)7G2i>*Ma_shLK5~1 zWFfaz8gh`cH`?MV;EtR;&;)M02Mu4?30c+Nxfh%lOnsHgr1NZONZ|e_^qRvc^Ww-x z%%AZa!J5A{uh(*sn>RhbaaR=zJw~T_rKLj%ByT*-_eQ%~P_^jn>hQab)Hg5209KxZ zQLE^=M?;}rm$g4q57niU>1A~HeY9S0Y{wrQzp`FBLgPPte7gz+{R!6Z_QZ8G_)V|x zg>~%xt(7RaGnqCZi7WE4P*}O(JVnM~ju;RT#%wT7yC$10< z*Z*<70_EFLI0+6{RaG)0Y^)VLKq;BFQL&(pyLA5HNdpNCw2zKR(s83IS8!?bmWTR0 z_wMZIp>uxK*2-Mp&eZO_b#cuZxCB8xcLPz0NJ;d)kASJ@i(CI!YIT_W?kRRDeE4yJ zopC48g(vVUGPFB(=vJM#ftQiMfdhI^%v|5!H@)mCZlJ#B+47hAf?$qhy>a_pruad{ zsTl>z+=7k}sLnEt-=Ru`tG|n@wC}90Dxky7#evs>>F80Ol4_>`N_+&shgf2kPviNv zG#L9d!CUXyci4k>R7wSnjzm{RgzRFweC_?)ShUI4)f?^Qr%cAvLP}xhT2|@%h2e

5LRMdWdYtg6B4Y5s+UYf+sj7mzF<0 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 b9STyse&*r%ZeP21;k<7I@KF6b^?(1_G%aEc diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/PoweredBy.imageset/Contents.json b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/PoweredBy.imageset/Contents.json deleted file mode 100644 index 35b43e98..00000000 --- a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/PoweredBy.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "powered-by.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/PoweredBy.imageset/powered-by.png b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/PoweredBy.imageset/powered-by.png deleted file mode 100644 index 5377d689d084bbc6c97d40f4b0a9965dc57b92ad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6006 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 XCTestAgentResponse - private let listener: NWListener - private let queue = DispatchQueue(label: "dev.reactnativeharness.xctest-agent.http") - - init(port: UInt16, handler: @escaping (XCTestAgentRequest) -> XCTestAgentResponse) throws { - guard let listenerPort = NWEndpoint.Port(rawValue: port) else { - throw NSError(domain: "HarnessXCTestAgent", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Invalid XCTest agent port \(port)" - ]) - } - - self.listener = try NWListener(using: .tcp, on: listenerPort) - self.handler = handler - } - - func start(log: @escaping (String) -> Void) { - listener.newConnectionHandler = { [weak self] connection in - self?.handle(connection: connection, log: log) - } - listener.stateUpdateHandler = { state in - log("HTTP listener state: \(String(describing: state))") - } - listener.start(queue: queue) - } - - func stop() { - listener.cancel() - } - - private func handle(connection: NWConnection, log: @escaping (String) -> Void) { - connection.start(queue: queue) - receive(on: connection, buffer: Data(), log: log) - } - - private func receive(on connection: NWConnection, buffer: Data, log: @escaping (String) -> Void) { - connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { - [weak self] data, _, isComplete, error in - guard let self else { - connection.cancel() - return - } - - if let error { - log("HTTP receive failed: \(error.localizedDescription)") - connection.cancel() - return - } - - var nextBuffer = buffer - if let data { - nextBuffer.append(data) - } - - if let request = self.parseRequest(from: nextBuffer) { - let response = self.handler(request) - self.send(response: response, on: connection, log: log) - return - } - - if isComplete { - connection.cancel() - return - } - - self.receive(on: connection, buffer: nextBuffer, log: log) - } - } - - private func parseRequest(from data: Data) -> XCTestAgentRequest? { - guard let headerRange = data.range(of: Data("\r\n\r\n".utf8)) else { - return nil - } - - let headerData = data[..= 2 else { - return nil - } - - let contentLength = headerLines.dropFirst().reduce(0) { partialResult, line in - let parts = line.split(separator: ":", maxSplits: 1).map(String.init) - guard parts.count == 2 else { - return partialResult - } - - return parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "content-length" - ? (Int(parts[1].trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0) - : partialResult - } - - let bodyStart = headerRange.upperBound - let bodyEnd = data.index(bodyStart, offsetBy: contentLength, limitedBy: data.endIndex) - - guard let bodyEnd else { - return nil - } - - return XCTestAgentRequest( - body: data[bodyStart.. Void) { - let statusText = response.statusCode == 200 ? "OK" : "Error" - let header = "HTTP/1.1 \(response.statusCode) \(statusText)\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: \(response.body.count)\r\n\r\n" - let payload = Data(header.utf8) + response.body - - connection.send(content: payload, contentContext: .defaultMessage, isComplete: true, completion: .contentProcessed { error in - if let error { - log("HTTP send failed: \(error.localizedDescription)") - } - - connection.cancel() - }) - } - - func encode(_ value: T) -> Data { - return (try? encoder.encode(value)) ?? Data("{}".utf8) - } -} - -final class HarnessXCTestAgentUITests: XCTestCase { - private enum Environment { - static let targetBundleIdentifier = "HARNESS_XCTEST_AGENT_TARGET_BUNDLE_ID" - static let tickIntervalMs = "HARNESS_XCTEST_AGENT_TICK_INTERVAL_MS" - } - - private enum Constants { - static let defaultSessionDuration: TimeInterval = 60 * 60 - static let defaultTickInterval: TimeInterval = 1 - } - - private static func resolveTickInterval() -> TimeInterval { - guard - let rawValue = ProcessInfo.processInfo.environment[Environment.tickIntervalMs], - let milliseconds = Int(rawValue), - milliseconds > 0 - else { - return Constants.defaultTickInterval - } - - return TimeInterval(milliseconds) / 1000 - } - - private let state = HarnessXCTestAgentState( - permissions: PermissionPromptConfiguration.fromEnvironment() - ) - private var lastTargetApplicationState: XCUIApplication.State? - private let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") - private var capabilities: [AgentCapability] = [] - private var httpServer: XCTestAgentHTTPServer? - private let tickInterval = HarnessXCTestAgentUITests.resolveTickInterval() - - private func log(_ message: String) { - NSLog("[HarnessXCTestAgent] %@", message) - } - - private func makeTargetApplication() -> XCUIApplication? { - if let bundleIdentifier = ProcessInfo.processInfo.environment[Environment.targetBundleIdentifier], !bundleIdentifier.isEmpty { - return XCUIApplication(bundleIdentifier: bundleIdentifier) - } - - return nil - } - - private func observeTargetApplication() { - guard let targetApplication = makeTargetApplication() else { - return - } - - let currentState = targetApplication.state - if currentState == lastTargetApplicationState { - return - } - - lastTargetApplicationState = currentState - log("target application state changed: \(String(describing: currentState))") - } - - private func jsonResponse(_ value: T) -> XCTestAgentResponse { - guard let httpServer else { - return XCTestAgentResponse(body: Data("{}".utf8), statusCode: 500) - } - - return XCTestAgentResponse(body: httpServer.encode(value), statusCode: 200) - } - - private func handleRequest(_ request: XCTestAgentRequest) -> XCTestAgentResponse { - switch (request.method, request.path) { - case ("GET", "/health"): - return jsonResponse( - XCTestAgentHealthResponse( - permissions: state.permissions, - status: "ok" - ) - ) - case ("POST", "/permissions/configure"): - guard let configuration = try? JSONDecoder().decode( - PermissionPromptConfiguration.self, - from: request.body - ) else { - return XCTestAgentResponse(body: Data("{\"error\":\"invalid configuration\"}".utf8), statusCode: 400) - } - - state.updatePermissions(configuration) - return jsonResponse(XCTestAgentPermissionsResponse(permissions: state.permissions)) - case ("GET", "/permissions"): - return jsonResponse(XCTestAgentPermissionsResponse(permissions: state.permissions)) - case ("POST", "/shutdown"): - log("shutdown requested") - state.requestShutdown() - return jsonResponse( - XCTestAgentHealthResponse( - permissions: state.permissions, - status: "shutting-down" - ) - ) - default: - return XCTestAgentResponse(body: Data("{\"error\":\"not found\"}".utf8), statusCode: 404) - } - } - - private func startHTTPServer() throws { - let port = UInt16(ProcessInfo.processInfo.environment["HARNESS_XCTEST_AGENT_PORT"] ?? "49200") ?? 49200 - httpServer = try XCTestAgentHTTPServer(port: port) { [weak self] request in - guard let self else { - return XCTestAgentResponse(body: Data("{}".utf8), statusCode: 500) - } - - return handleRequest(request) - } - httpServer?.start(log: log) - log("HTTP server started on port \(port)") - } - - override func setUpWithError() throws { - continueAfterFailure = false - capabilities = [ - PermissionPromptWatchdog( - state: state, - springboard: springboard - ) - ] - - log("setUpWithError started") - log("enabled capabilities: \(capabilities.map { String(describing: type(of: $0)) }.joined(separator: ", "))") - - for capability in capabilities { - try capability.setUp() - } - - try startHTTPServer() - - log("setUpWithError completed") - } - - override func tearDown() { - httpServer?.stop() - httpServer = nil - super.tearDown() - } - - @MainActor - func testAgentSession() { - log("testAgentSession started") - - let sessionDeadline = Date().addingTimeInterval(Constants.defaultSessionDuration) - - while Date() < sessionDeadline && !state.shouldShutdown { - observeTargetApplication() - - for capability in capabilities { - try? capability.tick() - } - - RunLoop.current.run( - until: Date().addingTimeInterval(tickInterval) - ) - } - - log("testAgentSession completed (shutdownRequested=\(state.shouldShutdown))") - } -} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/PermissionPromptWatchdog.swift b/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/PermissionPromptWatchdog.swift deleted file mode 100644 index 8a732266..00000000 --- a/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/PermissionPromptWatchdog.swift +++ /dev/null @@ -1,75 +0,0 @@ -import XCTest - -private enum PermissionPromptEnvironment { - static let autoAcceptPermissions = "HARNESS_XCTEST_AGENT_AUTO_ACCEPT_PERMISSIONS" -} - -struct PermissionPromptConfiguration: Codable { - var autoAcceptPermissions: Bool - - static func fromEnvironment() -> PermissionPromptConfiguration { - return PermissionPromptConfiguration( - autoAcceptPermissions: ProcessInfo.processInfo.environment[PermissionPromptEnvironment.autoAcceptPermissions] == "1" - ) - } -} - -final class PermissionPromptWatchdog: AgentCapability { - private enum Constants { - static let knownPositiveButtonLabels = [ - "Allow", - "OK", - "Continue", - "Next", - "While Using App", - "While Using the App", - "Always Allow", - "Allow Once", - "Join", - "Pair", - "Allow Full Access" - ] - } - - private let springboard: XCUIApplication - private let state: HarnessXCTestAgentState - - private func log(_ message: String) { - NSLog("[HarnessXCTestAgent][PermissionPromptWatchdog] %@", message) - } - - init(state: HarnessXCTestAgentState, springboard: XCUIApplication) { - self.state = state - self.springboard = springboard - } - - func setUp() throws { - if state.permissions.autoAcceptPermissions { - log("permission prompt watchdog enabled") - } - } - - func tick() throws { - guard state.permissions.autoAcceptPermissions else { - return - } - - let guardQuery = springboard.buttons.matching( - NSPredicate(format: "label IN %@", Constants.knownPositiveButtonLabels) - ).firstMatch - - guard guardQuery.exists else { - return - } - - for label in Constants.knownPositiveButtonLabels { - let button = springboard.buttons[label].firstMatch - - if button.exists && button.isHittable { - log("tapping button: \(label)") - button.tap() - return - } - } - } -} diff --git a/packages/tools/src/__tests__/harness-artifacts.test.ts b/packages/tools/src/__tests__/harness-artifacts.test.ts index 9ace9175..de234d33 100644 --- a/packages/tools/src/__tests__/harness-artifacts.test.ts +++ b/packages/tools/src/__tests__/harness-artifacts.test.ts @@ -26,7 +26,7 @@ describe('createHarnessArtifactDirectory', () => { platformId: 'ios', rootDir, runTimestamp: '2026-04-29T10-45-31-645Z', - runnerName: 'xctest-agent simulator', + runnerName: 'permission-agent simulator', }); expect(artifacts.rootDir).toBe(path.join(rootDir, 'logs')); @@ -34,7 +34,7 @@ describe('createHarnessArtifactDirectory', () => { path.join( rootDir, 'logs', - '2026-04-29T10-45-31-645Z--ios--xctest-agent-simulator--com.harnessplayground.dev' + '2026-04-29T10-45-31-645Z--ios--permission-agent-simulator--com.harnessplayground.dev' ) ); expect(fs.existsSync(artifacts.directoryPath)).toBe(true); @@ -45,8 +45,8 @@ describe('createHarnessArtifactDirectory', () => { expect(getHarnessCacheRootPath(rootDir)).toBe( path.join(rootDir, '.harness', 'cache') ); - expect(getHarnessCacheArtifactPath('xctest-agent simulator', rootDir)).toBe( - path.join(rootDir, '.harness', 'cache', 'xctest-agent-simulator') + expect(getHarnessCacheArtifactPath('agent-device runner', rootDir)).toBe( + path.join(rootDir, '.harness', 'cache', 'agent-device-runner') ); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e41bc20..56806dd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -463,19 +463,15 @@ importers: '@react-native-harness/tools': specifier: workspace:* version: link:../tools + agent-device: + specifier: ~0.21.0 + version: 0.21.1 tslib: specifier: ^2.3.0 version: 2.8.1 - yargs: - specifier: ^17.7.2 - version: 17.7.2 zod: specifier: ^3.25.67 version: 3.25.67 - devDependencies: - '@types/yargs': - specifier: ^17.0.32 - version: 17.0.33 packages/platform-vega: dependencies: @@ -3509,6 +3505,16 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agent-device@0.21.1: + resolution: {integrity: sha512-vFhcqkQlusuOHR389h7WLhINfLdsMie3D5YPYNm8OOIjh4iy/YCQtNoKVqxQ+zeTrYZMOBxbWEHnL07/WVzTPA==} + engines: {node: '>=22.12'} + hasBin: true + peerDependencies: + ai: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + ai: + optional: true + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -12089,7 +12095,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@22.1.0)(terser@5.42.0)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(terser@5.42.0)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -12228,6 +12234,8 @@ snapshots: agent-base@7.1.4: {} + agent-device@0.21.1: {} + ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 From d22526f29a0a0d6452d849ae518364700849612e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 14 Sep 2026 08:58:08 +0200 Subject: [PATCH 04/12] feat(cli): add the agent-device runner cache key to `harness ci` `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. --- action.yml | 32 +++-- .../__tests__/plan-ios-runner-cache.test.ts | 32 +++++ packages/cli/src/ci/plan-ios-runner-cache.ts | 118 ++++++++++++++++++ packages/cli/src/index.ts | 9 ++ packages/react-native-harness/package.json | 2 +- 5 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/ci/__tests__/plan-ios-runner-cache.test.ts create mode 100644 packages/cli/src/ci/plan-ios-runner-cache.ts diff --git a/action.yml b/action.yml index 30c75df6..fa6db9b3 100644 --- a/action.yml +++ b/action.yml @@ -123,7 +123,7 @@ runs: # workspace version), and it would also reject semver-correct # prereleases of that release. Bump REQUIRED_PROTOCOL only when a # change to the `harness ci` contract is breaking for this action. - REQUIRED_PROTOCOL=1 + REQUIRED_PROTOCOL=2 INSTALLED_VERSION=$(node -p "require(require.resolve('react-native-harness/package.json',{paths:[process.cwd()]})).version" 2>/dev/null) if [ -z "$INSTALLED_VERSION" ]; then echo "Error: could not resolve the 'react-native-harness' package in this project." @@ -195,15 +195,25 @@ runs: METRO_RESTORED_KEY: ${{ steps.restore-metro.outputs.cache-matched-key }} run: | ${{ steps.detect-pm.outputs.runner }}react-native-harness ci snapshot-metro - - name: Restore Harness cache - id: cache-harness-restore + # agent-device rebuilds its XCUITest runner whenever its own version, Xcode + # or the SDK changes, so the key is exact and carries no restore-keys: a + # near-miss entry would be ~165 MB of DerivedData the runner cannot reuse. + - name: Plan agent-device runner cache + id: plan-ios-runner-cache if: fromJson(steps.load-config.outputs.config).platformId == 'ios' + shell: bash + working-directory: ${{ steps.load-config.outputs.projectRoot }} + env: + INPUT_PROJECTROOT: ${{ steps.load-config.outputs.projectRoot }} + run: | + ${{ steps.detect-pm.outputs.runner }}react-native-harness ci plan-ios-runner-cache + - name: Restore agent-device runner cache (.harness/cache/agent-device-runner) + id: cache-ios-runner-restore + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'ios' && steps.plan-ios-runner-cache.outputs.iosRunnerCacheKey != '' }} uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: - path: ${{ steps.load-config.outputs.projectRoot }}/.harness/cache/xctest-agent-simulator-* - key: harness-ios-${{ runner.os }}-${{ hashFiles(format('{0}/.harness/cache/**/cache.json', steps.load-config.outputs.projectRoot)) }} - restore-keys: | - harness-ios-${{ runner.os }}- + path: ${{ steps.load-config.outputs.projectRoot }}/.harness/cache/agent-device-runner + key: ${{ steps.plan-ios-runner-cache.outputs.iosRunnerCacheKey }} # ── iOS ────────────────────────────────────────────────────────────────── # iOS simulator boot and app installation are handled by Harness itself. @@ -318,12 +328,12 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - - name: Save Harness cache - if: ${{ always() && fromJson(steps.load-config.outputs.config).platformId == 'ios' && steps.cache-harness-restore.outputs.cache-hit != 'true' }} + - name: Save agent-device runner cache + if: ${{ always() && fromJson(steps.load-config.outputs.config).platformId == 'ios' && steps.plan-ios-runner-cache.outputs.iosRunnerCacheKey != '' && steps.cache-ios-runner-restore.outputs.cache-hit != 'true' }} uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: - path: ${{ steps.load-config.outputs.projectRoot }}/.harness/cache/xctest-agent-simulator-* - key: ${{ steps.cache-harness-restore.outputs.cache-primary-key }} + path: ${{ steps.load-config.outputs.projectRoot }}/.harness/cache/agent-device-runner + key: ${{ steps.plan-ios-runner-cache.outputs.iosRunnerCacheKey }} - name: Plan Metro cache save id: plan-metro-save if: always() diff --git a/packages/cli/src/ci/__tests__/plan-ios-runner-cache.test.ts b/packages/cli/src/ci/__tests__/plan-ios-runner-cache.test.ts new file mode 100644 index 00000000..9383da3b --- /dev/null +++ b/packages/cli/src/ci/__tests__/plan-ios-runner-cache.test.ts @@ -0,0 +1,32 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { resolveAgentDeviceVersion } from '../plan-ios-runner-cache.js'; + +let projectRoot: string; + +describe('resolveAgentDeviceVersion', () => { + beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-ci-')); + }); + + afterEach(() => { + fs.rmSync(projectRoot, { recursive: true, force: true }); + }); + + it('reads the version from a hoisted node_modules layout', () => { + const packageDir = path.join(projectRoot, 'node_modules', 'agent-device'); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify({ name: 'agent-device', version: '0.21.0' }) + ); + + expect(resolveAgentDeviceVersion(projectRoot)).toBe('0.21.0'); + }); + + it('returns null when agent-device is not installed', () => { + expect(resolveAgentDeviceVersion(projectRoot)).toBeNull(); + }); +}); diff --git a/packages/cli/src/ci/plan-ios-runner-cache.ts b/packages/cli/src/ci/plan-ios-runner-cache.ts new file mode 100644 index 00000000..c3ee7d5d --- /dev/null +++ b/packages/cli/src/ci/plan-ios-runner-cache.ts @@ -0,0 +1,118 @@ +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { getFs } from '@react-native-harness/tools/harness-context'; +import { spawn } from '@react-native-harness/tools'; +import { resolveProjectRoot } from './workspace-root.js'; + +/** + * `agent-device`'s `exports` map does not expose `./package.json`, so its + * version is read by walking `node_modules` upwards from the Apple platform + * package that depends on it. That works for hoisted, isolated and nested + * layouts alike. + */ +export const resolveAgentDeviceVersion = ( + projectRoot: string +): string | null => { + const fs = getFs(); + const requireFromProject = createRequire(path.join(projectRoot, 'noop.js')); + + let startDirectory: string; + + try { + startDirectory = path.dirname( + requireFromProject.resolve( + '@react-native-harness/platform-apple/package.json' + ) + ); + } catch { + startDirectory = projectRoot; + } + + let directory = startDirectory; + + while (true) { + const manifestPath = path.join( + directory, + 'node_modules', + 'agent-device', + 'package.json' + ); + + if (fs.existsSync(manifestPath)) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { + version?: string; + }; + + return manifest.version ?? null; + } + + const parent = path.dirname(directory); + + if (parent === directory) { + return null; + } + + directory = parent; + } +}; + +const getXcodeVersion = async (): Promise => { + try { + const { stdout } = await spawn('xcodebuild', ['-version']); + + return stdout.trim(); + } catch { + return null; + } +}; + +/** + * Prints the cache key for the agent-device iOS runner's DerivedData, which + * `@react-native-harness/platform-apple` keeps in the Harness cache directory. + * agent-device rebuilds the runner whenever its own version, Xcode or the SDK + * changes, so the key is pinned to the installed package version and the + * `xcodebuild -version` output. + */ +export const runPlanIosRunnerCache = async (): Promise => { + try { + const projectRoot = resolveProjectRoot(process.env.INPUT_PROJECTROOT); + const githubOutput = process.env.GITHUB_OUTPUT; + + if (!githubOutput) { + throw new Error('GITHUB_OUTPUT environment variable is not set'); + } + + const agentDeviceVersion = resolveAgentDeviceVersion(projectRoot); + const xcodeVersion = await getXcodeVersion(); + + if (!agentDeviceVersion || !xcodeVersion) { + console.info( + 'Skipping the agent-device runner cache: could not resolve the installed agent-device version or the Xcode version.' + ); + + getFs().appendFileSync(githubOutput, 'iosRunnerCacheKey=\n'); + + return; + } + + const os = process.env.RUNNER_OS ?? process.platform; + const xcodeHash = createHash('sha256') + .update(xcodeVersion) + .digest('hex') + .slice(0, 12); + const cacheKey = `harness-agent-device-runner-${os}-${agentDeviceVersion}-${xcodeHash}`; + + console.info(`Planned agent-device runner cache key: ${cacheKey}`); + + getFs().appendFileSync(githubOutput, `iosRunnerCacheKey=${cacheKey}\n`); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } else { + console.error('Failed to plan the agent-device runner cache'); + } + + process.exit(1); + } +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 95fd2c65..9eacc2d8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -88,6 +88,7 @@ const CI_SUBCOMMANDS = [ 'plan-metro-restore', 'snapshot-metro', 'plan-metro-save', + 'plan-ios-runner-cache', ] as const; const printCiUsage = () => { @@ -98,6 +99,7 @@ Commands: plan-metro-restore Plan a Metro cache restore snapshot-metro Snapshot the Metro cache after restore plan-metro-save Plan a Metro cache save + plan-ios-runner-cache Print the agent-device iOS runner cache key These commands are invoked by the react-native-harness GitHub Action and are not intended to be run manually.`); @@ -137,6 +139,13 @@ const runCiCommand = async () => { await runPlanMetroSave(); return; } + case 'plan-ios-runner-cache': { + const { runPlanIosRunnerCache } = await import( + './ci/plan-ios-runner-cache.js' + ); + await runPlanIosRunnerCache(); + return; + } } }; diff --git a/packages/react-native-harness/package.json b/packages/react-native-harness/package.json index 6e6e1e82..1071f3bb 100644 --- a/packages/react-native-harness/package.json +++ b/packages/react-native-harness/package.json @@ -4,7 +4,7 @@ "comments": { "harnessActionProtocol": "Capability marker read by the callstackincubator/react-native-harness GitHub Action (see action.yml's 'Verify react-native-harness supports this action's CLI interface' step) to confirm the installed CLI supports the `harness ci ` interface the action invokes, instead of comparing version numbers. Bump only on a breaking change to that `harness ci` contract." }, - "harnessActionProtocol": 1, + "harnessActionProtocol": 2, "bin": { "react-native-harness": "./bin.js", "harness": "./bin.js" From a0bd0910a289a1c316c954dfe8e789c13fbbda79 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 14 Sep 2026 08:58:28 +0200 Subject: [PATCH 05/12] docs: document agent-device permission automation --- docs/github-action.md | 3 +- website/src/docs/api/cli.mdx | 18 ----- .../docs/getting-started/configuration.mdx | 4 +- website/src/docs/guides/permissions.mdx | 67 ++++++++++++++++--- 4 files changed, 63 insertions(+), 29 deletions(-) diff --git a/docs/github-action.md b/docs/github-action.md index 5ed4e515..d88c5c06 100644 --- a/docs/github-action.md +++ b/docs/github-action.md @@ -37,13 +37,14 @@ Keeping the action ref aligned with the `react-native-harness` version in your p - `cacheSavePolicy` (optional): When to save a new Metro cache entry -- `default-branch` (default), `always`, or `never` - Crash artifacts persisted to `.harness/crash-reports/` are uploaded automatically when present - Metro cache persisted to `.harness/cache/metro/` and `.harness/cache/metro-file-map/` is restored and saved automatically when present. The cache key is computed by `@react-native-harness/cache` from your lockfile(s), Metro/Babel config, the installed `@react-native-harness/bundler-metro` version, and your Harness config's `cache.version` salt -- a new entry is only saved when the run's cache content actually changed and `cacheSavePolicy` allows it +- For iOS runners, the `agent-device` UI test runner build in `.harness/cache/agent-device-runner/` is restored and saved automatically. The key is computed by `react-native-harness ci plan-ios-runner-cache` from the installed `agent-device` version and `xcodebuild -version`, with no restore-key prefixes: a near-miss entry cannot be reused by the runner, so an Xcode or `agent-device` upgrade means exactly one cold runner build ## Behavior Depending on the selected runner, the action: - For Android runners, loads and validates your Harness configuration, restores Metro cache, sets up the Android emulator with architecture detection, optionally caches AVD snapshots, installs your app on the emulator, runs the hooks inside the emulator session, and runs the Harness tests -- For iOS runners, loads and validates your Harness configuration, restores Metro cache, sets up the iOS simulator, installs your app on the simulator, runs the hooks around the Harness invocation, and runs the Harness tests +- For iOS runners, loads and validates your Harness configuration, restores Metro cache and the `agent-device` runner cache, sets up the iOS simulator, installs your app on the simulator, runs the hooks around the Harness invocation, and runs the Harness tests - For web runners, loads and validates your Harness configuration, restores Metro cache, installs Playwright Chromium, runs the hooks around the Harness invocation, and runs the Harness tests Hook behavior: diff --git a/website/src/docs/api/cli.mdx b/website/src/docs/api/cli.mdx index 940e2d4d..dfb338da 100644 --- a/website/src/docs/api/cli.mdx +++ b/website/src/docs/api/cli.mdx @@ -19,24 +19,6 @@ The wizard will help you: - Configure Android, iOS, or Web runners. - Generate configuration files (`rn-harness.config.mjs` and `jest.harness.config.mjs`). -### `xctest build` - -Builds the bundled iOS XCTest agent without reading `rn-harness.config.*`, starting Metro, launching an app, or running tests. - -```bash -npx react-native-harness xctest build --destination simulator -npx react-native-harness xctest build --destination device -npx react-native-harness xctest build --destination device --teamId BAJL5U28HC -``` - -`--destination simulator` builds simulator artifacts with the iOS Simulator SDK. `--destination device` builds device artifacts with the iOS SDK. Device signing is controlled only by the signing flags you pass: - -- `--teamId ` -- `--signingIdentity ` -- `--provisioningProfile ` - -Unsigned device builds are allowed intentionally for workflows that upload the `iphoneos` artifacts and re-sign them elsewhere. The command prints the DerivedData path, generated `.xctestrun` path, destination, and whether cached artifacts were reused. - ### `(default)` Runs tests using the Harness test runner. diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index 2a0d6f16..a4b8c26f 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -102,7 +102,7 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` | `bundleStartTimeout` | Bundle start timeout in milliseconds (default: `60000`). | | `maxAppRestarts` | Maximum number of automatic app relaunch attempts while Harness is waiting for startup (default: `2`). | | `eagerPrewarm` | Start building the Metro bundle while the platform is still booting so the first bundle is ready sooner (default: `true`). | -| `permissions` | Enable platform-specific permission prompt automation (default: `false`). On iOS, this controls whether Harness starts the XCTest-based permission helper. | +| `permissions` | Enable platform-specific permission prompt automation (default: `false`). On iOS, this controls whether Harness starts the agent-device permission watchdog. Requires Node.js 22.12 or newer. | | `resetEnvironmentBetweenTestFiles` | How to reset the environment between test files: `true`/`'process'` restarts the app process (default), `'runtime'` reloads the JS runtime in place (falling back to a process restart on failure), `false` disables resetting. | | `detectNativeCrashes` | Detect native app crashes during startup and test execution (default: `true`). | | `crashDetectionInterval` | Interval in milliseconds to check for native crashes (default: `500`). | @@ -198,7 +198,7 @@ Use the `permissions` flag to opt into Harness-managed permission prompt handlin **Default:** `false` -When `permissions` is `false`, Harness does not start platform-specific permission automation helpers. On iOS that means no XCTest agent session is started for permission auto-accept. Physical iOS devices still require `device.codeSign` in the runner config when you set `permissions: true`. +When `permissions` is `false`, Harness does not start platform-specific permission automation helpers. On iOS that means no agent-device UI test runner is prepared for permission auto-accept. Physical iOS devices still require `device.codeSign` in the runner config when you set `permissions: true`, and personal teams may also need `device.codeSign.runnerBundleId`. See the [permissions guide](/docs/guides/permissions). The platform ready timeout controls how long React Native Harness waits for the selected device, simulator, or emulator to become usable. This includes device discovery, simulator or emulator boot, and platform runtime setup before the app is launched. diff --git a/website/src/docs/guides/permissions.mdx b/website/src/docs/guides/permissions.mdx index e27c8fee..def78d24 100644 --- a/website/src/docs/guides/permissions.mdx +++ b/website/src/docs/guides/permissions.mdx @@ -19,30 +19,64 @@ Harness does not yet support selecting individual permission types or per-platfo When `permissions` is `false`: - Harness does not start permission automation helpers. -- On iOS, this means the XCTest-based permission helper is not started. +- On iOS, no permission watchdog and no UI test runner are started. When `permissions` is `true`: -- On iOS simulators, Harness starts the XCTest agent and enables best-effort permission prompt auto-accept. -- On iOS physical devices, Harness also starts the XCTest agent when the runner is configured with `device.codeSign`. +- On iOS simulators, Harness prepares the [agent-device](https://github.com/callstack/agent-device) UI test runner and watches for system permission prompts. +- On iOS physical devices, Harness does the same when the runner is configured with `device.codeSign`. - On Android emulators and physical devices, Harness uses `adb shell pm grant` to automatically grant the app's requested dangerous permissions. +:::info Node.js 22.12 or newer +iOS permission automation runs on `agent-device`, which requires Node.js 22.12 or newer. Harness fails with a clear error when `permissions: true` is used on an older Node.js. +::: + ## Platform-Specific Implementation Details ### iOS The iOS implementation does **not** pre-approve every permission up front. -Instead, it runs a best-effort watchdog that taps known positive system-prompt buttons such as: +Instead, it runs a best-effort watchdog that reads the buttons on the visible system prompt and taps the first known positive one: - `Allow` - `OK` - `Continue` +- `Next` +- `While Using App` - `While Using the App` - `Always Allow` - `Allow Once` +- `Allow While Using App` +- `Join` +- `Pair` +- `Allow Full Access` Because this is button-based automation, it should be treated as a practical helper for common prompts rather than a guarantee that every permission dialog will be handled. +The watchdog only polls while the app under test is running, so a prompt that appears on the home screen between test files is not tapped. + +#### Reaction time + +Each poll is a round trip through the UI test runner and takes roughly four seconds, so a prompt can stay on screen for up to about four seconds before it is dismissed. Tests that assert on post-permission state should allow for that. + +Set `HARNESS_PERMISSION_WATCHDOG_INTERVAL_MS` to change the gap between polls (default `1000`). The older `HARNESS_XCTEST_AGENT_TICK_INTERVAL_MS` name is still accepted. + +#### Device conflicts + +Harness runs its own `agent-device` daemon under `~/.agent-device/harness/`, but device ownership is shared across the whole machine. If you have your own `agent-device` session open on the same simulator or device, Harness fails with a `DEVICE_IN_USE` error naming the owning session and never takes the device over. Close that session, or point Harness at a different device. + +#### External and hosted runners + +`HARNESS_IOS_XCTESTRUN_FILE` and `HARNESS_IOS_XCTEST_DERIVED_DATA_PATH` still let you inject a prebuilt UI test artifact, for example on a hosted device farm. + +The artifact must now be an `AgentDeviceRunner` build. Produce one with: + +```bash +npx agent-device prepare ios-runner --platform ios --udid --json +``` + +and use the `xctestrunPath` it prints. Artifacts built by the removed `harness xctest build` command no longer work. + ### Android On Android, Harness inspects the installed app and grants only the permissions that meet both of these conditions: @@ -61,7 +95,7 @@ In practice, apps commonly end up having permissions such as camera, microphone, ## Performance Impact -Enabling `permissions` can make Harness heavier on iOS because it starts an additional XCTest agent session alongside the normal run. +Enabling `permissions` makes Harness heavier on iOS because it prepares and attaches a UI test runner alongside the normal run. This can have a negative impact on: @@ -69,7 +103,9 @@ This can have a negative impact on: - device preparation time - overall run performance -The impact is most important to keep in mind for iOS physical devices, where the XCTest agent requires code signing and extra setup, but the same helper is also started on iOS simulators when permissions are enabled. +The runner build is cached in `.harness/cache/agent-device-runner` and is reused across runs, so only the first run after an Xcode or `agent-device` upgrade pays for a full build. Attaching a cached runner to a booted simulator costs a few seconds per run. + +The impact is most important to keep in mind for iOS physical devices, where the runner requires code signing and extra setup, but the same helper is also started on iOS simulators when permissions are enabled. ## Physical iOS Devices @@ -85,10 +121,25 @@ applePlatform({ }) ``` -If `permissions: true` is set without `device.codeSign`, Harness skips the XCTest agent for that physical device. +If `permissions: true` is set without `device.codeSign`, Harness skips permission automation for that physical device. + +The UI test runner is now signed under the `agent-device` bundle identifiers (`com.callstack.agentdevice.runner` and its `.uitests` companion), not the old `HarnessXCTestAgent` ones. Provisioning profiles created for the old identifiers no longer apply. + +Personal teams cannot sign those identifiers. Set `codeSign.runnerBundleId` to an identifier your profile covers: + +```javascript +applePhysicalDevice('My iPhone', { + codeSign: { + teamId: 'TEAMID1234', + runnerBundleId: 'com.example.myapp.runner', + }, +}) +``` + +`codeSign.signingIdentity` and `codeSign.provisioningProfile` remain available for manual signing. ## Recommendation Leave `permissions` disabled unless your tests actually need system permission prompts to be handled automatically. -That keeps the default Harness startup path lighter and avoids paying the extra iOS XCTest overhead on runs that do not need it. +That keeps the default Harness startup path lighter and avoids paying the extra iOS UI test runner overhead on runs that do not need it. From 3bbb81b170c3d24a75c9f1802503536d5d8c9561 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 14 Sep 2026 08:58:30 +0200 Subject: [PATCH 06/12] chore: add version plans for the agent-device migration --- .../version-plan-1789368817816.md | 18 ++++++++++++++++++ .../version-plan-1789368825568.md | 8 ++++++++ 2 files changed, 26 insertions(+) create mode 100644 .nx/version-plans/version-plan-1789368817816.md create mode 100644 .nx/version-plans/version-plan-1789368825568.md diff --git a/.nx/version-plans/version-plan-1789368817816.md b/.nx/version-plans/version-plan-1789368817816.md new file mode 100644 index 00000000..b5495315 --- /dev/null +++ b/.nx/version-plans/version-plan-1789368817816.md @@ -0,0 +1,18 @@ +--- +__default__: minor +--- + +iOS permission prompt automation (`permissions: true`) is now powered by +agent-device instead of a Harness-maintained XCTest agent. Behavior is +unchanged: Harness watches for system permission prompts while your app is +running and taps the first known positive button, and it still requires +`device.codeSign` on physical devices. Three things change for existing +setups. Prompts are now dismissed within about four seconds instead of about +one, because each check is a round trip through the UI test runner. Physical +device provisioning profiles must cover the agent-device runner bundle +identifiers; personal teams can point the runner at their own identifier +with the new `device.codeSign.runnerBundleId` option. Prebuilt `.xctestrun` +artifacts supplied through `HARNESS_IOS_XCTESTRUN_FILE` must be rebuilt with +`agent-device prepare ios-runner`, and the `harness xctest build` command is +removed. The GitHub Action now caches the agent-device runner build and +requires a matching `react-native-harness` install. diff --git a/.nx/version-plans/version-plan-1789368825568.md b/.nx/version-plans/version-plan-1789368825568.md new file mode 100644 index 00000000..c7831c05 --- /dev/null +++ b/.nx/version-plans/version-plan-1789368825568.md @@ -0,0 +1,8 @@ +--- +__default__: minor +--- + +Every Harness package now declares a Node.js 22.12 floor in its `engines` +field. Harness already required a recent Node.js in practice; this makes the +requirement explicit so installs on older runtimes fail early instead of at +runtime. From dc1f2a40f668451b4957cfdca4c0f123508ebf7a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 14 Sep 2026 09:09:03 +0200 Subject: [PATCH 07/12] fix(platform-apple): bind the permission watchdog to a SpringBoard session 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. --- .../src/__tests__/permission-agent.test.ts | 12 ++++++++++++ packages/platform-ios/src/permission-agent.ts | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/platform-ios/src/__tests__/permission-agent.test.ts b/packages/platform-ios/src/__tests__/permission-agent.test.ts index 03ca682f..f4d03f7c 100644 --- a/packages/platform-ios/src/__tests__/permission-agent.test.ts +++ b/packages/platform-ios/src/__tests__/permission-agent.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ alert: vi.fn(), press: vi.fn(), prepare: vi.fn(), + open: vi.fn(), spawn: vi.fn(), })); @@ -76,11 +77,13 @@ describe('iOS permission agent', () => { vi.stubEnv('AGENT_DEVICE_IOS_BUNDLE_ID', ''); mocks.prepare.mockResolvedValue({}); + mocks.open.mockResolvedValue({}); mocks.alert.mockRejectedValue(alertNotFoundError()); mocks.press.mockResolvedValue({}); mocks.spawn.mockResolvedValue({ stdout: '', stderr: '' }); mocks.createAgentDeviceClient.mockReturnValue({ command: { alert: mocks.alert, prepare: mocks.prepare }, + apps: { open: mocks.open }, interactions: { press: mocks.press }, }); }); @@ -113,6 +116,15 @@ describe('iOS permission agent', () => { expect(process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH).toBe( path.join(projectRoot, '.harness', 'cache', 'agent-device-runner') ); + // Bound to SpringBoard, never to the app under test: `press` refuses to + // run without an open session, and Harness still owns the app lifecycle. + expect(mocks.open).toHaveBeenCalledWith( + expect.objectContaining({ + app: 'com.apple.springboard', + platform: 'ios', + udid: 'sim-udid', + }) + ); await agent.dispose(); }); diff --git a/packages/platform-ios/src/permission-agent.ts b/packages/platform-ios/src/permission-agent.ts index 3696172b..c39db2d1 100644 --- a/packages/platform-ios/src/permission-agent.ts +++ b/packages/platform-ios/src/permission-agent.ts @@ -37,6 +37,15 @@ const IOS_BUNDLE_ID_ENV = 'AGENT_DEVICE_IOS_BUNDLE_ID'; const RUNNER_CACHE_ARTIFACT_NAME = 'agent-device-runner'; const AGENT_DEVICE_SESSION_NAME = 'harness'; +/** + * The session is bound to SpringBoard, never to the app under test: Harness + * keeps sole ownership of launching, killing and relaunching that app. + * agent-device needs *some* open session because `interactions.press` refuses + * to run session-less (`SESSION_NOT_FOUND`), unlike `command.alert`, which + * attaches an implicit runner session per call. Binding to SpringBoard also + * matches where blocking system modals actually live. + */ +const SPRINGBOARD_BUNDLE_ID = 'com.apple.springboard'; const DEFAULT_WATCHDOG_INTERVAL_MS = 1000; /** * One `alert get` round trip costs ~4 s against a normal app screen, so the @@ -601,6 +610,12 @@ export const createIosPermissionAgent = ( udid: target.udid, timeoutMs: PREPARE_COMMAND_TIMEOUT_MS, }); + await client.apps.open({ + app: SPRINGBOARD_BUNDLE_ID, + platform: 'ios', + udid: target.udid, + timeoutMs: PREPARE_COMMAND_TIMEOUT_MS, + }); } catch (error) { if (isDeviceInUseError(error)) { throw createDeviceInUseError(error); From 4c86ec67713626caf1676f297d87de07b9de1a7c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 14 Sep 2026 09:29:25 +0200 Subject: [PATCH 08/12] fix(platform-apple): bound and harden the iOS permission watchdog - 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. --- .github/workflows/e2e-tests.yml | 26 ++ .../version-plan-1789368817816.md | 8 +- ...-replace-xctest-agent-with-agent-device.md | 35 ++ packages/cli/src/ci/plan-ios-runner-cache.ts | 19 +- packages/platform-ios/package.json | 2 +- .../src/__tests__/permission-agent.test.ts | 137 +++++- .../__tests__/permissions-disabled.test.ts | 85 ++++ packages/platform-ios/src/instance.ts | 2 + packages/platform-ios/src/permission-agent.ts | 440 +++++++++++++----- pnpm-lock.yaml | 12 +- 10 files changed, 641 insertions(+), 125 deletions(-) create mode 100644 packages/platform-ios/src/__tests__/permissions-disabled.test.ts diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 7b0076bf..b392fa9d 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -213,6 +213,19 @@ jobs: echo "HARNESS_RUNNER=$HARNESS_RUNNER" echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE" + # ADR-0001 merge gate: a Harness run must leave no agent-device daemon, + # xcodebuild or AgentDeviceRunner process behind, because those hold the + # host-global device claim and would block the next job on this runner. + - name: Verify no agent-device processes survived + if: always() + shell: bash + run: | + if pgrep -fl 'AgentDeviceRunner|xcodebuild test-without-building|agent-device/dist/src/internal/daemon'; then + echo "ERROR: agent-device processes survived the Harness run" + exit 1 + fi + echo "No agent-device, xcodebuild or AgentDeviceRunner process survived the run" + - name: Upload Harness logs if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -544,6 +557,19 @@ jobs: echo "Crash report artifacts found:" find "$CRASH_DIR" -mindepth 4 -type f -print + # ADR-0001 merge gate: a Harness run must leave no agent-device daemon, + # xcodebuild or AgentDeviceRunner process behind, because those hold the + # host-global device claim and would block the next job on this runner. + - name: Verify no agent-device processes survived + if: always() + shell: bash + run: | + if pgrep -fl 'AgentDeviceRunner|xcodebuild test-without-building|agent-device/dist/src/internal/daemon'; then + echo "ERROR: agent-device processes survived the Harness run" + exit 1 + fi + echo "No agent-device, xcodebuild or AgentDeviceRunner process survived the run" + - name: Upload Harness logs if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/.nx/version-plans/version-plan-1789368817816.md b/.nx/version-plans/version-plan-1789368817816.md index b5495315..4ed6091f 100644 --- a/.nx/version-plans/version-plan-1789368817816.md +++ b/.nx/version-plans/version-plan-1789368817816.md @@ -14,5 +14,9 @@ identifiers; personal teams can point the runner at their own identifier with the new `device.codeSign.runnerBundleId` option. Prebuilt `.xctestrun` artifacts supplied through `HARNESS_IOS_XCTESTRUN_FILE` must be rebuilt with `agent-device prepare ios-runner`, and the `harness xctest build` command is -removed. The GitHub Action now caches the agent-device runner build and -requires a matching `react-native-harness` install. +removed, along with the `buildXCTestAgent` export from +`@react-native-harness/platform-apple` and its `BuildXCTestAgentOptions`, +`BuildXCTestAgentResult`, `XCTestAgentBuildDestination` and +`XCTestAgentBuildSigning` types. The GitHub Action now caches the +agent-device runner build and requires a matching `react-native-harness` +install. diff --git a/docs/internal/adr/0001-replace-xctest-agent-with-agent-device.md b/docs/internal/adr/0001-replace-xctest-agent-with-agent-device.md index aef15622..33f74979 100644 --- a/docs/internal/adr/0001-replace-xctest-agent-with-agent-device.md +++ b/docs/internal/adr/0001-replace-xctest-agent-with-agent-device.md @@ -200,3 +200,38 @@ Also expected: - Physical devices: signing via `AGENT_DEVICE_IOS_*` env (`DEVELOPMENT_TEAM`, `CODE_SIGN_STYLE` Manual/Automatic). Session-less `alert`/`press` were verified on a simulator only; the physical path is merge gate 2. + +## Amendments + +Corrections and additions found while implementing this ADR. The sections +above are left as accepted; these entries take precedence where they differ. + +### 2026-09-14 — `press` requires an open session + +"Verified facts" records that session-less `alert`/`press` were verified on a +simulator. That holds for `command.alert`, which attaches an implicit runner +session per call, but **not** for `interactions.press`: with no open session it +fails `SESSION_NOT_FOUND` ("No active session. Run open first."), with +`--udid`, `--device` and `--target` selectors alike. The original experiment +only exercised `press` while a SpringBoard session happened to be open. + +Decision point 3 ("Harness never calls `apps.open`") is therefore amended: +after `command.prepare({ action: 'ios-runner' })`, Harness opens exactly one +session bound to `com.apple.springboard`. It never opens, launches, kills or +relaunches the app under test through agent-device; that lifecycle stays +entirely with `simctl`/`devicectl`, as decided. SpringBoard is also where the +blocking system modals the watchdog taps actually live. + +### 2026-09-14 — the runner cache key runs `xcodebuild -version` + +Decision point 7 keys the action.yml cache on the `agent-device` version plus +`xcodebuild -version`. The `harness ci plan-ios-runner-cache` helper that +computes the key therefore shells out to `xcodebuild -version` (bounded, and +degrading to a disabled cache with a CI warning annotation if it fails), which +the ADR did not call out as a CLI dependency. + +### 2026-09-14 — positive-label list gained `Allow While Using App` + +The list is described as carried over from the Swift watchdog. One label is +new: `Allow While Using App`, the middle button on the iOS 26 three-button +location sheet, which the old list did not cover. diff --git a/packages/cli/src/ci/plan-ios-runner-cache.ts b/packages/cli/src/ci/plan-ios-runner-cache.ts index c3ee7d5d..9fe6c0ce 100644 --- a/packages/cli/src/ci/plan-ios-runner-cache.ts +++ b/packages/cli/src/ci/plan-ios-runner-cache.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import { createRequire } from 'node:module'; import { getFs } from '@react-native-harness/tools/harness-context'; -import { spawn } from '@react-native-harness/tools'; +import { runCommand } from '@react-native-harness/tools'; import { resolveProjectRoot } from './workspace-root.js'; /** @@ -57,9 +57,14 @@ export const resolveAgentDeviceVersion = ( } }; +const XCODE_VERSION_TIMEOUT_MS = 60_000; + const getXcodeVersion = async (): Promise => { try { - const { stdout } = await spawn('xcodebuild', ['-version']); + const { stdout } = await runCommand('xcodebuild', ['-version'], { + signal: AbortSignal.timeout(XCODE_VERSION_TIMEOUT_MS), + timeoutMs: XCODE_VERSION_TIMEOUT_MS, + }); return stdout.trim(); } catch { @@ -87,8 +92,14 @@ export const runPlanIosRunnerCache = async (): Promise => { const xcodeVersion = await getXcodeVersion(); if (!agentDeviceVersion || !xcodeVersion) { - console.info( - 'Skipping the agent-device runner cache: could not resolve the installed agent-device version or the Xcode version.' + const missing = !agentDeviceVersion + ? 'the installed agent-device version' + : 'the Xcode version'; + + // An annotation, not a failure: the run still works, it just rebuilds + // the runner every time, which is worth seeing in the checks UI. + console.log( + `::warning::agent-device runner caching is disabled for this run: could not resolve ${missing}. Every run will rebuild the iOS UI test runner.` ); getFs().appendFileSync(githubOutput, 'iosRunnerCacheKey=\n'); diff --git a/packages/platform-ios/package.json b/packages/platform-ios/package.json index daee4098..a78c18dc 100644 --- a/packages/platform-ios/package.json +++ b/packages/platform-ios/package.json @@ -19,7 +19,7 @@ "@react-native-harness/config": "workspace:*", "@react-native-harness/platforms": "workspace:*", "@react-native-harness/tools": "workspace:*", - "agent-device": "~0.21.0", + "agent-device": "0.21.0", "tslib": "^2.3.0", "zod": "^3.25.67" }, diff --git a/packages/platform-ios/src/__tests__/permission-agent.test.ts b/packages/platform-ios/src/__tests__/permission-agent.test.ts index f4d03f7c..985fdfa5 100644 --- a/packages/platform-ios/src/__tests__/permission-agent.test.ts +++ b/packages/platform-ios/src/__tests__/permission-agent.test.ts @@ -9,7 +9,8 @@ const mocks = vi.hoisted(() => ({ press: vi.fn(), prepare: vi.fn(), open: vi.fn(), - spawn: vi.fn(), + closeSession: vi.fn(), + runCommand: vi.fn(), })); vi.mock('agent-device', () => ({ @@ -20,15 +21,17 @@ vi.mock('@react-native-harness/tools', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, spawn: mocks.spawn }; + return { ...actual, runCommand: mocks.runCommand }; }); const { assertSupportedNodeVersion, + buildLabelSelector, copyAgentDeviceLogs, createIosPermissionAgent, getAgentDeviceStateDir, getPermissionWatchdogIntervalMs, + getPrepareTimeoutMs, } = await import('../permission-agent.js'); const createAgentDeviceError = ( @@ -80,10 +83,12 @@ describe('iOS permission agent', () => { mocks.open.mockResolvedValue({}); mocks.alert.mockRejectedValue(alertNotFoundError()); mocks.press.mockResolvedValue({}); - mocks.spawn.mockResolvedValue({ stdout: '', stderr: '' }); + mocks.closeSession.mockResolvedValue({}); + mocks.runCommand.mockResolvedValue({ stdout: '', stderr: '' }); mocks.createAgentDeviceClient.mockReturnValue({ command: { alert: mocks.alert, prepare: mocks.prepare }, apps: { open: mocks.open }, + sessions: { close: mocks.closeSession }, interactions: { press: mocks.press }, }); }); @@ -299,7 +304,7 @@ describe('iOS permission agent', () => { await agent.dispose(); - expect(mocks.spawn).toHaveBeenCalledWith( + expect(mocks.runCommand).toHaveBeenCalledWith( process.execPath, expect.arrayContaining([ 'daemon', @@ -349,6 +354,127 @@ describe('iOS permission agent', () => { await agent.dispose(); }); + it('never overlaps two runner commands', async () => { + let inFlight = 0; + let maxInFlight = 0; + mocks.alert.mockImplementation(async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 40)); + inFlight -= 1; + throw alertNotFoundError(); + }); + + const agent = createAgent(); + await agent.prepare(); + agent.setAppRunning(true); + + await vi.waitFor(() => + expect(mocks.alert.mock.calls.length).toBeGreaterThan(2) + ); + + expect(maxInFlight).toBe(1); + + await agent.dispose(); + }); + + it('stops the daemon without waiting out an in-flight poll', async () => { + mocks.alert.mockImplementation( + () => + new Promise((_resolve, reject) => { + setTimeout(() => reject(alertNotFoundError()), 5000); + }) + ); + + const agent = createAgent(); + await agent.prepare(); + agent.setAppRunning(true); + + await vi.waitFor(() => expect(mocks.alert).toHaveBeenCalled()); + + const startedAt = Date.now(); + await agent.dispose(); + const elapsed = Date.now() - startedAt; + + // The daemon stop runs immediately; only the bounded drain is waited on, + // never the poll's own 20 s budget. + expect(mocks.runCommand).toHaveBeenCalled(); + expect(elapsed).toBeLessThan(4000); + }); + + it('gives up after three consecutive unclassified failures', async () => { + mocks.alert.mockRejectedValue( + createAgentDeviceError('RUNNER_CRASHED', 'the runner exited', { + hint: 'Check runner.log.', + }) + ); + + const agent = createAgent(); + await agent.prepare(); + agent.setAppRunning(true); + + await vi.waitFor(() => + expect(mocks.alert.mock.calls.length).toBe(3) + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // The loop is gone; no fourth attempt is ever made. + expect(mocks.alert.mock.calls.length).toBe(3); + + await agent.dispose(); + }); + + it('taps a padded label using its trimmed form', async () => { + mocks.alert.mockResolvedValue({ + message: 'Allow access?', + items: [' Allow ', 'Don’t Allow'], + }); + + const agent = createAgent(); + await agent.prepare(); + agent.setAppRunning(true); + + await vi.waitFor(() => expect(mocks.press).toHaveBeenCalled()); + + expect(mocks.press).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'label="Allow"' }) + ); + + await agent.dispose(); + }); + + it('refuses to build a selector for a label with a quote or backslash', () => { + expect(buildLabelSelector('Allow')).toBe('label="Allow"'); + expect(buildLabelSelector('Allow "Maps"')).toBeUndefined(); + expect(buildLabelSelector('Allow\\Once')).toBeUndefined(); + }); + + it('derives the prepare budget from platformReadyTimeout', () => { + expect(getPrepareTimeoutMs(undefined)).toBe(240_000); + expect(getPrepareTimeoutMs(300_000)).toBe(240_000); + expect(getPrepareTimeoutMs(600_000)).toBe(480_000); + // Never collapses to something unusably small. + expect(getPrepareTimeoutMs(1_000)).toBe(30_000); + }); + + it('keeps user-provided signing environment variables', async () => { + vi.stubEnv('AGENT_DEVICE_IOS_TEAM_ID', 'USERTEAM99'); + + const agent = createAgent({ + target: { + kind: 'device', + udid: 'device-udid', + codeSign: { teamId: 'TEAMID1234' }, + }, + }); + + await agent.prepare(); + + expect(process.env.AGENT_DEVICE_IOS_TEAM_ID).toBe('USERTEAM99'); + + await agent.dispose(); + }); + it('rejects Node versions below the agent-device floor', () => { expect(() => assertSupportedNodeVersion('22.11.0')).toThrow( /requires Node\.js 22\.12 or newer/ @@ -358,6 +484,9 @@ describe('iOS permission agent', () => { ); expect(() => assertSupportedNodeVersion('22.12.0')).not.toThrow(); expect(() => assertSupportedNodeVersion('24.1.0')).not.toThrow(); + // A newer major qualifies whatever its minor is. + expect(() => assertSupportedNodeVersion('24.0.0')).not.toThrow(); + expect(() => assertSupportedNodeVersion('23')).not.toThrow(); }); it('reads the watchdog interval from either environment variable', () => { diff --git a/packages/platform-ios/src/__tests__/permissions-disabled.test.ts b/packages/platform-ios/src/__tests__/permissions-disabled.test.ts new file mode 100644 index 00000000..ab100e84 --- /dev/null +++ b/packages/platform-ios/src/__tests__/permissions-disabled.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_METRO_PORT, + type Config as HarnessConfig, +} from '@react-native-harness/config'; +import * as simctl from '../xcrun/simctl.js'; + +// Deliberately NOT mocking '../permission-agent.js': this asserts that the +// real module never reaches agent-device when permissions are off, which a +// wholesale mock of the agent would hide. +const mocks = vi.hoisted(() => ({ + createAgentDeviceClient: vi.fn(), + runCommand: vi.fn(async () => ({ stdout: '', stderr: '' })), +})); + +vi.mock('agent-device', () => ({ + createAgentDeviceClient: mocks.createAgentDeviceClient, +})); + +// Keeps teardown from shelling out to the real agent-device CLI. +vi.mock('@react-native-harness/tools', async (importOriginal) => { + const actual = + await importOriginal(); + + return { ...actual, runCommand: mocks.runCommand }; +}); + +const { getAppleSimulatorPlatformInstance } = await import('../instance.js'); + +const simulatorConfig = { + name: 'ios', + device: { + type: 'simulator' as const, + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', +}; + +describe('iOS permission automation opt-out', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); + vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + }); + + it('never creates an agent-device client when permissions are disabled', async () => { + const instance = await getAppleSimulatorPlatformInstance( + simulatorConfig, + { metroPort: DEFAULT_METRO_PORT } as HarnessConfig, + { signal: new AbortController().signal } + ); + + await instance.dispose(); + + expect(mocks.createAgentDeviceClient).not.toHaveBeenCalled(); + }); + + it('creates an agent-device client when permissions are enabled', async () => { + mocks.createAgentDeviceClient.mockReturnValue({ + command: { alert: vi.fn(), prepare: vi.fn(async () => ({})) }, + apps: { open: vi.fn(async () => ({})) }, + sessions: { close: vi.fn(async () => ({})) }, + interactions: { press: vi.fn() }, + }); + + const instance = await getAppleSimulatorPlatformInstance( + simulatorConfig, + { metroPort: DEFAULT_METRO_PORT, permissions: true } as HarnessConfig, + { signal: new AbortController().signal } + ); + + expect(mocks.createAgentDeviceClient).toHaveBeenCalledTimes(1); + + await instance.dispose(); + }); +}); diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index e1222dd8..e478733a 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -83,6 +83,7 @@ export const getAppleSimulatorPlatformInstance = async ( kind: 'simulator', udid, }, + platformReadyTimeoutMs: harnessConfig.platformReadyTimeout, }) : null; @@ -260,6 +261,7 @@ export const getApplePhysicalDevicePlatformInstance = async ( udid: device.hardwareProperties.udid, codeSign: config.device.codeSign, }, + platformReadyTimeoutMs: harnessConfig.platformReadyTimeout, }) : null; diff --git a/packages/platform-ios/src/permission-agent.ts b/packages/platform-ios/src/permission-agent.ts index c39db2d1..d051f93c 100644 --- a/packages/platform-ios/src/permission-agent.ts +++ b/packages/platform-ios/src/permission-agent.ts @@ -8,7 +8,7 @@ import { delay as cancellableDelay, getHarnessCacheArtifactPath, logger, - spawn, + runCommand, waitForAbort, } from '@react-native-harness/tools'; import type { ApplePhysicalDeviceCodeSign } from './config.js'; @@ -53,13 +53,25 @@ const DEFAULT_WATCHDOG_INTERVAL_MS = 1000; * is treated as a (transient) timeout. */ const ALERT_COMMAND_TIMEOUT_MS = 20_000; -const PREPARE_COMMAND_TIMEOUT_MS = 600_000; +/** + * Fallback prepare budget when the runner's `platformReadyTimeout` is unknown. + * Deliberately below the 300 s default so Harness's own platform-ready timeout + * is what surfaces, not a stuck agent-device command. + */ +const DEFAULT_PREPARE_TIMEOUT_MS = 240_000; +const MINIMUM_PREPARE_TIMEOUT_MS = 30_000; +const SESSION_CLOSE_TIMEOUT_MS = 30_000; const DAEMON_STOP_TIMEOUT_MS = 60_000; +/** How long dispose() waits for an in-flight poll to unwind after the stop. */ +const WATCHDOG_DRAIN_TIMEOUT_MS = 2_000; +/** Consecutive unclassified failures before the watchdog gives up. */ +const MAX_CONSECUTIVE_UNKNOWN_FAILURES = 3; /** - * Buttons the watchdog is allowed to tap, in priority order. Carried over - * verbatim from the removed in-process XCTest watchdog, which tapped the first - * of these labels present on the prompt. + * Buttons the watchdog is allowed to tap, in priority order. Carried over from + * the removed in-process XCTest watchdog, which tapped the first of these + * labels present on the prompt. `Allow While Using App` is new: it is the label + * iOS 26 uses on the three-button location sheet. */ const POSITIVE_BUTTON_LABELS = [ 'Allow', @@ -88,6 +100,11 @@ export type IosPermissionAgentOptions = { appBundleId?: string; target: IosPermissionAgentTarget; projectRoot?: string; + /** + * The runner's `platformReadyTimeout`. The prepare budget is derived from it + * so a stuck agent-device command never outlives platform startup. + */ + platformReadyTimeoutMs?: number; }; export type IosPermissionAgent = { @@ -108,17 +125,18 @@ export type IosPermissionAgent = { export const assertSupportedNodeVersion = ( nodeVersion = process.versions.node ): void => { - const [major, minor] = nodeVersion + const [rawMajor, rawMinor] = nodeVersion .split('.') .map((part) => Number.parseInt(part, 10)); + const major = Number.isFinite(rawMajor) ? (rawMajor as number) : Number.NaN; + const minor = Number.isFinite(rawMinor) ? (rawMinor as number) : 0; - if ( - Number.isFinite(major) && - Number.isFinite(minor) && - ((major as number) > MINIMUM_NODE_MAJOR || - ((major as number) === MINIMUM_NODE_MAJOR && - (minor as number) >= MINIMUM_NODE_MINOR)) - ) { + // A newer major always qualifies, whatever its minor is. + if (major > MINIMUM_NODE_MAJOR) { + return; + } + + if (major === MINIMUM_NODE_MAJOR && minor >= MINIMUM_NODE_MINOR) { return; } @@ -156,6 +174,25 @@ export const getPermissionWatchdogIntervalMs = (): number => { return parsed; }; +export const getPrepareTimeoutMs = ( + platformReadyTimeoutMs?: number +): number => { + if ( + platformReadyTimeoutMs === undefined || + !Number.isFinite(platformReadyTimeoutMs) || + platformReadyTimeoutMs <= 0 + ) { + return DEFAULT_PREPARE_TIMEOUT_MS; + } + + // Stay clear of the platform-ready deadline so Harness's own timeout, with + // its actionable message, is the one that fires first. + return Math.max( + MINIMUM_PREPARE_TIMEOUT_MS, + Math.floor(platformReadyTimeoutMs * 0.8) + ); +}; + /** * Harness runs its own agent-device daemon so it never replaces, or inherits * stale signing environment from, a developer's own daemon. Device claims are @@ -191,10 +228,11 @@ const getExistingPath = (name: string): string | undefined => { /** * The agent-device daemon is spawned lazily by the first client request and - * inherits that process's environment. agent-device 0.21.0 exposes no - * `daemon start` subcommand that could be handed a dedicated environment - * (`agent-device help daemon` documents `daemon stop` only), so the variables - * the daemon needs are written to `process.env` before the client is created. + * inherits that process's environment for its whole lifetime. agent-device + * 0.21.0 exposes no `daemon start` subcommand that could be handed a dedicated + * environment (`agent-device help daemon` documents `daemon stop` only), so the + * variables the daemon needs are written to `process.env` before the client is + * created, and a value the user already set is never overwritten. */ const applyDaemonEnvironment = ({ runnerDerivedDataPath, @@ -203,9 +241,24 @@ const applyDaemonEnvironment = ({ runnerDerivedDataPath: string; target: IosPermissionAgentTarget; }): void => { - if (getTrimmedEnvironmentValue(RUNNER_DERIVED_PATH_ENV) === undefined) { - process.env[RUNNER_DERIVED_PATH_ENV] = runnerDerivedDataPath; - } + const setIfUnset = (name: string, value: string | undefined) => { + if (value === undefined || value.length === 0) { + return; + } + + if (getTrimmedEnvironmentValue(name) !== undefined) { + permissionAgentLogger.debug( + 'keeping the existing %s from the environment', + name + ); + + return; + } + + process.env[name] = value; + }; + + setIfUnset(RUNNER_DERIVED_PATH_ENV, runnerDerivedDataPath); if (target.kind !== 'device' || !target.codeSign) { return; @@ -214,19 +267,10 @@ const applyDaemonEnvironment = ({ const { teamId, signingIdentity, provisioningProfile, runnerBundleId } = target.codeSign; - process.env[IOS_TEAM_ID_ENV] = teamId; - - if (signingIdentity) { - process.env[IOS_SIGNING_IDENTITY_ENV] = signingIdentity; - } - - if (provisioningProfile) { - process.env[IOS_PROVISIONING_PROFILE_ENV] = provisioningProfile; - } - - if (runnerBundleId) { - process.env[IOS_BUNDLE_ID_ENV] = runnerBundleId; - } + setIfUnset(IOS_TEAM_ID_ENV, teamId); + setIfUnset(IOS_SIGNING_IDENTITY_ENV, signingIdentity); + setIfUnset(IOS_PROVISIONING_PROFILE_ENV, provisioningProfile); + setIfUnset(IOS_BUNDLE_ID_ENV, runnerBundleId); }; const getErrorCode = (error: unknown): string | undefined => { @@ -251,9 +295,23 @@ const getErrorDetails = (error: unknown): Record => { return {}; }; +const getErrorHint = (error: unknown): string | undefined => { + const { hint } = getErrorDetails(error); + + return typeof hint === 'string' && hint.length > 0 ? hint : undefined; +}; + +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + const isAlertNotFoundError = (error: unknown): boolean => getErrorDetails(error).runnerErrorCode === ALERT_NOT_FOUND_RUNNER_ERROR_CODE; +/** + * Only these three are expected during a healthy run: no prompt is showing, the + * runner is still finishing the previous command, or the round trip ran long. + * Everything else is reported, not swallowed. + */ const isTransientWatchdogError = (error: unknown): boolean => { if (isAlertNotFoundError(error)) { return true; @@ -269,9 +327,7 @@ const isTransientWatchdogError = (error: unknown): boolean => { return true; } - const message = error instanceof Error ? error.message : String(error); - - return /timed out|timeout/i.test(message); + return /timed out|timeout/i.test(getErrorMessage(error)); }; export const isDeviceInUseError = (error: unknown): boolean => @@ -282,13 +338,12 @@ export const isDeviceInUseError = (error: unknown): boolean => * usually the developer's own, and quietly taking it over would break it. */ const createDeviceInUseError = (error: unknown): Error => { - const message = error instanceof Error ? error.message : String(error); - const hint = getErrorDetails(error).hint; + const hint = getErrorHint(error); const lines = [ - `agent-device cannot claim this iOS device for permission automation: ${message}`, + `agent-device cannot claim this iOS device for permission automation: ${getErrorMessage(error)}`, ]; - if (typeof hint === 'string' && hint.length > 0) { + if (hint) { lines.push(hint); } @@ -299,18 +354,35 @@ const createDeviceInUseError = (error: unknown): Error => { return new Error(lines.join('\n')); }; -const findLabelToTap = (items: readonly string[]): string | undefined => { +export const findLabelToTap = ( + items: readonly string[] +): string | undefined => { for (const positiveLabel of POSITIVE_BUTTON_LABELS) { + // The trimmed label is what gets tapped: agent-device matches the selector + // against the button's own label, and surrounding whitespace in the JSON + // payload is not part of it. const match = items.find((item) => item.trim() === positiveLabel); if (match !== undefined) { - return match; + return match.trim(); } } return undefined; }; +/** + * agent-device selectors are `key="value"` with no documented escape syntax, so + * a label carrying a quote or backslash cannot be expressed safely. + */ +export const buildLabelSelector = (label: string): string | undefined => { + if (/["\\]/.test(label)) { + return undefined; + } + + return `label="${label}"`; +}; + const getAlertItems = (result: unknown): string[] => { if (typeof result !== 'object' || result === null) { return []; @@ -392,7 +464,12 @@ export const copyAgentDeviceLogs = ({ }): string[] => { const copied: string[] = []; - if (copyIfExists(path.join(stateDir, 'daemon.log'), path.join(targetDirectory, 'daemon.log'))) { + if ( + copyIfExists( + path.join(stateDir, 'daemon.log'), + path.join(targetDirectory, 'daemon.log') + ) + ) { copied.push('daemon.log'); } @@ -431,17 +508,47 @@ export const createIosPermissionAgent = ( const { target } = options; const stateDir = getAgentDeviceStateDir(projectRoot); const intervalMs = getPermissionWatchdogIntervalMs(); + const prepareTimeoutMs = getPrepareTimeoutMs(options.platformReadyTimeoutMs); + const daemonStopHint = `agent-device daemon stop --state-dir ${stateDir} --clean`; const watchdogAbortController = new AbortController(); let client: AgentDeviceClient | null = null; let watchdogTask: Promise | null = null; let appRunning = false; + let consecutiveUnknownFailures = 0; let disposed = false; let disposePromise: Promise | undefined; - const pollOnce = async (): Promise => { + /** Reports an unclassified failure; returns true when the loop must stop. */ + const recordUnknownFailure = (context: string, error: unknown): boolean => { + consecutiveUnknownFailures += 1; + const code = getErrorCode(error) ?? 'UNKNOWN'; + const hint = getErrorHint(error); + + permissionAgentLogger.warn( + 'iOS permission watchdog %s failed (%s): %s%s', + context, + code, + getErrorMessage(error), + hint ? ` ${hint}` : '' + ); + + if (consecutiveUnknownFailures < MAX_CONSECUTIVE_UNKNOWN_FAILURES) { + return false; + } + + permissionAgentLogger.warn( + 'iOS permission automation is disabled for the rest of this run after %d consecutive failures. System permission prompts will no longer be dismissed automatically.', + consecutiveUnknownFailures + ); + + return true; + }; + + /** Resolves false when the watchdog must stop. */ + const pollOnce = async (): Promise => { if (!client) { - return; + return false; } let alertResult: unknown; @@ -459,19 +566,20 @@ export const createIosPermissionAgent = ( } if (isTransientWatchdogError(error)) { + consecutiveUnknownFailures = 0; permissionAgentLogger.debug( 'permission watchdog poll skipped: %s', - error instanceof Error ? error.message : String(error) + getErrorMessage(error) ); - return; + return true; } - permissionAgentLogger.debug('permission watchdog poll failed', error); - - return; + return !recordUnknownFailure('poll', error); } + consecutiveUnknownFailures = 0; + const items = getAlertItems(alertResult); const label = findLabelToTap(items); @@ -481,7 +589,18 @@ export const createIosPermissionAgent = ( items.join(', ') ); - return; + return true; + } + + const selector = buildLabelSelector(label); + + if (selector === undefined) { + permissionAgentLogger.warn( + 'cannot tap the permission prompt button %j: its label contains a character agent-device selectors cannot express.', + label + ); + + return true; } permissionAgentLogger.debug( @@ -494,19 +613,27 @@ export const createIosPermissionAgent = ( await client.interactions.press({ platform: 'ios', udid: target.udid, - selector: `label="${label}"`, + selector, }); } catch (error) { if (isDeviceInUseError(error)) { throw createDeviceInUseError(error); } - permissionAgentLogger.debug( - 'failed to tap "%s" on the permission prompt', - label, - error - ); + if (isTransientWatchdogError(error)) { + permissionAgentLogger.debug( + 'tapping "%s" was skipped: %s', + label, + getErrorMessage(error) + ); + + return true; + } + + return !recordUnknownFailure(`tap of "${label}"`, error); } + + return true; }; // Serialised on purpose: the runner handles one command at a time, and @@ -516,16 +643,22 @@ export const createIosPermissionAgent = ( while (!signal.aborted) { if (appRunning) { + let shouldContinue: boolean; + try { - await pollOnce(); + shouldContinue = await pollOnce(); } catch (error) { permissionAgentLogger.error( 'stopping the iOS permission watchdog: %s', - error instanceof Error ? error.message : String(error) + getErrorMessage(error) ); return; } + + if (!shouldContinue) { + return; + } } if (signal.aborted) { @@ -548,19 +681,47 @@ export const createIosPermissionAgent = ( } }; + const closeSession = async (): Promise => { + if (!client) { + return; + } + + // `sessions.close` takes no timeout of its own, and the daemon stop below + // is the real guarantee, so this wait is bounded and best-effort. + const timeout = cancellableDelay(SESSION_CLOSE_TIMEOUT_MS); + + try { + await Promise.race([ + client.sessions.close().catch((error: unknown) => { + permissionAgentLogger.debug( + 'failed to close the agent-device session', + error + ); + }), + timeout.promise, + ]); + } finally { + timeout.cancel(); + } + }; + const stopDaemon = async (): Promise => { let binPath: string; try { binPath = resolveAgentDeviceBinPath(); } catch (error) { - permissionAgentLogger.debug('could not resolve the agent-device CLI', error); + permissionAgentLogger.warn( + 'could not resolve the agent-device CLI, so its daemon was left running: %s Stop it with: %s', + getErrorMessage(error), + daemonStopHint + ); return; } try { - await spawn( + await runCommand( process.execPath, [ binPath, @@ -571,10 +732,75 @@ export const createIosPermissionAgent = ( '--clean', '--json', ], - { timeout: DAEMON_STOP_TIMEOUT_MS } + { + signal: AbortSignal.timeout(DAEMON_STOP_TIMEOUT_MS), + timeoutMs: DAEMON_STOP_TIMEOUT_MS, + } + ); + } catch (error) { + permissionAgentLogger.warn( + 'failed to stop the agent-device daemon: %s An xcodebuild or AgentDeviceRunner process may still hold the device claim. Stop it with: %s', + getErrorMessage(error), + daemonStopHint ); + } + }; + + const collectLogs = (): void => { + try { + const logArtifacts = createHarnessArtifactDirectory({ + artifactType: 'logs', + bundleId: options.appBundleId, + platformId: 'ios', + runnerName: `permission-agent-${target.kind}`, + }); + const copied = copyAgentDeviceLogs({ + stateDir, + targetDirectory: logArtifacts.directoryPath, + }); + + if (copied.length > 0) { + permissionAgentLogger.debug( + 'copied agent-device logs (%s) to %s', + copied.join(', '), + logArtifacts.directoryPath + ); + } + } catch (error) { + permissionAgentLogger.debug('failed to copy agent-device logs', error); + } + }; + + /** + * agent-device commands take no abort signal, so cancellation is a race: the + * caller stops waiting and the daemon is torn down, which kills the in-flight + * xcodebuild/AgentDeviceRunner and releases the host-global device claim. + */ + const runBounded = async ( + description: string, + work: () => Promise, + signal?: AbortSignal + ): Promise => { + if (!signal) { + return await work(); + } + + const abortWait = waitForAbort(signal); + + try { + return await Promise.race([work(), abortWait.promise]); } catch (error) { - permissionAgentLogger.debug('failed to stop the agent-device daemon', error); + if (signal.aborted) { + permissionAgentLogger.debug( + 'aborted while %s; stopping the agent-device daemon', + description + ); + await stopDaemon(); + } + + throw error; + } finally { + abortWait.cancel(); } }; @@ -598,24 +824,35 @@ export const createIosPermissionAgent = ( }); permissionAgentLogger.debug( - 'preparing the agent-device iOS runner for %s (state dir %s)', + 'preparing the agent-device iOS runner for %s (state dir %s, budget %d ms)', target.udid, - stateDir + stateDir, + prepareTimeoutMs ); try { - await client.command.prepare({ - action: 'ios-runner', - platform: 'ios', - udid: target.udid, - timeoutMs: PREPARE_COMMAND_TIMEOUT_MS, - }); - await client.apps.open({ - app: SPRINGBOARD_BUNDLE_ID, - platform: 'ios', - udid: target.udid, - timeoutMs: PREPARE_COMMAND_TIMEOUT_MS, - }); + await runBounded( + 'preparing the agent-device iOS runner', + () => + client!.command.prepare({ + action: 'ios-runner', + platform: 'ios', + udid: target.udid, + timeoutMs: prepareTimeoutMs, + }), + signal + ); + await runBounded( + 'opening the agent-device SpringBoard session', + () => + client!.apps.open({ + app: SPRINGBOARD_BUNDLE_ID, + platform: 'ios', + udid: target.udid, + timeoutMs: prepareTimeoutMs, + }), + signal + ); } catch (error) { if (isDeviceInUseError(error)) { throw createDeviceInUseError(error); @@ -624,8 +861,6 @@ export const createIosPermissionAgent = ( throw error; } - signal?.throwIfAborted(); - watchdogTask = runWatchdog(); }, setAppRunning: (running) => { @@ -641,40 +876,29 @@ export const createIosPermissionAgent = ( appRunning = false; watchdogAbortController.abort(); - if (watchdogTask) { - await watchdogTask.catch(() => undefined); - watchdogTask = null; - } - + // Tear the daemon down first: an in-flight `alert get` can still have + // most of its 20 s budget left, and stopping the daemon ends it + // immediately instead of making teardown wait it out. + await closeSession(); client = null; - await stopDaemon(); - try { - const logArtifacts = createHarnessArtifactDirectory({ - artifactType: 'logs', - bundleId: options.appBundleId, - platformId: 'ios', - runnerName: `permission-agent-${target.kind}`, - }); - const copied = copyAgentDeviceLogs({ - stateDir, - targetDirectory: logArtifacts.directoryPath, - }); - - if (copied.length > 0) { - permissionAgentLogger.debug( - 'copied agent-device logs (%s) to %s', - copied.join(', '), - logArtifacts.directoryPath - ); + if (watchdogTask) { + const drain = cancellableDelay(WATCHDOG_DRAIN_TIMEOUT_MS); + + try { + await Promise.race([ + watchdogTask.catch(() => undefined), + drain.promise, + ]); + } finally { + drain.cancel(); } - } catch (error) { - permissionAgentLogger.debug( - 'failed to copy agent-device logs', - error - ); + + watchdogTask = null; } + + collectLogs(); })()), }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56806dd5..4d62e7bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -464,8 +464,8 @@ importers: specifier: workspace:* version: link:../tools agent-device: - specifier: ~0.21.0 - version: 0.21.1 + specifier: 0.21.0 + version: 0.21.0 tslib: specifier: ^2.3.0 version: 2.8.1 @@ -3505,8 +3505,8 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - agent-device@0.21.1: - resolution: {integrity: sha512-vFhcqkQlusuOHR389h7WLhINfLdsMie3D5YPYNm8OOIjh4iy/YCQtNoKVqxQ+zeTrYZMOBxbWEHnL07/WVzTPA==} + agent-device@0.21.0: + resolution: {integrity: sha512-s/7KlieaLLemNtKlc/EWmkphRzdmNu7c3AuAPFb9OZGG6SLCPBvS0wTKJCbmfxhLMGpEQ88GhinOj/0zoqY7hg==} engines: {node: '>=22.12'} hasBin: true peerDependencies: @@ -12095,7 +12095,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(terser@5.42.0)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@22.1.0)(terser@5.42.0)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -12234,7 +12234,7 @@ snapshots: agent-base@7.1.4: {} - agent-device@0.21.1: {} + agent-device@0.21.0: {} ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: From 45dfed2f86c4593f54bb84327fa85c22e0ff3ede Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 14 Sep 2026 09:37:53 +0200 Subject: [PATCH 09/12] fix(platform-apple): rebuild a stale agent-device 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 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. --- .../src/__tests__/permission-agent.test.ts | 44 +++++++++++ packages/platform-ios/src/permission-agent.ts | 75 +++++++++++++++---- 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/packages/platform-ios/src/__tests__/permission-agent.test.ts b/packages/platform-ios/src/__tests__/permission-agent.test.ts index 985fdfa5..4eef3647 100644 --- a/packages/platform-ios/src/__tests__/permission-agent.test.ts +++ b/packages/platform-ios/src/__tests__/permission-agent.test.ts @@ -475,6 +475,50 @@ describe('iOS permission agent', () => { await agent.dispose(); }); + it('clears a stale runner cache and retries prepare once', async () => { + const runnerCache = path.join( + projectRoot, + '.harness', + 'cache', + 'agent-device-runner' + ); + fs.mkdirSync(runnerCache, { recursive: true }); + fs.writeFileSync(path.join(runnerCache, 'stale'), 'x'); + + mocks.prepare + .mockRejectedValueOnce( + createAgentDeviceError( + 'COMMAND_FAILED', + 'Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH automatically' + ) + ) + .mockResolvedValue({}); + + const agent = createAgent(); + await agent.prepare(); + + expect(mocks.prepare).toHaveBeenCalledTimes(2); + expect(fs.existsSync(runnerCache)).toBe(false); + + await agent.dispose(); + }); + + it('does not clear a user-provided runner derived path', async () => { + vi.stubEnv('AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH', '/custom/derived'); + const refusal = createAgentDeviceError( + 'COMMAND_FAILED', + 'Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH automatically' + ); + mocks.prepare.mockRejectedValue(refusal); + + const agent = createAgent(); + + await expect(agent.prepare()).rejects.toThrow(refusal); + expect(mocks.prepare).toHaveBeenCalledTimes(1); + + await agent.dispose(); + }); + it('rejects Node versions below the agent-device floor', () => { expect(() => assertSupportedNodeVersion('22.11.0')).toThrow( /requires Node\.js 22\.12 or newer/ diff --git a/packages/platform-ios/src/permission-agent.ts b/packages/platform-ios/src/permission-agent.ts index d051f93c..15444278 100644 --- a/packages/platform-ios/src/permission-agent.ts +++ b/packages/platform-ios/src/permission-agent.ts @@ -89,6 +89,14 @@ const POSITIVE_BUTTON_LABELS = [ ] as const; const DEVICE_IN_USE_ERROR_CODE = 'DEVICE_IN_USE'; +/** + * agent-device refuses to wipe a derived-data path that came from + * `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, which Harness always sets. It needs + * that wipe whenever the cached runner no longer matches the installed + * agent-device version, Xcode or SDK, so Harness does the cleaning itself. + */ +const REFUSED_CLEAN_MESSAGE = + 'Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH'; const RUNNER_BUSY_ERROR_CODE = 'RUNNER_BUSY'; const ALERT_NOT_FOUND_RUNNER_ERROR_CODE = 'ALERT_NOT_FOUND'; @@ -240,7 +248,9 @@ const applyDaemonEnvironment = ({ }: { runnerDerivedDataPath: string; target: IosPermissionAgentTarget; -}): void => { +}): { ownsRunnerDerivedDataPath: boolean } => { + const ownsRunnerDerivedDataPath = + getTrimmedEnvironmentValue(RUNNER_DERIVED_PATH_ENV) === undefined; const setIfUnset = (name: string, value: string | undefined) => { if (value === undefined || value.length === 0) { return; @@ -260,17 +270,17 @@ const applyDaemonEnvironment = ({ setIfUnset(RUNNER_DERIVED_PATH_ENV, runnerDerivedDataPath); - if (target.kind !== 'device' || !target.codeSign) { - return; - } + if (target.kind === 'device' && target.codeSign) { + const { teamId, signingIdentity, provisioningProfile, runnerBundleId } = + target.codeSign; - const { teamId, signingIdentity, provisioningProfile, runnerBundleId } = - target.codeSign; + setIfUnset(IOS_TEAM_ID_ENV, teamId); + setIfUnset(IOS_SIGNING_IDENTITY_ENV, signingIdentity); + setIfUnset(IOS_PROVISIONING_PROFILE_ENV, provisioningProfile); + setIfUnset(IOS_BUNDLE_ID_ENV, runnerBundleId); + } - setIfUnset(IOS_TEAM_ID_ENV, teamId); - setIfUnset(IOS_SIGNING_IDENTITY_ENV, signingIdentity); - setIfUnset(IOS_PROVISIONING_PROFILE_ENV, provisioningProfile); - setIfUnset(IOS_BUNDLE_ID_ENV, runnerBundleId); + return { ownsRunnerDerivedDataPath }; }; const getErrorCode = (error: unknown): string | undefined => { @@ -333,6 +343,9 @@ const isTransientWatchdogError = (error: unknown): boolean => { export const isDeviceInUseError = (error: unknown): boolean => getErrorCode(error) === DEVICE_IN_USE_ERROR_CODE; +export const isRefusedRunnerCleanError = (error: unknown): boolean => + getErrorMessage(error).includes(REFUSED_CLEAN_MESSAGE); + /** * A claimed device is never retried: the owner is a live agent-device session, * usually the developer's own, and quietly taking it over would break it. @@ -809,12 +822,15 @@ export const createIosPermissionAgent = ( assertSupportedNodeVersion(); const runnerDerivedDataPath = getRunnerDerivedDataPath(projectRoot); - applyDaemonEnvironment({ runnerDerivedDataPath, target }); + const { ownsRunnerDerivedDataPath } = applyDaemonEnvironment({ + runnerDerivedDataPath, + target, + }); fs.mkdirSync(stateDir, { recursive: true }); const { createAgentDeviceClient } = await import('agent-device'); - client = createAgentDeviceClient({ + const agentDeviceClient = createAgentDeviceClient({ stateDir, session: AGENT_DEVICE_SESSION_NAME, iosXctestrunFile: getExistingPath(XCTESTRUN_FILE_ENV), @@ -823,6 +839,8 @@ export const createIosPermissionAgent = ( ), }); + client = agentDeviceClient; + permissionAgentLogger.debug( 'preparing the agent-device iOS runner for %s (state dir %s, budget %d ms)', target.udid, @@ -830,11 +848,11 @@ export const createIosPermissionAgent = ( prepareTimeoutMs ); - try { - await runBounded( + const prepareRunner = () => + runBounded( 'preparing the agent-device iOS runner', () => - client!.command.prepare({ + agentDeviceClient.command.prepare({ action: 'ios-runner', platform: 'ios', udid: target.udid, @@ -842,10 +860,35 @@ export const createIosPermissionAgent = ( }), signal ); + + try { + try { + await prepareRunner(); + } catch (error) { + // agent-device will not wipe a derived path it did not choose, but + // it needs that wipe after an agent-device, Xcode or SDK upgrade. + // The directory is Harness's own cache, so clear it and retry once + // rather than failing every run until someone deletes it by hand. + if (!isRefusedRunnerCleanError(error) || !ownsRunnerDerivedDataPath) { + throw error; + } + + logger.info( + 'Rebuilding the cached iOS UI test runner (it no longer matches the installed agent-device or Xcode)...' + ); + permissionAgentLogger.debug( + 'clearing the stale runner cache at %s', + runnerDerivedDataPath + ); + fs.rmSync(runnerDerivedDataPath, { recursive: true, force: true }); + + await prepareRunner(); + } + await runBounded( 'opening the agent-device SpringBoard session', () => - client!.apps.open({ + agentDeviceClient.apps.open({ app: SPRINGBOARD_BUNDLE_ID, platform: 'ios', udid: target.udid, From 84c7cfc311a368664b0ca16fde157bc1f7d14a87 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 14 Sep 2026 10:55:17 +0200 Subject: [PATCH 10/12] perf(platform-apple): accept permission prompts in a single round trip 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. --- .../src/__tests__/permission-agent.test.ts | 67 +++++++-- packages/platform-ios/src/permission-agent.ts | 142 +++++++++++++++--- 2 files changed, 174 insertions(+), 35 deletions(-) diff --git a/packages/platform-ios/src/__tests__/permission-agent.test.ts b/packages/platform-ios/src/__tests__/permission-agent.test.ts index 4eef3647..a6c775b2 100644 --- a/packages/platform-ios/src/__tests__/permission-agent.test.ts +++ b/packages/platform-ios/src/__tests__/permission-agent.test.ts @@ -45,6 +45,24 @@ const alertNotFoundError = () => runnerErrorCode: 'ALERT_NOT_FOUND', }); +/** + * What the runner reports when it will not press any button on the prompt. + * `ErrorPayload.code` is left unset for this case, so there is no code to + * match on. + */ +const acceptButtonNotFoundError = () => + createAgentDeviceError('COMMAND_FAILED', 'alert accept button not found'); + +const promptWithItems = (message: string, items: string[]) => { + mocks.alert.mockImplementation(async ({ action }: { action: string }) => { + if (action === 'accept') { + throw acceptButtonNotFoundError(); + } + + return { message, items }; + }); +}; + let projectRoot: string; let homeDir: string; @@ -83,6 +101,7 @@ describe('iOS permission agent', () => { mocks.open.mockResolvedValue({}); mocks.alert.mockRejectedValue(alertNotFoundError()); mocks.press.mockResolvedValue({}); + mocks.press.mockResolvedValue({}); mocks.closeSession.mockResolvedValue({}); mocks.runCommand.mockResolvedValue({ stdout: '', stderr: '' }); mocks.createAgentDeviceClient.mockReturnValue({ @@ -167,11 +186,39 @@ describe('iOS permission agent', () => { await agent.dispose(); }); - it('taps the first known positive button on a prompt', async () => { - mocks.alert.mockResolvedValue({ - message: 'Allow “Playground” to use your location?', - items: ['Allow Once', 'Allow While Using App', 'Don’t Allow'], - }); + it('accepts a prompt in a single round trip when the runner can press it', async () => { + mocks.alert.mockResolvedValue({}); + + const agent = createAgent(); + await agent.prepare(); + agent.setAppRunning(true); + + await vi.waitFor(() => expect(mocks.alert).toHaveBeenCalled()); + + expect(mocks.alert).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'accept', + platform: 'ios', + udid: 'sim-udid', + }) + ); + // One round trip: no `alert get` and no separate press. + expect( + mocks.alert.mock.calls.filter( + ([options]) => (options as { action: string }).action === 'get' + ) + ).toHaveLength(0); + expect(mocks.press).not.toHaveBeenCalled(); + + await agent.dispose(); + }); + + it('falls back to get + press when accept finds no button it may press', async () => { + promptWithItems('Allow “Playground” to use your location?', [ + 'Allow Once', + 'Allow While Using App', + 'Don’t Allow', + ]); const agent = createAgent(); await agent.prepare(); @@ -189,10 +236,7 @@ describe('iOS permission agent', () => { }); it('never taps a negative button', async () => { - mocks.alert.mockResolvedValue({ - message: 'Delete everything?', - items: ['Don’t Allow', 'Cancel'], - }); + promptWithItems('Delete everything?', ['Don’t Allow', 'Cancel']); const agent = createAgent(); await agent.prepare(); @@ -425,10 +469,7 @@ describe('iOS permission agent', () => { }); it('taps a padded label using its trimmed form', async () => { - mocks.alert.mockResolvedValue({ - message: 'Allow access?', - items: [' Allow ', 'Don’t Allow'], - }); + promptWithItems('Allow access?', [' Allow ', 'Don’t Allow']); const agent = createAgent(); await agent.prepare(); diff --git a/packages/platform-ios/src/permission-agent.ts b/packages/platform-ios/src/permission-agent.ts index 15444278..47daf280 100644 --- a/packages/platform-ios/src/permission-agent.ts +++ b/packages/platform-ios/src/permission-agent.ts @@ -66,6 +66,12 @@ const DAEMON_STOP_TIMEOUT_MS = 60_000; const WATCHDOG_DRAIN_TIMEOUT_MS = 2_000; /** Consecutive unclassified failures before the watchdog gives up. */ const MAX_CONSECUTIVE_UNKNOWN_FAILURES = 3; +/** + * How many prompts may be dismissed back to back without pausing. A chain of + * prompts is normal; an endless one is not, and skipping the gap forever would + * turn the loop into a busy wait against the runner. + */ +const MAX_CONSECUTIVE_FAST_TICKS = 3; /** * Buttons the watchdog is allowed to tap, in priority order. Carried over from @@ -97,6 +103,15 @@ const DEVICE_IN_USE_ERROR_CODE = 'DEVICE_IN_USE'; */ const REFUSED_CLEAN_MESSAGE = 'Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH'; +/** + * The runner reports "no button I am allowed to press" as a bare message with + * no code: `RunnerTests+Alert.swift` returns + * `ErrorPayload(message: "alert accept button not found")`, and + * `ErrorPayload.code` is optional and left unset there (only "no alert at all" + * is typed, as `ALERT_NOT_FOUND`). Prose is therefore the only discriminator + * available for this case. + */ +const ACCEPT_BUTTON_NOT_FOUND_MESSAGE = 'alert accept button not found'; const RUNNER_BUSY_ERROR_CODE = 'RUNNER_BUSY'; const ALERT_NOT_FOUND_RUNNER_ERROR_CODE = 'ALERT_NOT_FOUND'; @@ -346,6 +361,9 @@ export const isDeviceInUseError = (error: unknown): boolean => export const isRefusedRunnerCleanError = (error: unknown): boolean => getErrorMessage(error).includes(REFUSED_CLEAN_MESSAGE); +export const isAcceptButtonNotFoundError = (error: unknown): boolean => + getErrorMessage(error).includes(ACCEPT_BUTTON_NOT_FOUND_MESSAGE); + /** * A claimed device is never retried: the owner is a live agent-device session, * usually the developer's own, and quietly taking it over would break it. @@ -558,16 +576,30 @@ export const createIosPermissionAgent = ( return true; }; - /** Resolves false when the watchdog must stop. */ - const pollOnce = async (): Promise => { - if (!client) { - return false; - } + type PollOutcome = { + /** False when the watchdog must stop for the rest of the run. */ + continue: boolean; + /** True when a prompt was dismissed, so the next tick runs immediately. */ + handled: boolean; + }; + + const CONTINUE: PollOutcome = { continue: true, handled: false }; + const HANDLED: PollOutcome = { continue: true, handled: true }; + const STOP: PollOutcome = { continue: false, handled: false }; + /** + * Falls back to reading the prompt and tapping a known positive label. This + * is what handles the sheets `alert accept` refuses, above all the iOS + * location prompt (`Allow Once` / `Allow While Using App` / `Don't Allow`), + * where no button carries a label the runner recognises as an accept. + */ + const tapPositiveLabel = async ( + agentDeviceClient: AgentDeviceClient + ): Promise => { let alertResult: unknown; try { - alertResult = await client.command.alert({ + alertResult = await agentDeviceClient.command.alert({ action: 'get', platform: 'ios', udid: target.udid, @@ -579,20 +611,17 @@ export const createIosPermissionAgent = ( } if (isTransientWatchdogError(error)) { - consecutiveUnknownFailures = 0; permissionAgentLogger.debug( - 'permission watchdog poll skipped: %s', + 'reading the permission prompt was skipped: %s', getErrorMessage(error) ); - return true; + return CONTINUE; } - return !recordUnknownFailure('poll', error); + return recordUnknownFailure('prompt read', error) ? STOP : CONTINUE; } - consecutiveUnknownFailures = 0; - const items = getAlertItems(alertResult); const label = findLabelToTap(items); @@ -602,7 +631,7 @@ export const createIosPermissionAgent = ( items.join(', ') ); - return true; + return CONTINUE; } const selector = buildLabelSelector(label); @@ -613,7 +642,7 @@ export const createIosPermissionAgent = ( label ); - return true; + return CONTINUE; } permissionAgentLogger.debug( @@ -623,7 +652,7 @@ export const createIosPermissionAgent = ( ); try { - await client.interactions.press({ + await agentDeviceClient.interactions.press({ platform: 'ios', udid: target.udid, selector, @@ -640,26 +669,84 @@ export const createIosPermissionAgent = ( getErrorMessage(error) ); - return true; + return CONTINUE; } - return !recordUnknownFailure(`tap of "${label}"`, error); + return recordUnknownFailure(`tap of "${label}"`, error) ? STOP : CONTINUE; } - return true; + return HANDLED; + }; + + /** + * Accept-first: one round trip both detects the prompt and presses its + * button, which halves the latency on the common two-button prompts. The + * runner cannot press the wrong button doing this — verified against + * `RunnerTests+Alert.swift`, whose `chooseAlertButton(_:action:)` for + * "accept" returns either a button whose label is in its accept list + * (`ok`, `allow`, `yes`, `continue`, `done`, `open`, `open settings`, + * `confirm*`) or, only for a single-button alert, that button when its label + * is *not* in the dismiss list (`cancel`, `close`, `dismiss`, `don't allow`, + * `not now`, `no`, `keep browsing`, `later`). The two lists are disjoint, so + * a deny or dismiss button is never activated. Anything it will not press + * reports "alert accept button not found" and falls through below. + */ + const pollOnce = async (): Promise => { + if (!client) { + return STOP; + } + + const agentDeviceClient = client; + + try { + await agentDeviceClient.command.alert({ + action: 'accept', + platform: 'ios', + udid: target.udid, + timeoutMs: ALERT_COMMAND_TIMEOUT_MS, + }); + } catch (error) { + if (isDeviceInUseError(error)) { + throw createDeviceInUseError(error); + } + + if (isAcceptButtonNotFoundError(error)) { + consecutiveUnknownFailures = 0; + + return await tapPositiveLabel(agentDeviceClient); + } + + if (isTransientWatchdogError(error)) { + consecutiveUnknownFailures = 0; + permissionAgentLogger.debug( + 'permission watchdog poll skipped: %s', + getErrorMessage(error) + ); + + return CONTINUE; + } + + return recordUnknownFailure('poll', error) ? STOP : CONTINUE; + } + + consecutiveUnknownFailures = 0; + permissionAgentLogger.debug('accepted a system permission prompt'); + + return HANDLED; }; // Serialised on purpose: the runner handles one command at a time, and // overlapping requests are what produce RUNNER_BUSY storms. const runWatchdog = async (): Promise => { const { signal } = watchdogAbortController; + let consecutiveFastTicks = 0; while (!signal.aborted) { - if (appRunning) { - let shouldContinue: boolean; + let outcome: PollOutcome = CONTINUE; + if (appRunning) { try { - shouldContinue = await pollOnce(); + outcome = await pollOnce(); } catch (error) { permissionAgentLogger.error( 'stopping the iOS permission watchdog: %s', @@ -669,7 +756,7 @@ export const createIosPermissionAgent = ( return; } - if (!shouldContinue) { + if (!outcome.continue) { return; } } @@ -678,6 +765,17 @@ export const createIosPermissionAgent = ( return; } + // A dismissed prompt is often followed straight away by the next one, so + // the gap is skipped after a successful accept or tap -- but only a few + // times in a row, so a prompt that never actually goes away cannot turn + // the loop into a busy wait. + if (outcome.handled && consecutiveFastTicks < MAX_CONSECUTIVE_FAST_TICKS) { + consecutiveFastTicks += 1; + continue; + } + + consecutiveFastTicks = 0; + // The interval is the gap between calls, not a fixed period, so a slow // round trip never queues another poll behind itself. const tick = cancellableDelay(intervalMs); From 70d1e2f279326b840c73546e7c796cfd6a099df7 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 14 Sep 2026 11:00:05 +0200 Subject: [PATCH 11/12] docs: describe the accept-first permission watchdog reaction time --- website/src/docs/guides/permissions.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/src/docs/guides/permissions.mdx b/website/src/docs/guides/permissions.mdx index def78d24..336fc0c6 100644 --- a/website/src/docs/guides/permissions.mdx +++ b/website/src/docs/guides/permissions.mdx @@ -57,7 +57,9 @@ The watchdog only polls while the app under test is running, so a prompt that ap #### Reaction time -Each poll is a round trip through the UI test runner and takes roughly four seconds, so a prompt can stay on screen for up to about four seconds before it is dismissed. Tests that assert on post-permission state should allow for that. +Each poll is a round trip through the UI test runner and takes a few seconds. Most prompts are detected and dismissed in a single round trip; prompts whose buttons Harness has to read first, such as the three-button location sheet, take two. A prompt can therefore sit on screen for several seconds before it is dismissed, and a prompt that appears while a poll is already running waits for that poll to finish first. + +Allow for this in tests that assert on post-permission state, and keep in mind that Jest's `testTimeout` bounds the whole test, including the wait for the prompt to be answered. Set `HARNESS_PERMISSION_WATCHDOG_INTERVAL_MS` to change the gap between polls (default `1000`). The older `HARNESS_XCTEST_AGENT_TICK_INTERVAL_MS` name is still accepted. From dee8a3c6ee14d87d1f2e105c0ebde397b1e47272 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 14 Sep 2026 11:02:32 +0200 Subject: [PATCH 12/12] docs: describe which buttons the iOS permission watchdog presses --- ...-replace-xctest-agent-with-agent-device.md | 26 +++++++++++++++++++ website/src/docs/guides/permissions.mdx | 14 +++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/internal/adr/0001-replace-xctest-agent-with-agent-device.md b/docs/internal/adr/0001-replace-xctest-agent-with-agent-device.md index 33f74979..3745e431 100644 --- a/docs/internal/adr/0001-replace-xctest-agent-with-agent-device.md +++ b/docs/internal/adr/0001-replace-xctest-agent-with-agent-device.md @@ -235,3 +235,29 @@ the ADR did not call out as a CLI dependency. The list is described as carried over from the Swift watchdog. One label is new: `Allow While Using App`, the middle button on the iOS 26 three-button location sheet, which the old list did not cover. + +### 2026-09-14 — accept-first watchdog, and a wider set of buttons it may press + +The "Compatibility and conflicts" table rejects `alert accept` outright because +it cannot handle three-button sheets. Measured on iOS 26.4, the two-round-trip +design it mandates (`alert get` then `press`) is too slow for a prompt raised +inside a test: the playground camera test timed out at its 10 s `testTimeout`. + +Each tick now calls `alert accept` first, which detects and presses in one +round trip (1.4 s measured), and falls back to `alert get` plus a label +`press` only when accept reports `alert accept button not found` — which is +exactly the three-button case the table was concerned with. With this, the +same fresh-install test passes in 7.8 s. + +The consequence is a slightly wider net than the Harness positive-label list. +Per `RunnerTests+Alert.swift`, accept presses a button labelled `ok`, `allow`, +`yes`, `continue`, `done`, `open`, `open settings` or `confirm*`, or the sole +button of a one-button alert when its label is not one of `cancel`, `close`, +`dismiss`, `don't allow`, `not now`, `no`, `keep browsing`, `later`. The two +lists are disjoint, so a deny or dismiss button is never pressed, but a +one-button app alert that is not a permission prompt can now be dismissed. +This is documented in the permissions guide. + +A prompt that appears while a tick is already in flight waits for that tick to +finish, measured at up to ~5 s. The guide tells users to budget ~10 s for a +test that triggers a prompt. diff --git a/website/src/docs/guides/permissions.mdx b/website/src/docs/guides/permissions.mdx index 336fc0c6..78f47c9b 100644 --- a/website/src/docs/guides/permissions.mdx +++ b/website/src/docs/guides/permissions.mdx @@ -36,7 +36,13 @@ iOS permission automation runs on `agent-device`, which requires Node.js 22.12 o ### iOS The iOS implementation does **not** pre-approve every permission up front. -Instead, it runs a best-effort watchdog that reads the buttons on the visible system prompt and taps the first known positive one: +Instead, it runs a best-effort watchdog that answers system prompts as they appear. + +Each check first asks agent-device to accept the prompt. That presses a button labelled **OK**, **Allow**, **Yes**, **Continue**, **Done**, **Open** or **Open Settings**, or one starting with **Confirm** — or, on a system alert with a single button, that button, unless it is a dismissal. It never presses **Don't Allow**, **Cancel**, **Close**, **Dismiss**, **Not Now**, **No**, **Keep Browsing** or **Later**. + +This is a slightly wider net than the list below: a one-button alert, or a **Yes**/**Done**/**Open** button, is accepted even though it is not a permission prompt in the strict sense. + +When agent-device finds no button it is allowed to press — the three-button location sheet (**Allow Once** / **Allow While Using App** / **Don't Allow**) is the common case — Harness reads the prompt's buttons and taps the first one from its own positive list: - `Allow` - `OK` @@ -57,9 +63,11 @@ The watchdog only polls while the app under test is running, so a prompt that ap #### Reaction time -Each poll is a round trip through the UI test runner and takes a few seconds. Most prompts are detected and dismissed in a single round trip; prompts whose buttons Harness has to read first, such as the three-button location sheet, take two. A prompt can therefore sit on screen for several seconds before it is dismissed, and a prompt that appears while a poll is already running waits for that poll to finish first. +Each check is a round trip through the UI test runner and takes a few seconds. Most prompts are detected and dismissed in a single round trip; the ones that fall back to the positive list take two. + +A prompt that appears while a check is already running has to wait for that check to finish first, which adds up to another round trip — measured at around five seconds on an iOS 26 simulator. -Allow for this in tests that assert on post-permission state, and keep in mind that Jest's `testTimeout` bounds the whole test, including the wait for the prompt to be answered. +Budget roughly **10 seconds** for a test that triggers a permission prompt, and remember that Jest's `testTimeout` bounds the whole test, including that wait. Set `HARNESS_PERMISSION_WATCHDOG_INTERVAL_MS` to change the gap between polls (default `1000`). The older `HARNESS_XCTEST_AGENT_TICK_INTERVAL_MS` name is still accepted.