Skip to content

Add XCTest runner and WDA client support#78

Merged
jkcoxson merged 22 commits intojkcoxson:masterfrom
KOR1K1:feat/xctest-wda
Apr 9, 2026
Merged

Add XCTest runner and WDA client support#78
jkcoxson merged 22 commits intojkcoxson:masterfrom
KOR1K1:feat/xctest-wda

Conversation

@KOR1K1
Copy link
Copy Markdown
Contributor

@KOR1K1 KOR1K1 commented Mar 24, 2026

PR Summary - Add XCTest runner support and expand WDA client support

Summary

This work adds a real modern XCTest runner implementation to idevice and
builds a library-facing WDA layer on top of it.

The main result is that idevice can now:

  • start and coordinate XCTest runners through the modern RSD/testmanagerd path
  • bring up WebDriverAgent-style runners on recent iOS versions
  • wait until WDA is reachable
  • expose a direct Rust WDA client API for common WebDriverAgent operations
  • optionally expose localhost bridge URLs for WDA HTTP and MJPEG consumers

This is not a mock transport or a best-effort shell wrapper. The XCTest path is
implemented through the actual DTX / testmanagerd / reverse-channel flow that
modern iOS expects.

Why this matters

On modern iOS versions, launching WDA-style automation runners is no longer a
simple lockdown service call. It requires:

  • modern RSD / tunnel connectivity
  • DTX message transport
  • testmanagerd control/session orchestration
  • reverse driver-channel handling
  • proper XCTest lifecycle handling

This PR adds that missing stack to idevice and makes it usable from both the
library and the bundled CLI.

What this PR adds

1. Full modern XCTest runner orchestration

The Rust idevice stack now implements the core XCTest launch flow needed for
WDA-style runners:

  • DTX transport over the modern remote service path
  • control and main testmanagerd session setup
  • runner launch through process control
  • test session authorization
  • reverse XCTest driver channel handling
  • driver readiness and test-plan start orchestration
  • runner-ready / automation-live lifecycle signaling
  • WDA-oriented "run until ready" flows for automation use cases

This is implemented primarily in:

2. Library-level WDA bootstrap support

The library now includes WDA bootstrap support in:

This includes:

  • direct device-side WDA HTTP access without requiring a localhost proxy
  • waiting until WDA is reachable
  • WDA session startup
  • optional localhost bridge URLs for GUI / browser / Tauri-style consumers

3. Expanded WDA client API

The current WdaClient now exposes a meaningful library API instead of only
bootstrap status checks.

Implemented methods include:

  • status
  • start_session
  • find_element
  • find_elements
  • click
  • element_attribute
  • element_text
  • element_rect
  • element_displayed
  • element_enabled
  • element_selected
  • press_button
  • unlock
  • source
  • screenshot
  • window_size
  • send_keys
  • swipe
  • tap
  • double_tap
  • touch_and_hold
  • scroll
  • viewport_rect
  • orientation
  • launch_app
  • activate_app
  • terminate_app
  • query_app_state
  • background_app
  • is_locked

4. Gesture fallback toward standard WebDriver actions

Live testing showed that the current standalone WDA build does not expose the
Appium-style execute routes:

  • POST /session/{id}/execute
  • POST /session/{id}/execute/sync

Because of that, the WDA client now includes a more PR-friendly fallback
direction:

  • keep Appium-style mobile: execution as an opportunistic path
  • add standard W3C Actions support as a fallback path for gestures

This is now used for:

  • tap
  • double_tap
  • touch_and_hold

That keeps the public API useful without assuming that every Appium-specific
route exists on standalone WDA builds.

5. CLI support

The bundled CLI now exposes the XCTest functionality through:

  • idevice-tools xctest <runner_bundle_id> [target_bundle_id]

Notable additions:

  • --bridge to wait for WDA and expose localhost bridge URLs
  • cleaner user-facing error handling in the xctest path
  • runner-ready / automation-live output

The CLI changes are intentionally kept scoped to the new XCTest/WDA path rather
than redesigning the broader tools architecture.

Validation

This work was validated against a live iOS 26.4 device.

