Skip to main content

ios_core/services/testmanager/
results.rs

1//! XCTest result event parsing and run summary accumulation.
2//!
3//! Testmanager reports progress as DTX method invocations using private XCTest
4//! selectors. This module translates the selectors into stable Rust events and
5//! accumulates those events into a serializable summary for CLI and binding users.
6
7use crate::services::dtx::{DtxMessage, DtxPayload, NSObject};
8use serde::Serialize;
9
10/// XCTest selector emitted when a test plan begins.
11pub const DID_BEGIN_EXECUTING_TEST_PLAN_SELECTOR: &str = "_XCT_didBeginExecutingTestPlan";
12/// XCTest selector emitted when a test plan finishes.
13pub const DID_FINISH_EXECUTING_TEST_PLAN_SELECTOR: &str = "_XCT_didFinishExecutingTestPlan";
14/// XCTest selector for normal log messages.
15pub const LOG_MESSAGE_SELECTOR: &str = "_XCT_logMessage:";
16/// XCTest selector for debug log messages.
17pub const LOG_DEBUG_MESSAGE_SELECTOR: &str = "_XCT_logDebugMessage:";
18/// XCTest selector emitted when a suite starts.
19pub const TEST_SUITE_STARTED_SELECTOR: &str = "_XCT_testSuite:didStartAt:";
20/// XCTest selector emitted when older XCTest runtimes finish a suite.
21pub const TEST_SUITE_FINISHED_SELECTOR: &str =
22    "_XCT_testSuite:didFinishAt:runCount:withFailures:unexpected:testDuration:totalDuration:";
23/// XCTest selector emitted when newer XCTest runtimes finish a suite with skip counts.
24pub const TEST_SUITE_FINISHED_WITH_SKIP_SELECTOR: &str =
25    "_XCT_testSuiteWithIdentifier:didFinishAt:runCount:skipCount:failureCount:expectedFailureCount:uncaughtExceptionCount:testDuration:totalDuration:";
26/// XCTest selector emitted when a test case starts.
27pub const TEST_CASE_STARTED_SELECTOR: &str = "_XCT_testCaseDidStartForTestClass:method:";
28/// XCTest selector emitted when a test case finishes.
29pub const TEST_CASE_FINISHED_SELECTOR: &str =
30    "_XCT_testCaseDidFinishForTestClass:method:withStatus:duration:";
31/// XCTest selector emitted when a test case records a failure.
32pub const TEST_CASE_FAILED_SELECTOR: &str =
33    "_XCT_testCaseDidFailForTestClass:method:withMessage:file:line:";
34
35/// A normalized XCTest execution event decoded from a DTX method invocation.
36#[derive(Debug, Clone, PartialEq, Serialize)]
37#[serde(tag = "type", rename_all = "snake_case")]
38pub enum TestExecutionEvent {
39    /// The test plan started.
40    BeganPlan,
41    /// The test plan finished.
42    FinishedPlan,
43    /// XCTest emitted a log message.
44    Log {
45        /// Log message text.
46        message: String,
47        /// Whether the message came from the debug log selector.
48        debug: bool,
49    },
50    /// A test suite started.
51    SuiteStarted {
52        /// Suite name.
53        name: String,
54        /// Start timestamp string as reported by XCTest.
55        started_at: Option<String>,
56    },
57    /// A test suite finished and reported aggregate counts.
58    SuiteFinished {
59        /// Suite name.
60        name: String,
61        /// Finish timestamp string as reported by XCTest.
62        finished_at: Option<String>,
63        /// Number of tests reported by the suite.
64        test_count: u64,
65        /// Number of skipped tests.
66        skipped: u64,
67        /// Number of failures.
68        failures: u64,
69        /// Number of expected failures.
70        expected_failures: u64,
71        /// Number of unexpected failures.
72        unexpected_failures: u64,
73        /// Number of uncaught exceptions.
74        uncaught_exceptions: u64,
75        /// XCTest execution duration in seconds.
76        test_duration_seconds: f64,
77        /// Total suite duration in seconds.
78        total_duration_seconds: f64,
79    },
80    /// A test case started.
81    CaseStarted {
82        /// XCTest class name.
83        class_name: String,
84        /// XCTest method name.
85        method_name: String,
86    },
87    /// A test case reported a failure.
88    CaseFailed {
89        /// XCTest class name.
90        class_name: String,
91        /// XCTest method name.
92        method_name: String,
93        /// Failure message.
94        message: String,
95        /// Source file when XCTest reports one.
96        file: Option<String>,
97        /// Source line when XCTest reports one.
98        line: Option<u64>,
99    },
100    /// A test case finished with a final status.
101    CaseFinished {
102        /// XCTest class name.
103        class_name: String,
104        /// XCTest method name.
105        method_name: String,
106        /// Final test status.
107        status: TestCaseStatus,
108        /// Test case duration in seconds.
109        duration_seconds: f64,
110    },
111}
112
113impl TestExecutionEvent {
114    /// Decode a supported XCTest DTX method invocation.
115    pub fn from_dtx_message(message: &DtxMessage) -> Option<Self> {
116        let DtxPayload::MethodInvocation { selector, args } = &message.payload else {
117            return None;
118        };
119        match selector.as_str() {
120            DID_BEGIN_EXECUTING_TEST_PLAN_SELECTOR => Some(Self::BeganPlan),
121            DID_FINISH_EXECUTING_TEST_PLAN_SELECTOR => Some(Self::FinishedPlan),
122            LOG_MESSAGE_SELECTOR => Some(Self::Log {
123                message: string_arg(args, 0)?,
124                debug: false,
125            }),
126            LOG_DEBUG_MESSAGE_SELECTOR => Some(Self::Log {
127                message: string_arg(args, 0)?,
128                debug: true,
129            }),
130            TEST_SUITE_STARTED_SELECTOR => Some(Self::SuiteStarted {
131                name: string_arg(args, 0)?,
132                started_at: optional_string_arg(args, 1),
133            }),
134            TEST_SUITE_FINISHED_SELECTOR => Some(Self::SuiteFinished {
135                name: string_arg(args, 0)?,
136                finished_at: optional_string_arg(args, 1),
137                test_count: uint_arg(args, 2).unwrap_or(0),
138                skipped: 0,
139                failures: uint_arg(args, 3).unwrap_or(0),
140                expected_failures: 0,
141                unexpected_failures: uint_arg(args, 4).unwrap_or(0),
142                uncaught_exceptions: 0,
143                test_duration_seconds: double_arg(args, 5).unwrap_or(0.0),
144                total_duration_seconds: double_arg(args, 6).unwrap_or(0.0),
145            }),
146            TEST_SUITE_FINISHED_WITH_SKIP_SELECTOR => {
147                let name = identifier_suite_name(args.first())?;
148                Some(Self::SuiteFinished {
149                    name,
150                    finished_at: optional_string_arg(args, 1),
151                    test_count: uint_arg(args, 2).unwrap_or(0),
152                    skipped: uint_arg(args, 3).unwrap_or(0),
153                    failures: uint_arg(args, 4).unwrap_or(0),
154                    expected_failures: uint_arg(args, 5).unwrap_or(0),
155                    unexpected_failures: 0,
156                    uncaught_exceptions: uint_arg(args, 6).unwrap_or(0),
157                    test_duration_seconds: double_arg(args, 7).unwrap_or(0.0),
158                    total_duration_seconds: double_arg(args, 8).unwrap_or(0.0),
159                })
160            }
161            TEST_CASE_STARTED_SELECTOR => Some(Self::CaseStarted {
162                class_name: string_arg(args, 0)?,
163                method_name: string_arg(args, 1)?,
164            }),
165            TEST_CASE_FAILED_SELECTOR => Some(Self::CaseFailed {
166                class_name: string_arg(args, 0)?,
167                method_name: string_arg(args, 1)?,
168                message: string_arg(args, 2).unwrap_or_default(),
169                file: optional_string_arg(args, 3),
170                line: uint_arg(args, 4),
171            }),
172            TEST_CASE_FINISHED_SELECTOR => Some(Self::CaseFinished {
173                class_name: string_arg(args, 0)?,
174                method_name: string_arg(args, 1)?,
175                status: TestCaseStatus::from_wda_status(&string_arg(args, 2)?),
176                duration_seconds: double_arg(args, 3).unwrap_or(0.0),
177            }),
178            _ => None,
179        }
180    }
181
182    /// Return true when this event marks the end of the plan.
183    pub fn is_finished_plan(&self) -> bool {
184        matches!(self, Self::FinishedPlan)
185    }
186}
187
188/// Normalized XCTest case status.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
190#[serde(rename_all = "snake_case")]
191pub enum TestCaseStatus {
192    /// Test passed.
193    Passed,
194    /// Test failed.
195    Failed,
196    /// XCTest reported an expected failure.
197    ExpectedFailure,
198    /// XCTest reported a stalled case.
199    Stalled,
200    /// Test was skipped.
201    Skipped,
202    /// Status string not modeled by ios-core yet.
203    Other(String),
204}
205
206impl TestCaseStatus {
207    fn from_wda_status(status: &str) -> Self {
208        match status {
209            "passed" => Self::Passed,
210            "failed" => Self::Failed,
211            "expected failure" => Self::ExpectedFailure,
212            "stalled" => Self::Stalled,
213            "skipped" => Self::Skipped,
214            other => Self::Other(other.to_string()),
215        }
216    }
217}
218
219/// Failure details for a single test case.
220#[derive(Debug, Clone, PartialEq, Serialize)]
221pub struct TestFailure {
222    /// Failure message emitted by XCTest.
223    pub message: String,
224    /// Source file path when available.
225    pub file: Option<String>,
226    /// Source line when available.
227    pub line: Option<u64>,
228}
229
230/// Summary for one XCTest case.
231#[derive(Debug, Clone, PartialEq, Serialize)]
232pub struct TestCaseSummary {
233    /// XCTest class name.
234    pub class_name: String,
235    /// XCTest method name.
236    pub method_name: String,
237    /// Final case status, if observed.
238    pub status: Option<TestCaseStatus>,
239    /// Case duration in seconds, if reported.
240    pub duration_seconds: Option<f64>,
241    /// First failure associated with the case, if any.
242    pub failure: Option<TestFailure>,
243}
244
245/// Summary for one XCTest suite.
246#[derive(Debug, Clone, PartialEq, Serialize)]
247pub struct TestSuiteSummary {
248    /// Suite name.
249    pub name: String,
250    /// Start timestamp string as reported by XCTest.
251    pub started_at: Option<String>,
252    /// Finish timestamp string as reported by XCTest.
253    pub finished_at: Option<String>,
254    /// Total tests reported by the suite.
255    pub test_count: Option<u64>,
256    /// Skipped test count.
257    pub skipped: Option<u64>,
258    /// Failure count.
259    pub failures: Option<u64>,
260    /// Expected failure count.
261    pub expected_failures: Option<u64>,
262    /// Unexpected failure count.
263    pub unexpected_failures: Option<u64>,
264    /// Uncaught exception count.
265    pub uncaught_exceptions: Option<u64>,
266    /// XCTest execution duration in seconds.
267    pub test_duration_seconds: Option<f64>,
268    /// Total suite duration in seconds.
269    pub total_duration_seconds: Option<f64>,
270    /// Case summaries accumulated for this suite.
271    pub cases: Vec<TestCaseSummary>,
272}
273
274/// Summary for an XCTest run.
275#[derive(Debug, Clone, PartialEq, Serialize)]
276pub struct TestRunSummary {
277    /// Whether a plan-start event was observed.
278    pub began: bool,
279    /// Whether a plan-finish event was observed.
280    pub finished: bool,
281    /// Total test count across suites.
282    pub total_tests: u64,
283    /// Total failed test count across suites.
284    pub failed_tests: u64,
285    /// Total skipped test count across suites.
286    pub skipped_tests: u64,
287    /// Non-debug log messages.
288    pub logs: Vec<String>,
289    /// Debug log messages.
290    pub debug_logs: Vec<String>,
291    /// Suite summaries.
292    pub suites: Vec<TestSuiteSummary>,
293}
294
295/// Stateful accumulator for XCTest events.
296#[derive(Debug, Default, Clone)]
297pub struct TestRunRecorder {
298    began: bool,
299    finished: bool,
300    logs: Vec<String>,
301    debug_logs: Vec<String>,
302    suites: Vec<TestSuiteSummary>,
303}
304
305impl TestRunRecorder {
306    /// Apply one event to the current run summary.
307    pub fn apply(&mut self, event: TestExecutionEvent) {
308        match event {
309            TestExecutionEvent::BeganPlan => self.began = true,
310            TestExecutionEvent::FinishedPlan => self.finished = true,
311            TestExecutionEvent::Log { message, debug } => {
312                if debug {
313                    self.debug_logs.push(message);
314                } else {
315                    self.logs.push(message);
316                }
317            }
318            TestExecutionEvent::SuiteStarted { name, started_at } => {
319                self.suites.push(TestSuiteSummary {
320                    name,
321                    started_at,
322                    finished_at: None,
323                    test_count: None,
324                    skipped: None,
325                    failures: None,
326                    expected_failures: None,
327                    unexpected_failures: None,
328                    uncaught_exceptions: None,
329                    test_duration_seconds: None,
330                    total_duration_seconds: None,
331                    cases: Vec::new(),
332                });
333            }
334            TestExecutionEvent::SuiteFinished {
335                name,
336                finished_at,
337                test_count,
338                skipped,
339                failures,
340                expected_failures,
341                unexpected_failures,
342                uncaught_exceptions,
343                test_duration_seconds,
344                total_duration_seconds,
345            } => {
346                let suite = self.find_or_create_suite(&name);
347                suite.finished_at = finished_at;
348                suite.test_count = Some(test_count);
349                suite.skipped = Some(skipped);
350                suite.failures = Some(failures);
351                suite.expected_failures = Some(expected_failures);
352                suite.unexpected_failures = Some(unexpected_failures);
353                suite.uncaught_exceptions = Some(uncaught_exceptions);
354                suite.test_duration_seconds = Some(test_duration_seconds);
355                suite.total_duration_seconds = Some(total_duration_seconds);
356            }
357            TestExecutionEvent::CaseStarted {
358                class_name,
359                method_name,
360            } => {
361                let suite = self.find_or_create_suite(&class_name);
362                suite.cases.push(TestCaseSummary {
363                    class_name,
364                    method_name,
365                    status: None,
366                    duration_seconds: None,
367                    failure: None,
368                });
369            }
370            TestExecutionEvent::CaseFailed {
371                class_name,
372                method_name,
373                message,
374                file,
375                line,
376            } => {
377                let case = self.find_or_create_case(&class_name, &method_name);
378                case.status = Some(TestCaseStatus::Failed);
379                case.failure = Some(TestFailure {
380                    message,
381                    file,
382                    line,
383                });
384            }
385            TestExecutionEvent::CaseFinished {
386                class_name,
387                method_name,
388                status,
389                duration_seconds,
390            } => {
391                let case = self.find_or_create_case(&class_name, &method_name);
392                if case.status != Some(TestCaseStatus::Stalled) {
393                    case.status = Some(status);
394                }
395                case.duration_seconds = Some(duration_seconds);
396            }
397        }
398    }
399
400    /// Build a serializable summary from the events applied so far.
401    pub fn summary(&self) -> TestRunSummary {
402        let total_tests = self
403            .suites
404            .iter()
405            .map(|suite| suite.test_count.unwrap_or(suite.cases.len() as u64))
406            .sum();
407        let failed_tests = self
408            .suites
409            .iter()
410            .map(|suite| {
411                suite.failures.unwrap_or_else(|| {
412                    suite
413                        .cases
414                        .iter()
415                        .filter(|case| case.status == Some(TestCaseStatus::Failed))
416                        .count() as u64
417                })
418            })
419            .sum();
420        let skipped_tests = self
421            .suites
422            .iter()
423            .map(|suite| {
424                suite.skipped.unwrap_or_else(|| {
425                    suite
426                        .cases
427                        .iter()
428                        .filter(|case| case.status == Some(TestCaseStatus::Skipped))
429                        .count() as u64
430                })
431            })
432            .sum();
433
434        TestRunSummary {
435            began: self.began,
436            finished: self.finished,
437            total_tests,
438            failed_tests,
439            skipped_tests,
440            logs: self.logs.clone(),
441            debug_logs: self.debug_logs.clone(),
442            suites: self.suites.clone(),
443        }
444    }
445
446    fn find_or_create_case(&mut self, class_name: &str, method_name: &str) -> &mut TestCaseSummary {
447        let suite = self.find_or_create_suite(class_name);
448        if let Some(index) = suite
449            .cases
450            .iter()
451            .rposition(|case| case.class_name == class_name && case.method_name == method_name)
452        {
453            return &mut suite.cases[index];
454        }
455        let index = suite.cases.len();
456        suite.cases.push(TestCaseSummary {
457            class_name: class_name.to_string(),
458            method_name: method_name.to_string(),
459            status: None,
460            duration_seconds: None,
461            failure: None,
462        });
463        &mut suite.cases[index]
464    }
465
466    fn find_or_create_suite(&mut self, name: &str) -> &mut TestSuiteSummary {
467        if let Some(index) = self.suites.iter().rposition(|suite| suite.name == name) {
468            return &mut self.suites[index];
469        }
470        let index = self.suites.len();
471        self.suites.push(TestSuiteSummary {
472            name: name.to_string(),
473            started_at: None,
474            finished_at: None,
475            test_count: None,
476            skipped: None,
477            failures: None,
478            expected_failures: None,
479            unexpected_failures: None,
480            uncaught_exceptions: None,
481            test_duration_seconds: None,
482            total_duration_seconds: None,
483            cases: Vec::new(),
484        });
485        &mut self.suites[index]
486    }
487}
488
489fn string_arg(args: &[NSObject], index: usize) -> Option<String> {
490    args.get(index)
491        .and_then(NSObject::as_str)
492        .map(ToString::to_string)
493}
494
495fn optional_string_arg(args: &[NSObject], index: usize) -> Option<String> {
496    string_arg(args, index).filter(|value| !value.is_empty())
497}
498
499fn uint_arg(args: &[NSObject], index: usize) -> Option<u64> {
500    match args.get(index)? {
501        NSObject::Uint(value) => Some(*value),
502        NSObject::Int(value) if *value >= 0 => Some(*value as u64),
503        _ => None,
504    }
505}
506
507fn double_arg(args: &[NSObject], index: usize) -> Option<f64> {
508    match args.get(index)? {
509        NSObject::Double(value) => Some(*value),
510        NSObject::Int(value) => Some(*value as f64),
511        NSObject::Uint(value) => Some(*value as f64),
512        _ => None,
513    }
514}
515
516fn identifier_suite_name(value: Option<&NSObject>) -> Option<String> {
517    match value? {
518        NSObject::String(value) => Some(value.clone()),
519        NSObject::Array(values) => values.first().and_then(|value| match value {
520            NSObject::String(name) => Some(name.clone()),
521            _ => None,
522        }),
523        NSObject::Dict(dict) => dict
524            .get("container")
525            .or_else(|| dict.get("suite"))
526            .or_else(|| dict.get("testClass"))
527            .and_then(NSObject::as_str)
528            .map(ToString::to_string),
529        _ => None,
530    }
531}