Skip to main content

ios_core/services/testmanager/
mod.rs

1//! Minimal XCTestManager/testmanagerd startup helpers.
2//!
3//! This is the first Rust slice for the iOS 17+ `com.apple.dt.testmanagerd.remote`
4//! path: request the XCTestManager channel on two DTX connections, then expose the
5//! key startup selectors used before full test execution begins.
6
7pub mod workflow;
8pub mod xctestrun;
9
10use crate::proto::nskeyedarchiver_encode::{
11    archive_uuid, archive_xct_capabilities, archive_xctest_configuration, XcTestConfiguration,
12    XctCapabilities,
13};
14use bytes::Bytes;
15use tokio::io::{AsyncRead, AsyncWrite};
16use uuid::Uuid;
17
18use crate::services::dtx::{
19    archived_object, encode_dtx, DtxConnection, DtxError, DtxMessage, DtxPayload, NSObject, PrimArg,
20};
21
22pub const SERVICE_NAME: &str = "com.apple.dt.testmanagerd.remote";
23pub const DAEMON_CONNECTION_INTERFACE: &str =
24    "dtxproxy:XCTestManager_IDEInterface:XCTestManager_DaemonConnectionInterface";
25pub const DRIVER_INTERFACE: &str = "dtxproxy:XCTestDriverInterface:XCTestManager_IDEInterface";
26pub const START_EXECUTING_SELECTOR: &str = "_IDE_startExecutingTestPlanWithProtocolVersion:";
27pub const INITIATE_SESSION_SELECTOR: &str = "_IDE_initiateSessionWithIdentifier:capabilities:";
28pub const INITIATE_CONTROL_SESSION_SELECTOR: &str = "_IDE_initiateControlSessionWithCapabilities:";
29pub const AUTHORIZE_TEST_SESSION_SELECTOR: &str = "_IDE_authorizeTestSessionWithProcessID:";
30pub const TEST_RUNNER_READY_SELECTOR: &str = "_XCT_testRunnerReadyWithCapabilities:";
31pub const TEST_BUNDLE_READY_SELECTOR: &str =
32    "_XCT_testBundleReadyWithProtocolVersion:minimumVersion:";
33pub const REQUEST_CHANNEL_SELECTOR: &str = "_requestChannelWithCode:identifier:";
34pub const PROTOCOL_VERSION: u32 = 36;
35const MSG_RESPONSE: u32 = 3;
36
37#[derive(Debug, Clone)]
38pub enum StartupEvent {
39    TestRunnerReady {
40        message: DtxMessage,
41    },
42    TestBundleReady {
43        message: DtxMessage,
44        protocol_version: u64,
45        minimum_version: u64,
46    },
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct StartupSummary {
51    pub protocol_version: u64,
52    pub minimum_version: u64,
53}
54
55pub struct TestmanagerClient<S1, S2 = S1> {
56    session: DtxConnection<S1>,
57    session_channel: i32,
58    driver_channel: Option<i32>,
59    control: Option<(DtxConnection<S2>, i32)>,
60}
61
62impl<S1, S2> TestmanagerClient<S1, S2>
63where
64    S1: AsyncRead + AsyncWrite + Unpin + Send,
65    S2: AsyncRead + AsyncWrite + Unpin + Send,
66{
67    pub async fn connect(session_stream: S1, control_stream: S2) -> Result<Self, DtxError> {
68        let mut session = DtxConnection::new(session_stream);
69        let mut control = DtxConnection::new(control_stream);
70
71        let session_channel = session.request_channel(DAEMON_CONNECTION_INTERFACE).await?;
72        let control_channel = control.request_channel(DAEMON_CONNECTION_INTERFACE).await?;
73
74        Ok(Self {
75            session,
76            session_channel,
77            driver_channel: None,
78            control: Some((control, control_channel)),
79        })
80    }
81
82    pub async fn initiate_session(
83        &mut self,
84        session_identifier: Bytes,
85        capabilities: Bytes,
86    ) -> Result<DtxMessage, DtxError> {
87        self.session
88            .method_call(
89                self.session_channel,
90                INITIATE_SESSION_SELECTOR,
91                &[
92                    archived_object(session_identifier),
93                    archived_object(capabilities),
94                ],
95            )
96            .await
97    }
98
99    pub async fn initiate_control_session(
100        &mut self,
101        capabilities: Bytes,
102    ) -> Result<DtxMessage, DtxError> {
103        let (control, channel) = self.control_mut()?;
104        control
105            .method_call(
106                channel,
107                INITIATE_CONTROL_SESSION_SELECTOR,
108                &[archived_object(capabilities)],
109            )
110            .await
111    }
112
113    pub async fn initiate_session_with_capabilities(
114        &mut self,
115        session_identifier: Uuid,
116        capabilities: XctCapabilities,
117    ) -> Result<DtxMessage, DtxError> {
118        self.initiate_session(
119            Bytes::from(archive_uuid(session_identifier)),
120            Bytes::from(archive_xct_capabilities(capabilities)),
121        )
122        .await
123    }
124
125    pub async fn initiate_control_session_with_capabilities(
126        &mut self,
127        capabilities: XctCapabilities,
128    ) -> Result<DtxMessage, DtxError> {
129        self.initiate_control_session(Bytes::from(archive_xct_capabilities(capabilities)))
130            .await
131    }
132
133    pub async fn authorize_test_session_with_process_id(
134        &mut self,
135        pid: u64,
136    ) -> Result<bool, DtxError> {
137        let (control, channel) = self.control_mut()?;
138        let response = control
139            .method_call(
140                channel,
141                AUTHORIZE_TEST_SESSION_SELECTOR,
142                &[PrimArg::Int64(pid as i64)],
143            )
144            .await?;
145
146        match response.payload {
147            DtxPayload::Response(NSObject::Bool(authorized)) => Ok(authorized),
148            other => Err(DtxError::Protocol(format!(
149                "unexpected authorize test session response: {other:?}"
150            ))),
151        }
152    }
153
154    pub async fn request_driver_channel(&mut self) -> Result<i32, DtxError> {
155        let channel = self.session.request_channel(DRIVER_INTERFACE).await?;
156        self.driver_channel = Some(channel);
157        Ok(channel)
158    }
159
160    pub async fn await_driver_channel_request(&mut self) -> Result<i32, DtxError> {
161        loop {
162            let msg = self.session.recv().await?;
163            if let DtxPayload::MethodInvocation { selector, .. } = &msg.payload {
164                if selector == REQUEST_CHANNEL_SELECTOR {
165                    let (_requested_code, identifier) = decode_channel_request(&msg)?;
166                    if msg.expects_reply {
167                        self.session.send_ack(&msg).await?;
168                    }
169
170                    if identifier == DRIVER_INTERFACE {
171                        // testmanagerd often requests code `1` here but then sends traffic on
172                        // the default `-1` channel. Match go-ios' compatibility workaround.
173                        self.driver_channel = Some(-1);
174                        return Ok(-1);
175                    }
176                    continue;
177                }
178            }
179
180            if msg.expects_reply {
181                self.session.send_ack(&msg).await?;
182            }
183        }
184    }
185
186    pub async fn start_executing_test_plan(&mut self) -> Result<(), DtxError> {
187        let channel = self.driver_channel.unwrap_or(-1);
188        self.session
189            .method_call_async(
190                channel,
191                START_EXECUTING_SELECTOR,
192                &[PrimArg::Int64(PROTOCOL_VERSION as i64)],
193            )
194            .await
195    }
196
197    pub async fn recv_startup_event(&mut self) -> Result<StartupEvent, DtxError> {
198        loop {
199            let msg = self.session.recv().await?;
200            if let DtxPayload::MethodInvocation { selector, .. } = &msg.payload {
201                match selector.as_str() {
202                    TEST_RUNNER_READY_SELECTOR => {
203                        return Ok(StartupEvent::TestRunnerReady { message: msg });
204                    }
205                    TEST_BUNDLE_READY_SELECTOR => {
206                        let (protocol_version, minimum_version) =
207                            decode_test_bundle_ready_versions(&msg)?;
208                        return Ok(StartupEvent::TestBundleReady {
209                            message: msg,
210                            protocol_version,
211                            minimum_version,
212                        });
213                    }
214                    _ => {}
215                }
216            }
217
218            if msg.expects_reply {
219                self.session.send_ack(&msg).await?;
220            }
221        }
222    }
223
224    pub async fn respond_test_runner_ready(
225        &mut self,
226        msg: &DtxMessage,
227        configuration: Bytes,
228    ) -> Result<(), DtxError> {
229        let frame = encode_dtx(
230            msg.identifier,
231            msg.conversation_idx + 1,
232            msg.channel_code,
233            false,
234            MSG_RESPONSE,
235            &configuration,
236            &[],
237        );
238        self.session.send_raw(&frame).await
239    }
240
241    pub async fn respond_test_runner_ready_with_configuration(
242        &mut self,
243        msg: &DtxMessage,
244        configuration: XcTestConfiguration,
245    ) -> Result<(), DtxError> {
246        self.respond_test_runner_ready(
247            msg,
248            Bytes::from(archive_xctest_configuration(configuration)),
249        )
250        .await
251    }
252
253    pub async fn complete_startup_with_configuration(
254        &mut self,
255        configuration: XcTestConfiguration,
256    ) -> Result<StartupSummary, DtxError> {
257        let mut bundle_ready = None;
258        let mut pending_configuration = Some(configuration);
259
260        loop {
261            match self.recv_startup_event().await? {
262                StartupEvent::TestBundleReady {
263                    protocol_version,
264                    minimum_version,
265                    ..
266                } => {
267                    bundle_ready = Some(StartupSummary {
268                        protocol_version,
269                        minimum_version,
270                    });
271                }
272                StartupEvent::TestRunnerReady { message } => {
273                    let configuration = pending_configuration.take().ok_or_else(|| {
274                        DtxError::Protocol("test runner ready received more than once".into())
275                    })?;
276                    self.respond_test_runner_ready_with_configuration(&message, configuration)
277                        .await?;
278
279                    if let Some(summary) = bundle_ready {
280                        return Ok(summary);
281                    }
282                }
283            }
284        }
285    }
286
287    pub async fn authorize_and_start_test_plan_with_configuration(
288        &mut self,
289        pid: u64,
290        configuration: XcTestConfiguration,
291    ) -> Result<StartupSummary, DtxError> {
292        if !self.authorize_test_session_with_process_id(pid).await? {
293            return Err(DtxError::Protocol(
294                "testmanagerd rejected test session authorization".into(),
295            ));
296        }
297
298        let mut bundle_ready = None;
299        let mut pending_configuration = Some(configuration);
300        let mut driver_ready = self.driver_channel.is_some();
301
302        loop {
303            let msg = self.session.recv().await?;
304            if let DtxPayload::MethodInvocation { selector, .. } = &msg.payload {
305                match selector.as_str() {
306                    TEST_BUNDLE_READY_SELECTOR => {
307                        let (protocol_version, minimum_version) =
308                            decode_test_bundle_ready_versions(&msg)?;
309                        bundle_ready = Some(StartupSummary {
310                            protocol_version,
311                            minimum_version,
312                        });
313                    }
314                    TEST_RUNNER_READY_SELECTOR => {
315                        let configuration = pending_configuration.take().ok_or_else(|| {
316                            DtxError::Protocol(
317                                "test runner ready received more than once during startup".into(),
318                            )
319                        })?;
320                        self.respond_test_runner_ready_with_configuration(&msg, configuration)
321                            .await?;
322                    }
323                    REQUEST_CHANNEL_SELECTOR => {
324                        let (_requested_code, identifier) = decode_channel_request(&msg)?;
325                        if msg.expects_reply {
326                            self.session.send_ack(&msg).await?;
327                        }
328                        if identifier == DRIVER_INTERFACE {
329                            self.driver_channel = Some(-1);
330                            driver_ready = true;
331                        }
332                        if let Some(summary) = bundle_ready.filter(|_| driver_ready) {
333                            if pending_configuration.is_none() {
334                                // driver channel arrived last — all three conditions met, start now.
335                                self.start_executing_test_plan().await?;
336                                return Ok(summary);
337                            }
338                        }
339                        continue;
340                    }
341                    _ => {}
342                }
343            }
344
345            if msg.expects_reply
346                && !matches!(
347                    &msg.payload,
348                    DtxPayload::MethodInvocation {
349                        selector,
350                        ..
351                    } if selector == TEST_RUNNER_READY_SELECTOR
352                )
353            {
354                self.session.send_ack(&msg).await?;
355            }
356
357            // Check again for non-REQUEST_CHANNEL messages (e.g. bundle ready arriving after
358            // runner ready, when driver channel was already established beforehand).
359            if let Some(summary) = bundle_ready.filter(|_| driver_ready) {
360                if pending_configuration.is_none() {
361                    self.start_executing_test_plan().await?;
362                    return Ok(summary);
363                }
364            }
365        }
366    }
367
368    fn control_mut(&mut self) -> Result<(&mut DtxConnection<S2>, i32), DtxError> {
369        self.control
370            .as_mut()
371            .map(|(control, channel)| (control, *channel))
372            .ok_or_else(|| DtxError::Protocol("control connection is not configured".into()))
373    }
374}
375
376fn decode_test_bundle_ready_versions(msg: &DtxMessage) -> Result<(u64, u64), DtxError> {
377    let DtxPayload::MethodInvocation { args, .. } = &msg.payload else {
378        return Err(DtxError::Protocol(
379            "test bundle ready event did not contain a method invocation".into(),
380        ));
381    };
382
383    let protocol_version = args
384        .first()
385        .and_then(|value| value.as_int())
386        .ok_or_else(|| DtxError::Protocol("missing test bundle protocol version".into()))?;
387    let minimum_version = args
388        .get(1)
389        .and_then(|value| value.as_int())
390        .ok_or_else(|| DtxError::Protocol("missing test bundle minimum version".into()))?;
391
392    if protocol_version < 0 || minimum_version < 0 {
393        return Err(DtxError::Protocol(
394            "test bundle versions must be non-negative".into(),
395        ));
396    }
397
398    Ok((protocol_version as u64, minimum_version as u64))
399}
400
401fn decode_channel_request(msg: &DtxMessage) -> Result<(i32, String), DtxError> {
402    let DtxPayload::MethodInvocation { args, .. } = &msg.payload else {
403        return Err(DtxError::Protocol(
404            "channel request did not contain a method invocation".into(),
405        ));
406    };
407
408    let requested_code = args
409        .first()
410        .and_then(|value| value.as_int())
411        .ok_or_else(|| DtxError::Protocol("missing requested channel code".into()))?;
412    let identifier = args
413        .get(1)
414        .and_then(|value| value.as_str())
415        .ok_or_else(|| DtxError::Protocol("missing requested channel identifier".into()))?;
416
417    if requested_code < i32::MIN as i64 || requested_code > i32::MAX as i64 {
418        return Err(DtxError::Protocol(
419            "requested channel code out of range".into(),
420        ));
421    }
422
423    Ok((requested_code as i32, identifier.to_string()))
424}
425
426impl<S> TestmanagerClient<S, S>
427where
428    S: AsyncRead + AsyncWrite + Unpin + Send,
429{
430    /// Test-only constructor: single session connection, no control connection.
431    #[cfg(feature = "testmanager")]
432    pub fn from_session_connection_for_test(session_stream: S, session_channel: i32) -> Self {
433        Self {
434            session: DtxConnection::new(session_stream),
435            session_channel,
436            driver_channel: None,
437            control: None,
438        }
439    }
440
441    /// Test-only constructor: both session and control connections.
442    #[cfg(feature = "testmanager")]
443    pub fn from_connections_for_test(
444        session_stream: S,
445        session_channel: i32,
446        control_stream: S,
447        control_channel: i32,
448    ) -> Self {
449        Self {
450            session: DtxConnection::new(session_stream),
451            session_channel,
452            driver_channel: None,
453            control: Some((DtxConnection::new(control_stream), control_channel)),
454        }
455    }
456}