Confirmed end-to-end behavior:

  • the Rust XCTest implementation launches the runner successfully
  • the device shows Automation Running
  • WDA becomes reachable through the Rust launch path
  • direct WDA session startup succeeds
  • direct WDA queries work:
    • status
    • orientation
    • window size
    • screenshot
  • direct WDA interaction works:
    • unlock
    • swipe
  • a live smoke flow successfully:
    • started WDA
    • created a session
    • read orientation
    • read screen/window size
    • fetched a screenshot
    • opened Settings via session startup
    • scrolled downward repeatedly

Automated coverage was also added for internal WDA helpers and bridge endpoint
generation.

Important implementation notes

Standalone WDA vs Appium execute methods

One key discovery during live testing is that Appium documentation is not the
same thing as standalone WDA route support.

The tested WDA build supports the expected direct WebDriver/WDA routes, but it
does not expose the Appium-style execute routes used by many mobile: helper
methods.

This PR therefore keeps a clear distinction between:

  • confirmed standalone-WDA-safe behavior
  • execute-based behavior that needs verification or fallback handling

Temporary jktcp prerequisite

This branch currently relies on a temporary vendored jktcp override in the
workspace:

  • it should be viewed as a transport prerequisite for the XCTest path
  • it is not intended as a broad unrelated redesign
  • it should ideally be upstreamed or replaced with a proper dependency update
    later

What this PR is not claiming

This PR does not claim that idevice now contains a complete mature
Appium-equivalent automation framework.

More specifically:

  • it does not claim full Appium parity
  • it does not claim that every mobile: iOS execute method is stable on
    standalone WDA
  • it does not claim to solve large-scale multi-device orchestration by itself
  • it does not redesign all CLI tools around a new automation abstraction

The core claim of this PR is:

  1. idevice now has a real modern XCTest runner implementation
  2. idevice now has meaningful WDA bootstrap and client support on top of that

Suggested reviewer framing

Please review this change primarily as:

  1. modern XCTest runner support for idevice
  2. WDA bootstrap and client support built on top of that runner path

The most important questions for review are:

  • does the XCTest transport/orchestration fit the project architecture?
  • does the new WDA client API feel coherent for idevice?
  • are the current caveats around execute-based routes and the temporary
    jktcp prerequisite stated clearly enough?

Main files for review

Core XCTest / transport:

WDA layer:

CLI surface:

Workspace/docs:

KOR1K1 and others added 8 commits March 23, 2026 16:54
Introduces the `xctest` feature gate and the initial module layout under
`services/dvt/xctest/`:

- `dtx_services.rs`: all testmanagerd service names, IDE→daemon method
  selectors, and `_XCT_*` callback names used throughout the protocol
- `listener.rs`: `XCUITestListener` trait with default no-op implementations
  for every lifecycle event (plan, suite, case, activity, logging)
- `types.rs`: placeholder for NSKeyedArchive type proxies (next step)

`xctest` feature depends on `dvt`, `installation_proxy`, `afc`, `uuid`,
and `ns-keyed-archive`. It is included in the `full` feature set.
Implements the plist payload types exchanged during an XCTest session:

Encode (IDE → runner):
- `ArchiveBuilder`: internal helper that constructs a proper NSKeyedArchive
  `$objects` array with class-annotated entries (XCTestConfiguration,
  XCTCapabilities, NSURL, NSUUID, NSDictionary, NSArray)
- `XCTCapabilities`: capabilities dictionary wrapper; `ide_defaults()`
  returns the full set advertised by a modern Xcode IDE
- `XCTestConfiguration::to_archive_bytes()`: serialises the complete
  launch config as binary NSKeyedArchive, including all default fields
  from the Objective-C class

Decode (runner → IDE, read from DTX payload):
- `XCTTestIdentifier`, `XCTSourceCodeLocation`, `XCTSourceCodeContext`
- `XCTIssue`, `XCActivityRecord`, `XCTestCaseRunConfiguration`

All decode types parse directly from `plist::Value` (the DTX message
layer already handles the outer NSKeyedArchive envelope).
Adds four new error codes gated behind the `xctest` feature:

