1use crate::services::dtx::{DtxMessage, DtxPayload, NSObject};
8use serde::Serialize;
9
10pub const DID_BEGIN_EXECUTING_TEST_PLAN_SELECTOR: &str = "_XCT_didBeginExecutingTestPlan";
12pub const DID_FINISH_EXECUTING_TEST_PLAN_SELECTOR: &str = "_XCT_didFinishExecutingTestPlan";
14pub const LOG_MESSAGE_SELECTOR: &str = "_XCT_logMessage:";
16pub const LOG_DEBUG_MESSAGE_SELECTOR: &str = "_XCT_logDebugMessage:";
18pub const TEST_SUITE_STARTED_SELECTOR: &str = "_XCT_testSuite:didStartAt:";
20pub const TEST_SUITE_FINISHED_SELECTOR: &str =
22 "_XCT_testSuite:didFinishAt:runCount:withFailures:unexpected:testDuration:totalDuration:";
23pub const TEST_SUITE_FINISHED_WITH_SKIP_SELECTOR: &str =
25 "_XCT_testSuiteWithIdentifier:didFinishAt:runCount:skipCount:failureCount:expectedFailureCount:uncaughtExceptionCount:testDuration:totalDuration:";
26pub const TEST_CASE_STARTED_SELECTOR: &str = "_XCT_testCaseDidStartForTestClass:method:";
28pub const TEST_CASE_FINISHED_SELECTOR: &str =
30 "_XCT_testCaseDidFinishForTestClass:method:withStatus:duration:";
31pub const TEST_CASE_FAILED_SELECTOR: &str =
33 "_XCT_testCaseDidFailForTestClass:method:withMessage:file:line:";
34
35#[derive(Debug, Clone, PartialEq, Serialize)]
37#[serde(tag = "type", rename_all = "snake_case")]
38pub enum TestExecutionEvent {
39 BeganPlan,
41 FinishedPlan,
43 Log {
45 message: String,
47 debug: bool,
49 },
50 SuiteStarted {
52 name: String,
54 started_at: Option<String>,
56 },
57 SuiteFinished {
59 name: String,
61 finished_at: Option<String>,
63 test_count: u64,
65 skipped: u64,
67 failures: u64,
69 expected_failures: u64,
71 unexpected_failures: u64,
73 uncaught_exceptions: u64,
75 test_duration_seconds: f64,
77 total_duration_seconds: f64,
79 },
80 CaseStarted {
82 class_name: String,
84 method_name: String,
86 },
87 CaseFailed {
89 class_name: String,
91 method_name: String,
93 message: String,
95 file: Option<String>,
97 line: Option<u64>,
99 },
100 CaseFinished {
102 class_name: String,
104 method_name: String,
106 status: TestCaseStatus,
108 duration_seconds: f64,
110 },
111}
112
113impl TestExecutionEvent {
114 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 pub fn is_finished_plan(&self) -> bool {
184 matches!(self, Self::FinishedPlan)
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
190#[serde(rename_all = "snake_case")]
191pub enum TestCaseStatus {
192 Passed,
194 Failed,
196 ExpectedFailure,
198 Stalled,
200 Skipped,
202 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#[derive(Debug, Clone, PartialEq, Serialize)]
221pub struct TestFailure {
222 pub message: String,
224 pub file: Option<String>,
226 pub line: Option<u64>,
228}
229
230#[derive(Debug, Clone, PartialEq, Serialize)]
232pub struct TestCaseSummary {
233 pub class_name: String,
235 pub method_name: String,
237 pub status: Option<TestCaseStatus>,
239 pub duration_seconds: Option<f64>,
241 pub failure: Option<TestFailure>,
243}
244
245#[derive(Debug, Clone, PartialEq, Serialize)]
247pub struct TestSuiteSummary {
248 pub name: String,
250 pub started_at: Option<String>,
252 pub finished_at: Option<String>,
254 pub test_count: Option<u64>,
256 pub skipped: Option<u64>,
258 pub failures: Option<u64>,
260 pub expected_failures: Option<u64>,
262 pub unexpected_failures: Option<u64>,
264 pub uncaught_exceptions: Option<u64>,
266 pub test_duration_seconds: Option<f64>,
268 pub total_duration_seconds: Option<f64>,
270 pub cases: Vec<TestCaseSummary>,
272}
273
274#[derive(Debug, Clone, PartialEq, Serialize)]
276pub struct TestRunSummary {
277 pub began: bool,
279 pub finished: bool,
281 pub total_tests: u64,
283 pub failed_tests: u64,
285 pub skipped_tests: u64,
287 pub logs: Vec<String>,
289 pub debug_logs: Vec<String>,
291 pub suites: Vec<TestSuiteSummary>,
293}
294
295#[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 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 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}