-
Notifications
You must be signed in to change notification settings - Fork 93
fix(xctest): support iOS 14-16 WDA launch via legacy transport #125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
16172fd
d1b84f6
6fa574f
14d9138
a3b03ec
c3d92fa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -70,7 +70,7 @@ use dtx_services::{ | |
| XCT_LOG_MESSAGE, XCT_METHOD_DID_MEASURE_METRIC, XCT_RUNNER_READY_WITH_CAPABILITIES, | ||
| XCT_SUITE_DID_FINISH, XCT_SUITE_DID_FINISH_ID, XCT_SUITE_DID_START, XCT_SUITE_DID_START_ID, | ||
| XCT_UI_INIT_DID_FAIL, XCTEST_DRIVER_INTERFACE, XCTEST_MANAGER_DAEMON_CONNECTION_INTERFACE, | ||
| XCTEST_MANAGER_IDE_INTERFACE, XCTEST_PROXY_IDE_TO_DRIVER, | ||
| XCTEST_MANAGER_IDE_INTERFACE, XCTEST_PROXY_DRIVER_TO_IDE, XCTEST_PROXY_IDE_TO_DRIVER, | ||
| }; | ||
| use listener::{XCTestCaseResult, XCUITestListener}; | ||
| use types::{ | ||
|
|
@@ -905,16 +905,33 @@ struct DriverProxy { | |
| impl DriverProxy { | ||
| async fn wait( | ||
| client: &mut RemoteServerClient<Box<dyn ReadWrite>>, | ||
| ios_major_version: u8, | ||
| timeout_secs: f64, | ||
| ) -> Result<Self, IdeviceError> { | ||
| Ok(Self { | ||
| channel: wait_for_driver_channel(client, timeout_secs).await?, | ||
| channel: wait_for_driver_channel(client, ios_major_version, timeout_secs).await?, | ||
| }) | ||
| } | ||
|
|
||
| async fn start_executing_test_plan(&mut self) -> Result<(), IdeviceError> { | ||
| start_executing_test_plan(&mut self.channel).await | ||
| } | ||
|
|
||
| /// iOS 14-16 (serialized transport): actively start the test plan on the | ||
| /// bridge channel requested by testmanagerd. Mirrors the go-ios xcode12 | ||
| /// path (xcuitestrunner_12.go): after ForChannelRequest returns the bridge | ||
| /// channel (-1), call `_IDE_startExecutingTestPlanWithProtocolVersion:` with | ||
| /// the NSKeyedArchived protocol version 36, MethodCallAsync (no reply wait). | ||
| async fn start_executing_test_plan_legacy(&mut self) -> Result<(), IdeviceError> { | ||
| let version_bytes = AuxValue::archived_value(Value::Integer(36i64.into())); | ||
| self.channel | ||
| .call_method( | ||
| Some(IDE_START_EXECUTING_TEST_PLAN), | ||
| Some(vec![version_bytes]), | ||
| false, | ||
| ) | ||
| .await | ||
| } | ||
| } | ||
|
|
||
| struct XCTestProcessControlChannel<'a, R: ReadWrite> { | ||
|
|
@@ -953,30 +970,47 @@ impl<'a, R: ReadWrite + 'static> XCTestProcessControlChannel<'a, R> { | |
| /// replies with an empty acknowledgement, registers the channel, and returns a | ||
| /// `Channel` handle to it. | ||
| fn testmanager_uses_proxy(ios_major_version: u8) -> bool { | ||
| ios_major_version >= 17 | ||
| // All supported iOS versions (11+) use the | ||
| // dtxproxy:XCTestManager_IDEInterface:XCTestManager_DaemonConnectionInterface | ||
| // proxy channel — matches tidevice (unconditional) and pymobiledevice3 | ||
| // (proxy channel form is version-independent; only the lockdown service | ||
| // name changes below iOS 14). The previous >=14 threshold left iOS 11-13 | ||
| // on the plain IDEInterface channel, which testmanagerd cancels | ||
| // ("No channel handler specified"); iOS 11-13 with the plain channel is | ||
| // unverified on-device, but the reference implementations never use it. | ||
| ios_major_version >= 11 | ||
| } | ||
|
|
||
| async fn wait_for_xctest_service_channel( | ||
| main_client: &mut RemoteServerClient<Box<dyn ReadWrite>>, | ||
| plain_identifiers: &[&str], | ||
| proxy_remote_identifiers: &[&str], | ||
| ios_major_version: u8, | ||
| timeout_secs: f64, | ||
| ) -> Result<OwnedChannel<Box<dyn ReadWrite>>, IdeviceError> { | ||
| let timeout = Some(std::time::Duration::from_secs_f64(timeout_secs)); | ||
|
|
||
| let code = match main_client | ||
| .wait_for_proxied_service_channel_code(proxy_remote_identifiers, true, Some(true), timeout) | ||
| .await | ||
| { | ||
| Ok(code) => code, | ||
| Err(IdeviceError::XcTestTimeout(_)) => match main_client | ||
| // Wait for exactly the channel form this iOS version uses, under a single | ||
| // deadline (pymobiledevice3 parity). A sequential proxied-then-plain | ||
| // fallback would double the effective timeout and would only ever select | ||
| // the plain channel after the proxied wait expired. | ||
| let code = if testmanager_uses_proxy(ios_major_version) { | ||
| main_client | ||
| .wait_for_proxied_service_channel_code( | ||
| proxy_remote_identifiers, | ||
| true, | ||
| Some(true), | ||
| timeout, | ||
| ) | ||
| .await | ||
| } else { | ||
| main_client | ||
| .wait_for_service_channel_code(plain_identifiers, Some(true), timeout) | ||
| .await | ||
| { | ||
| Ok(code) => code, | ||
| Err(IdeviceError::XcTestTimeout(_)) => return Err(IdeviceError::TestRunnerTimeout), | ||
| Err(error) => return Err(error), | ||
| }, | ||
| }; | ||
| let code = match code { | ||
| Ok(code) => code, | ||
| Err(IdeviceError::XcTestTimeout(_)) => return Err(IdeviceError::TestRunnerTimeout), | ||
| Err(error) => return Err(error), | ||
| }; | ||
|
|
||
|
|
@@ -990,7 +1024,12 @@ async fn register_early_driver_channel_handler( | |
| let xctest_config = xctest_config.clone(); | ||
| main_client | ||
| .register_incoming_channel_initializer( | ||
| &[XCTEST_DRIVER_INTERFACE, XCTEST_PROXY_IDE_TO_DRIVER], | ||
| &[ | ||
| XCTEST_DRIVER_INTERFACE, | ||
| XCTEST_PROXY_IDE_TO_DRIVER, | ||
| XCTEST_PROXY_DRIVER_TO_IDE, // legacy iOS < 17 bridge channel form | ||
| XCTEST_MANAGER_IDE_INTERFACE, // legacy iOS 15 driver channel name | ||
| ], | ||
| move |mut channel, _identifier| { | ||
| let xctest_config = xctest_config.clone(); | ||
| Box::pin(async move { | ||
|
|
@@ -1056,22 +1095,38 @@ async fn launch_and_authorize_test_runner( | |
| async fn start_test_plan_session( | ||
| main_client: &mut RemoteServerClient<Box<dyn ReadWrite>>, | ||
| _main_proxy: &mut TestManagerProxy<Box<dyn ReadWrite>>, | ||
| ios_major_version: u8, | ||
| ) -> Result<OwnedChannel<Box<dyn ReadWrite>>, IdeviceError> { | ||
| let mut driver_proxy = DriverProxy::wait(main_client, 30.0).await?; | ||
| driver_proxy.start_executing_test_plan().await?; | ||
| driver_proxy.channel.clear_incoming_handler().await; | ||
| Ok(driver_proxy.channel) | ||
| if ios_major_version < 17 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are iOS 11–13 still using the plain daemon channel? The XCTest module explicitly documents support for iOS 11+, but this threshold leaves iOS 11–13 on the plain Both comparison implementations select the old lockdown service below iOS 14 while still opening the IDE-to-daemon channel as
Should this be
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @truebit You're right, and I confirmed both references: tidevice opens the dtxproxy:...DaemonConnectionInterface channel unconditionally, and pymobiledevice3 only switches the lockdown service name below iOS 14 while keeping the proxy channel form. Since the plain channel is what testmanagerd cancels on iOS 14–16, keeping iOS 11–13 on plain had no supporting evidence. Changed the threshold to >= 11, aligning with both reference implementations. Note: iOS 11–13 remains unverified on-device (no device available), but the divergence from both references is gone. |
||
| // iOS 14-16: no XCTestDriverInterface channel (serialized transport). | ||
| // Order matches go-ios: wait for the testmanagerd bridge channel request | ||
| // (ForChannelRequest), then actively startExecutingTestPlan(36) on it. | ||
| let mut driver_proxy = DriverProxy::wait(main_client, ios_major_version, 30.0).await?; | ||
| driver_proxy.start_executing_test_plan_legacy().await?; | ||
| // early handler has served the capabilities exchange; clear it so the | ||
| // dispatch loop owns channel messages from here on | ||
| driver_proxy.channel.clear_incoming_handler().await; | ||
| Ok(driver_proxy.channel) | ||
| } else { | ||
| let mut driver_proxy = DriverProxy::wait(main_client, ios_major_version, 30.0).await?; | ||
| driver_proxy.start_executing_test_plan().await?; | ||
| driver_proxy.channel.clear_incoming_handler().await; | ||
| Ok(driver_proxy.channel) | ||
| } | ||
| } | ||
|
|
||
| pub(super) async fn wait_for_driver_channel( | ||
| main_client: &mut RemoteServerClient<Box<dyn ReadWrite>>, | ||
| ios_major_version: u8, | ||
| timeout_secs: f64, | ||
| ) -> Result<OwnedChannel<Box<dyn ReadWrite>>, IdeviceError> { | ||
| const DRIVER_SERVICE_IDENTIFIERS: &[&str] = &[XCTEST_DRIVER_INTERFACE]; | ||
| const DRIVER_SERVICE_IDENTIFIERS: &[&str] = | ||
| &[XCTEST_DRIVER_INTERFACE, XCTEST_MANAGER_IDE_INTERFACE]; // the latter for iOS 15 | ||
| wait_for_xctest_service_channel( | ||
| main_client, | ||
| DRIVER_SERVICE_IDENTIFIERS, | ||
| DRIVER_SERVICE_IDENTIFIERS, | ||
| ios_major_version, | ||
| timeout_secs, | ||
| ) | ||
| .await | ||
|
|
@@ -2005,15 +2060,60 @@ impl XCUITestService { | |
| // 3. Build XCTestConfiguration | ||
| let xctest_config = cfg.build_xctest_configuration(session_id, ios_major_version)?; | ||
|
|
||
| // 3.5 iOS < 17: write the xctestconfiguration into the app container's | ||
| // tmp/ dir (launch env XCTestConfigurationFilePath points at it; iOS 17+ | ||
| // passes config via the capabilities reply instead). Mirrors go-ios | ||
| // createTestConfigOnDevice (house_arrest VendContainer + write | ||
| // tmp/<session>.xctestconfiguration). | ||
| if ios_major_version < 17 { | ||
| use crate::services::afc::opcode::AfcFopenMode; | ||
| use crate::services::house_arrest::HouseArrestClient; | ||
| let house = HouseArrestClient::connect(&*self.provider).await?; | ||
| let mut afc = house.vend_container(&cfg.runner_bundle_id).await?; | ||
| if let Err(e) = afc.mk_dir("/tmp").await { | ||
| debug!("mk_dir /tmp: {e} (likely already exists)"); | ||
| } | ||
| let relative = xctest_path.clone(); // already /tmp/<session>.xctestconfiguration | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clean up stale XCTest configuration files Each run writes a uniquely named Both legacy reference implementations remove old configuration files before uploading the new one:
Could we similarly remove stale
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @truebit Agreed — each run left a uniquely named .xctestconfiguration behind. Fixed by cleaning before writing, matching tidevice: list /tmp and remove any *.xctestconfiguration prior to writing the new file, so stale files from crashed runs get cleaned up on the next launch too. Removal failures are logged at debug level and don't fail the run. |
||
| // Remove stale configs from previous runs before writing (tidevice | ||
| // parity): each run writes a uniquely named file and neither the | ||
| // success nor the error path removes it, so repeated WDA launches | ||
| // would accumulate files in the runner container. | ||
| if let Ok(files) = afc.list_dir("/tmp").await { | ||
| for fname in files { | ||
| if fname.ends_with(".xctestconfiguration") | ||
| && let Err(e) = afc.remove(format!("/tmp/{fname}")).await | ||
| { | ||
| debug!("remove stale config {fname}: {e}"); | ||
| } | ||
| } | ||
| } | ||
| let bytes = xctest_config.to_archive_bytes()?; | ||
| let mut fd = match afc.open(&relative, AfcFopenMode::WrOnly).await { | ||
| Ok(fd) => fd, | ||
| Err(e) => { | ||
| warn!("write xctestconfig failed: path={relative} err={e}"); | ||
| return Err(e); | ||
| } | ||
| }; | ||
| fd.write_entire(&bytes).await?; | ||
| fd.close().await?; | ||
| debug!("wrote xctestconfiguration to device: {}", relative); | ||
| } | ||
|
|
||
| // 4. Connect to testmanagerd (ctrl + main) and DVT | ||
| let mut conns = connect_testmanagerd(&*self.provider, ios_major_version).await?; | ||
| // Register the incoming channel initializer BEFORE opening the main | ||
| // channel: testmanagerd immediately opens the driver bridge channel | ||
| // (named XCTestManager_IDEInterface on iOS 15) in response to our open; | ||
| // a late registration gets the request canceled ("No channel handler | ||
| // specified") and the runner transport times out. | ||
| register_early_driver_channel_handler(&mut conns.main, &xctest_config).await; | ||
| let mut ctrl_proxy = TestManagerProxy::open(&mut conns.ctrl, ios_major_version).await?; | ||
| let mut main_proxy = TestManagerProxy::open(&mut conns.main, ios_major_version).await?; | ||
| let mut process_control = XCTestProcessControlChannel::open(&mut conns.dvt).await?; | ||
|
|
||
| let config_name = cfg.config_name().to_owned(); | ||
| initialize_testmanager_sessions(&mut ctrl_proxy, &mut main_proxy, &xctest_config).await?; | ||
| register_early_driver_channel_handler(&mut conns.main, &xctest_config).await; | ||
| initialize_testmanager_daemon_sessions( | ||
| &mut ctrl_proxy, | ||
| &mut main_proxy, | ||
|
|
@@ -2047,7 +2147,8 @@ impl XCUITestService { | |
| .await?; | ||
|
|
||
| // 6-7. Wait for driver channel and start the test plan. | ||
| let driver_channel = start_test_plan_session(&mut conns.main, &mut main_proxy).await?; | ||
| let driver_channel = | ||
| start_test_plan_session(&mut conns.main, &mut main_proxy, ios_major_version).await?; | ||
|
|
||
| // 8. Dispatch loop, raced against the runner connection dropping. | ||
| run_dispatch_loop_until_done_or_disconnect( | ||
|
|
@@ -2149,6 +2250,111 @@ mod tests { | |
| use super::wda_port_overrides_from_runner_environment; | ||
| use crate::services::wda::WdaPorts; | ||
|
|
||
| #[test] | ||
| fn testmanager_uses_proxy_for_all_supported_ios_versions() { | ||
| // The proxied daemon channel is used on every supported iOS version, | ||
| // matching tidevice (unconditional) and pymobiledevice3 (proxy form is | ||
| // version-independent). Lock this in so a version threshold cannot | ||
| // silently regress iOS 11-13 back onto the plain channel. | ||
| for version in 11..=18u8 { | ||
| assert!( | ||
| super::testmanager_uses_proxy(version), | ||
| "iOS {version} should use the proxied daemon channel" | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn legacy_launch_env_points_at_config_file() { | ||
| let session = uuid::Uuid::new_v4(); | ||
| let config_path = format!( | ||
| "/tmp/{}.xctestconfiguration", | ||
| session.to_string().to_uppercase() | ||
| ); | ||
| let (_, env, _) = super::build_launch_env( | ||
| 15, | ||
| &session, | ||
| "/app/Runner.app", | ||
| "/var/mobile/Containers/Data/Application/ABC", | ||
| "WebDriverAgentRunner", | ||
| &config_path, | ||
| None, | ||
| None, | ||
| ); | ||
|
|
||
| // iOS < 17: the runner reads the config from the path we write into | ||
| // the app container's tmp/ dir. | ||
| let on_device = env | ||
| .get("XCTestConfigurationFilePath") | ||
| .and_then(|v| v.as_string()) | ||
| .expect("XCTestConfigurationFilePath set"); | ||
| assert!(on_device.starts_with("/var/mobile/Containers/Data/Application/ABC/tmp/")); | ||
| assert!(on_device.ends_with(".xctestconfiguration")); | ||
| // iOS 17+ only marker must not leak into the legacy launch env. | ||
| assert!(env.get("XCTestManagerVariant").is_none()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn ios17_launch_env_clears_config_file_and_sets_variant() { | ||
| let session = uuid::Uuid::new_v4(); | ||
| let (_, env, _) = super::build_launch_env( | ||
| 17, | ||
| &session, | ||
| "/app/Runner.app", | ||
| "/var/mobile/Containers/Data/Application/ABC", | ||
| "WebDriverAgentRunner", | ||
| "/tmp/x.xctestconfiguration", | ||
| None, | ||
| None, | ||
| ); | ||
|
|
||
| // iOS 17+: config travels via the capabilities reply, so the env path | ||
| // is cleared and the DDI variant marker is set. | ||
| assert_eq!( | ||
| env.get("XCTestConfigurationFilePath") | ||
| .and_then(|v| v.as_string()), | ||
| Some("") | ||
| ); | ||
| assert_eq!( | ||
| env.get("XCTestManagerVariant").and_then(|v| v.as_string()), | ||
| Some("DDI") | ||
| ); | ||
| let dyld_fw = env | ||
| .get("DYLD_FRAMEWORK_PATH") | ||
| .and_then(|v| v.as_string()) | ||
| .expect("DYLD_FRAMEWORK_PATH set"); | ||
| assert!(dyld_fw.starts_with('$')); | ||
| } | ||
|
|
||
| #[test] | ||
| fn launch_env_merges_runner_overrides() { | ||
| let session = uuid::Uuid::new_v4(); | ||
| let extra = crate::plist!(dict { | ||
| "USE_PORT": "8200", | ||
| "NSUnbufferedIO": "NO", | ||
| }); | ||
| let (_, env, _) = super::build_launch_env( | ||
| 15, | ||
| &session, | ||
| "/app/Runner.app", | ||
| "/var/mobile/Containers/Data/Application/ABC", | ||
| "WebDriverAgentRunner", | ||
| "/tmp/x.xctestconfiguration", | ||
| Some(&extra), | ||
| None, | ||
| ); | ||
|
|
||
| assert_eq!( | ||
| env.get("USE_PORT").and_then(|v| v.as_string()), | ||
| Some("8200") | ||
| ); | ||
| // Caller-provided env overrides the base value. | ||
| assert_eq!( | ||
| env.get("NSUnbufferedIO").and_then(|v| v.as_string()), | ||
| Some("NO") | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn wda_ports_follow_runner_environment() { | ||
| let runner_env = crate::plist!(dict { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we still need to implement --env feature that already been merged in iOS17+ implemention
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@truebit Good point — rebased onto latest master, which includes #128's runner environment overrides (USE_PORT / MJPEG_SERVER_PORT). The legacy iOS 14-16 transport now goes through the same run_until_wda_ready path, so the env overrides apply there too.