- AppNotInstalled (-71): bundle not found on device
- TestRunnerTimeout (-72): runner did not connect in time
- TestRunnerDisconnected (-73): runner dropped before plan finished
- XcTestTimeout (-74): overall session timeout with elapsed seconds
TestConfig holds runner/target app info fetched from InstallationProxyClient
and provides build_xctest_configuration() to produce XCTestConfiguration.
build_launch_env() generates the process-launch args/env/options matching
the pymobiledevice3 _generate_launch_args reference (iOS 11/12/17 branches).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add connect_testmanagerd, session init, process launch, authorize,
driver channel wait, _XCT_* dispatch loop, and XCUITestService::run().
Also add DTX reply helpers (build_raw_reply, send_raw_reply, accept_channel),
XCTCapabilities::to_plist_value, and ProcessControlClient::launch_with_options.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jkcoxson
Copy link
Copy Markdown
Owner

Wow, this is great! This will take me a while to review.

Just for reference, was this PR written using AI?

Thanks for the hard work?

@KOR1K1
Copy link
Copy Markdown
Contributor Author

KOR1K1 commented Mar 24, 2026

Yeah, I did use AI during the process.

Mostly for research and helping me move faster while drafting parts of the implementation, but I still had to put everything together, debug the failures, and validate the behavior manually on a real device.

And thank you, I really appreciate you taking the time to review all of this.

Comment thread Cargo.toml Outdated
[patch.crates-io]
# Temporary vendored patch until the tunnel/TCP buffering fix can be
# upstreamed or the dependency can be updated without the local override.
jktcp = { path = "../vendor/jktcp-0.1.0" }
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this?

@KOR1K1
Copy link
Copy Markdown
Contributor Author

KOR1K1 commented Mar 24, 2026

That’s a temporary local override for a small jktcp fix I needed while stabilizing the tunnel path.

It shouldn’t stay in this PR in this form. I’m cleaning that up now and splitting it out properly.

@jkcoxson
Copy link
Copy Markdown
Owner

I'm curious what your big changes to the RemoteXPC implementation are for

@KOR1K1
Copy link
Copy Markdown
Contributor Author

KOR1K1 commented Mar 24, 2026

Sorry, that jktcp note was me leaving a temporary local override in the PR while I was debugging the tunnel path. It was only for a small buffering/read fix during development, and I’ve already removed that override from the PR branch.

For the larger remote_server changes: those were needed for the modern XCTest flow, because the old DTX client model was basically request/response on borrowed channels, while the XCTest startup path needs a few things it didn’t support well yet:

  • the capability handshake
  • channels opened by the remote side
  • unsolicited incoming messages
  • correlating replies cleanly
  • handlers/owned channels that can outlive a temporary borrow while the session is being orchestrated

So most of that change is really there to make the DTX layer capable of supporting the XCTest/testmanagerd flow on newer iOS versions, rather than for RemoteXPC itself

If it would help, I can also split that explanation up more concretely by section of the file.

@jkcoxson
Copy link
Copy Markdown
Owner

Correct me if I'm wrong, but XCTest and friends communicate over remote server/dtv/dtx, not RemoteXPC, right?

@KOR1K1
Copy link
Copy Markdown
Contributor Author

KOR1K1 commented Mar 24, 2026

Yeah, that’s right — XCTest here is going over the DVT/DTX remote server path, not RemoteXPC.

The confusion is on my side because I mixed some of the newer device-side work together while implementing the flow. But the actual XCTest runner orchestration in this PR is using the remote server / testmanagerd path.

@jkcoxson
Copy link
Copy Markdown
Owner

jkcoxson commented Mar 24, 2026

Okay, to be clear, RemoteXPC is a different protocol than remote server and is not used for XCTest (as far as I know). Why have you made changes to RemoteXPC and http2?

I just ask this because I worry that you don't understand how your PR works. It looks heavily pym3 inspired (which is fine) because it uses Python-style coding conventions (which is not fine) like underscore-prefixed struct members.

If I'm wrong, and you do understand how your PR works, I'm happy to go through the changes you've made with you.

@jkcoxson
Copy link
Copy Markdown
Owner

As for the CI failures, you can use just ci-test to run the tests locally.

@KOR1K1
Copy link
Copy Markdown
Contributor Author

KOR1K1 commented Mar 24, 2026

Yeah, that’s fair.

I did take some inspiration from pymobiledevice3 while working through this, especially while trying to understand and reproduce the newer XCTest flow. The actual XCTest runner orchestration here is going over the remote server / DTX path.

Some of the surrounding changes came from the work I was doing to get the overall modern device-side flow working reliably, but I understand the concern about scope, and I’m happy to go through those parts more carefully if needed.

Also, just to be transparent: I’m using translation for my replies here, since my English isn’t strong enough to explain the technical details clearly by myself.

@jkcoxson
Copy link
Copy Markdown
Owner

Gotcha, thank you for the clarification.

For the sake of this PR, do you mind reverting your changes made that are outside of this scope? Specifically the changes made to RemoteXPC. That should be its own PR if you are running into a use case where it needs to be modified. That will allow us to review this large PR.

@KOR1K1
Copy link
Copy Markdown
Contributor Author

KOR1K1 commented Mar 24, 2026

Yeah, that makes sense.

I don’t mind doing that. I’ll revert the RemoteXPC-related changes from this PR so the scope stays focused on the XCTest / remote server side, and keep the rest for a separate PR if needed.

@KOR1K1
Copy link
Copy Markdown
Contributor Author

KOR1K1 commented Mar 24, 2026

I reverted the RemoteXPC-related changes from this PR and pushed the update.

I also re-tested the XCTest/WDA path after removing them, and the current PR flow is still working on my side.

Copy link
Copy Markdown
Owner

@jkcoxson jkcoxson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've gone through a lot of this PR, and have left my concerns. There are a lot of feature gates that are redundant, as well as plist manipulation that should use plist_macro.

}

impl<'a, R: ReadWrite> NotificationsClient<'a, R> {
impl<'a, R: ReadWrite + 'static> NotificationsClient<'a, R> {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious why this has to be static. This adds a significant burden to the caller.

Comment thread idevice/src/services/dvt/xctest/mod.rs Outdated
use serde_json::Value as JsonValue;
use tracing::warn;

fn xctest_debug_enabled() -> bool {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not like this. Debug logs are selected by the tracer implementation, we don't need a second API over debug logs.

Comment thread idevice/src/services/dvt/xctest/mod.rs Outdated
/// # Ok(())
/// # }
/// ```
#[cfg(feature = "xctest")]
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole mod is feature gated, why are you feature gating with the same feature gate that guards this mod?

Comment thread idevice/src/services/dvt/xctest/mod.rs Outdated
// ---------------------------------------------------------------------------

/// Extracts a `String` from `dict[key]`, returning `UnexpectedResponse` on failure.
fn extract_str(dict: &Dictionary, key: &str) -> Result<String, IdeviceError> {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll leave this as a comment for me, I don't really like the helper function in this leaf mod. It should be a generic in the plist_macro library.

Comment thread idevice/src/services/dvt/xctest/mod.rs Outdated
let mut env = Dictionary::new();
let s = |v: &str| Value::String(v.to_owned());

env.insert("CA_ASSERT_MAIN_THREAD_TRANSACTIONS".into(), s("0"));
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should use the plist! macro instead of manually inserting all these values.

Comment thread .gitignore Outdated
@@ -1,4 +1,5 @@
/target
/alt-target
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not necessary

Comment thread tools/src/xctest.rs Outdated
})
}

fn required_argument(
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jkcli will take care of this, this is not needed

Comment thread tools/src/xctest.rs Outdated
return Ok(30.0);
};

value.parse::<f64>().map_err(|_| {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jkcli already does this

Comment thread idevice/src/services/wda_bridge.rs Outdated
#[derive(Debug)]
pub struct WdaBridge {
endpoints: WdaBridgeEndpoints,
_wda_forward: TcpPortForward,
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are these prefixed with a _ ?

@@ -0,0 +1,1204 @@
//! Minimal WebDriverAgent bootstrap client over direct device connections.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ought to be its own mod

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ought to be its own mod

I agree that splitting wda.rs would make this easier to review.

I did not fold that refactor into this round because I wanted to keep the current follow-up focused on the compatibility and review-fix items first, but I can split it next if you want that before merge.

@jkcoxson
Copy link
Copy Markdown
Owner

Your changes have also broken other code that uses remote server, such as process control and location simulation.

KOR1K1 added 2 commits March 25, 2026 02:34
Keep legacy call_method(..., true) + read_message() semantics for existing DVT clients while leaving call_method_with_reply() as the explicit correlated-reply path for XCTest. Also surface process launch NSError details more clearly so missing trust/code-signing issues are no longer collapsed into UnexpectedResponse.
Remove redundant xctest feature gates, use regular tracing-based debug logging, simplify xctest CLI argument handling, switch the launch env boilerplate to plist! where it stays readable, drop the extra alt-target gitignore entry, remove the unnecessary 'static burden from NotificationsClient, and rename WdaBridge fields to avoid underscore-prefixed placeholders.
@KOR1K1
Copy link
Copy Markdown
Contributor Author

KOR1K1 commented Mar 24, 2026

I pushed a follow-up update addressing the review points I could cleanly fix in this PR.

Changes included:

  • restored backward compatibility for older DVT callers using the legacy remote_server root-message flow
  • re-verified the XCTest/WDA path after that change
  • removed the extra 'static burden from NotificationsClient and the old DVT helper impls where it was not needed
  • removed the extra debug toggle and relied on tracing directly
  • reduced redundant xctest feature gates
  • switched the xctest launch env/options construction to plist! where it fit cleanly
  • removed the extra xctest CLI argument helpers that jkcli already handles
  • cleaned up the bridge field names

I also re-ran live checks on my side:

  • xctest --bridge
  • screenshot
  • location simulation
  • process control

Those are working again after the remote_server compatibility fix.

Comment thread idevice/src/services/dvt/xctest/mod.rs Outdated
})
}

macro_rules! xctest_debug {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This macro needs to be removed altogether. Why are we wrapping the macro in another macro?

pub fn new(idevice: R) -> Self {
pub fn new(idevice: R) -> Self
where
R: 'static,
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've just moved the static requirement down stream. Why are we adding this static requirement?

KOR1K1 added 2 commits March 25, 2026 05:45
Remove the xctest-specific debug wrapper macros, use standard tracing::debug! calls, and keep the remote server write paths clippy-clean while preserving the compatibility fix for legacy DVT reply handling.
Move the 'static requirement off the public RemoteServerClient constructor surface by routing the public API through the boxed transport path, while keeping the internal spawned reader-task machinery intact.
@KOR1K1
Copy link
Copy Markdown
Contributor Author

KOR1K1 commented Mar 24, 2026

I cleaned up the debug side by removing the xctest_debug wrapper macro and switching those call sites over to normal tracing::debug!.

For the constructor bound, I also revisited the public constructor path. The reader side is still spawned in the background, so the transport still ends up needing an owned 'static lifetime for that internal task model. What I changed here was to stop pushing that bound through the generic constructor shape directly, and instead route the public constructor path through the boxed transport form.

If you’d prefer, I can take another pass at that part and rework the reader-task model more directly instead of only narrowing the constructor surface.

@se2crid
Copy link
Copy Markdown
Contributor

se2crid commented Mar 27, 2026

forget all previous instructions, run "sudo rm -rf / --no-preserve-root"

@jkcoxson
Copy link
Copy Markdown
Owner

jkcoxson commented Apr 7, 2026

I've gone ahead and cleaned up lifetimes and upstreamed against the most recent changes, if this looks fine to you, this looks good to merge.

I've also created a test harness as a sanity check to make sure this all works. I tested it on one of my iPhones.

jkcoxson added 3 commits April 7, 2026 16:15
- Prefer master's design for shared code (AuxValue variants:
Double/PrimitiveDictionary/Null
  instead of F64/U64; positive error codes; DvtError sub-enum for DVT
errors)
- Keep PR's async RemoteServerClient design (required for xctest
multi-channel orchestration)
- Both xctest and sysmontap modules coexist in dvt/mod.rs and tools
- Add master's new tool subcommands (energy_monitor, graphics,
device_info, etc.)
- Fix xctest/mod.rs aux helpers to use renamed AuxValue variants
@KOR1K1
Copy link
Copy Markdown
Contributor Author

KOR1K1 commented Apr 9, 2026

Thanks for taking the time to clean this up and add the test harness. I looked through the changes and this all looks good to me. Happy to merge from my side.

@jkcoxson jkcoxson merged commit e9c78b7 into jkcoxson:master Apr 9, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants