1use std::collections::HashMap;
2
3use crate::proto::nskeyedarchiver_encode::{NsUrl, XcTestConfiguration, XctCapabilities};
4use plist::{Dictionary, Uid, Value};
5use uuid::Uuid;
6
7use super::xctestrun::SchemeData;
8
9const TARGET_APP_ENV_KEY: &str = "__IOS_TUNNEL_TARGET_APP_ENV_JSON";
10const TARGET_APP_ARGS_KEY: &str = "__IOS_TUNNEL_TARGET_APP_ARGS_JSON";
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct InstalledAppInfo {
14 pub bundle_id: String,
15 pub path: String,
16 pub executable: String,
17 pub container: Option<String>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct TestLaunchPlan {
22 pub runner: InstalledAppInfo,
23 pub target: Option<InstalledAppInfo>,
24 pub xctest_bundle_name: String,
25 pub is_xctest: bool,
26 pub args: Vec<String>,
27 pub env: HashMap<String, String>,
28 pub tests_to_run: Vec<String>,
29 pub tests_to_skip: Vec<String>,
30}
31
32impl TestLaunchPlan {
33 pub fn from_scheme(
34 scheme: &SchemeData,
35 runner: InstalledAppInfo,
36 target: Option<InstalledAppInfo>,
37 ) -> Self {
38 let mut env = HashMap::new();
39 merge_string_values(&mut env, &scheme.environment_variables);
40 merge_string_values(&mut env, &scheme.testing_environment_variables);
41 merge_string_values(&mut env, &scheme.ui_target_app_environment_variables);
42 store_target_app_context(
43 &mut env,
44 &scheme.ui_target_app_environment_variables,
45 &scheme.ui_target_app_command_line_arguments,
46 );
47
48 Self {
49 runner,
50 target,
51 xctest_bundle_name: bundle_name_from_path(&scheme.test_bundle_path),
52 is_xctest: !scheme.is_ui_test_bundle,
53 args: scheme.command_line_arguments.clone(),
54 env,
55 tests_to_run: scheme.only_test_identifiers.clone(),
56 tests_to_skip: scheme.skip_test_identifiers.clone(),
57 }
58 }
59
60 pub fn test_bundle_path(&self) -> String {
61 format!("{}/PlugIns/{}", self.runner.path, self.xctest_bundle_name)
62 }
63
64 pub fn xctest_configuration(
65 &self,
66 product_major_version: u64,
67 session_identifier: Uuid,
68 ) -> XcTestConfiguration {
69 let automation_framework_path = if product_major_version >= 17 {
70 "/System/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework"
71 } else {
72 "/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework"
73 };
74
75 let mut additional_fields = reference_default_xctest_fields();
76
77 if let Some(target) = &self.target {
78 additional_fields.push((
79 "productModuleName".to_string(),
80 Value::String(product_module_name(&self.xctest_bundle_name)),
81 ));
82 additional_fields.push((
83 "targetApplicationBundleID".to_string(),
84 Value::String(target.bundle_id.clone()),
85 ));
86 additional_fields.push((
87 "targetApplicationPath".to_string(),
88 Value::String(target.path.clone()),
89 ));
90 additional_fields.push((
91 "targetApplicationArguments".to_string(),
92 Value::Array(
93 self.target_application_arguments()
94 .into_iter()
95 .map(Value::String)
96 .collect(),
97 ),
98 ));
99 additional_fields.push((
100 "targetApplicationEnvironment".to_string(),
101 Value::Dictionary(self.target_application_environment()),
102 ));
103 }
104
105 if !self.tests_to_run.is_empty() {
106 additional_fields.push((
107 "testsToRun".to_string(),
108 Value::Array(
109 self.tests_to_run
110 .iter()
111 .cloned()
112 .map(Value::String)
113 .collect(),
114 ),
115 ));
116 }
117 if !self.tests_to_skip.is_empty() {
118 additional_fields.push((
119 "testsToSkip".to_string(),
120 Value::Array(
121 self.tests_to_skip
122 .iter()
123 .cloned()
124 .map(Value::String)
125 .collect(),
126 ),
127 ));
128 }
129
130 XcTestConfiguration {
131 session_identifier,
132 test_bundle_url: NsUrl {
133 path: self.test_bundle_path(),
134 },
135 ide_capabilities: default_capabilities(),
136 automation_framework_path: automation_framework_path.to_string(),
137 initialize_for_ui_testing: !self.is_xctest,
138 report_results_to_ide: true,
139 tests_must_run_on_main_thread: true,
140 test_timeouts_enabled: false,
141 additional_fields,
142 }
143 }
144
145 pub fn launch_environment(
146 &self,
147 product_major_version: u64,
148 session_identifier: Uuid,
149 ) -> HashMap<String, String> {
150 let mut env = HashMap::from([
151 (
152 "CA_ASSERT_MAIN_THREAD_TRANSACTIONS".to_string(),
153 "0".to_string(),
154 ),
155 ("CA_DEBUG_TRANSACTIONS".to_string(), "0".to_string()),
156 (
157 "DYLD_FRAMEWORK_PATH".to_string(),
158 format!("{}/Frameworks:", self.runner.path),
159 ),
160 (
161 "DYLD_LIBRARY_PATH".to_string(),
162 format!("{}/Frameworks", self.runner.path),
163 ),
164 ("MTC_CRASH_ON_REPORT".to_string(), "1".to_string()),
165 ("NSUnbufferedIO".to_string(), "YES".to_string()),
166 (
167 "SQLITE_ENABLE_THREAD_ASSERTIONS".to_string(),
168 "1".to_string(),
169 ),
170 ("WDA_PRODUCT_BUNDLE_IDENTIFIER".to_string(), String::new()),
171 ("XCTestBundlePath".to_string(), self.test_bundle_path()),
172 (
173 "XCTestSessionIdentifier".to_string(),
174 session_identifier.to_string().to_uppercase(),
175 ),
176 (
177 "XCODE_DBG_XPC_EXCLUSIONS".to_string(),
178 "com.apple.dt.xctestSymbolicator".to_string(),
179 ),
180 ]);
181
182 if let Some(container) = &self.runner.container {
183 env.insert(
184 "XCTestConfigurationFilePath".to_string(),
185 format!(
186 "{container}/tmp/{}.xctestconfiguration",
187 session_identifier.to_string().to_uppercase()
188 ),
189 );
190 }
191 if product_major_version >= 11 {
192 env.insert(
193 "DYLD_INSERT_LIBRARIES".to_string(),
194 "/Developer/usr/lib/libMainThreadChecker.dylib".to_string(),
195 );
196 env.insert("OS_ACTIVITY_DT_MODE".to_string(), "YES".to_string());
197 }
198 if product_major_version >= 17 {
199 env.insert(
200 "DYLD_FRAMEWORK_PATH".to_string(),
201 format!(
202 "{}/Frameworks:/System/Developer/Library/Frameworks:",
203 self.runner.path
204 ),
205 );
206 env.insert(
207 "DYLD_LIBRARY_PATH".to_string(),
208 format!("{}/Frameworks:/System/Developer/usr/lib", self.runner.path),
209 );
210 env.insert("XCTestConfigurationFilePath".to_string(), String::new());
212 env.insert("XCTestManagerVariant".to_string(), "DDI".to_string());
213 }
214
215 for (key, value) in &self.env {
216 if is_internal_target_app_key(key) {
217 continue;
218 }
219 env.insert(key.clone(), value.clone());
220 }
221 env
222 }
223
224 pub fn launch_arguments(&self) -> Vec<String> {
225 let mut args = vec![
226 "-NSTreatUnknownArgumentsAsOpen".to_string(),
227 "NO".to_string(),
228 "-ApplePersistenceIgnoreState".to_string(),
229 "YES".to_string(),
230 ];
231 args.extend(self.args.clone());
232 args
233 }
234
235 pub fn launch_options(&self, product_major_version: u64) -> Vec<(String, Value)> {
236 let mut options = vec![("StartSuspendedKey".to_string(), Value::Boolean(false))];
237 if product_major_version >= 12 {
238 options.push(("ActivateSuspended".to_string(), Value::Boolean(true)));
239 }
240 options
241 }
242
243 fn target_application_arguments(&self) -> Vec<String> {
244 self.env
245 .get(TARGET_APP_ARGS_KEY)
246 .and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
247 .unwrap_or_default()
248 }
249
250 fn target_application_environment(&self) -> plist::Dictionary {
251 self.env
252 .get(TARGET_APP_ENV_KEY)
253 .and_then(|value| serde_json::from_str::<HashMap<String, String>>(value).ok())
254 .map(|env| {
255 env.into_iter()
256 .map(|(key, value)| (key, Value::String(value)))
257 .collect()
258 })
259 .unwrap_or_default()
260 }
261}
262
263fn store_target_app_context(
264 dst: &mut HashMap<String, String>,
265 env: &HashMap<String, Value>,
266 args: &[String],
267) {
268 let mut target_env = HashMap::new();
269 merge_string_values(&mut target_env, env);
270 if !target_env.is_empty() {
271 dst.insert(
274 TARGET_APP_ENV_KEY.to_string(),
275 serde_json::to_string(&target_env).unwrap(),
276 );
277 }
278 if !args.is_empty() {
279 dst.insert(
281 TARGET_APP_ARGS_KEY.to_string(),
282 serde_json::to_string(args).unwrap(),
283 );
284 }
285}
286
287fn merge_string_values(dst: &mut HashMap<String, String>, src: &HashMap<String, Value>) {
288 for (key, value) in src {
289 if let Some(value) = value_as_string(value) {
290 dst.insert(key.clone(), value);
291 }
292 }
293}
294
295fn value_as_string(value: &Value) -> Option<String> {
296 match value {
297 Value::String(s) => Some(s.clone()),
298 Value::Boolean(flag) => Some(if *flag { "true" } else { "false" }.to_string()),
299 Value::Integer(n) => Some(n.to_string()),
300 Value::Real(n) => Some(n.to_string()),
301 _ => None,
302 }
303}
304
305fn is_internal_target_app_key(key: &str) -> bool {
306 matches!(key, TARGET_APP_ENV_KEY | TARGET_APP_ARGS_KEY)
307}
308
309fn bundle_name_from_path(path: &str) -> String {
310 path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
311}
312
313fn product_module_name(xctest_bundle_name: &str) -> String {
314 xctest_bundle_name.trim_end_matches(".xctest").to_string()
315}
316
317fn default_capabilities() -> XctCapabilities {
318 XctCapabilities {
319 capabilities: vec![
320 (
321 "expected failure test capability".to_string(),
322 Value::Boolean(true),
323 ),
324 (
325 "test case run configurations".to_string(),
326 Value::Boolean(true),
327 ),
328 ("test timeout capability".to_string(), Value::Boolean(true)),
329 ("test iterations".to_string(), Value::Boolean(true)),
330 (
331 "request diagnostics for specific devices".to_string(),
332 Value::Boolean(true),
333 ),
334 (
335 "delayed attachment transfer".to_string(),
336 Value::Boolean(true),
337 ),
338 ("skipped test capability".to_string(), Value::Boolean(true)),
339 (
340 "daemon container sandbox extension".to_string(),
341 Value::Boolean(true),
342 ),
343 (
344 "ubiquitous test identifiers".to_string(),
345 Value::Boolean(true),
346 ),
347 ("XCTIssue capability".to_string(), Value::Boolean(true)),
348 ],
349 }
350}
351
352fn reference_default_xctest_fields() -> Vec<(String, Value)> {
353 vec![
354 (
355 "aggregateStatisticsBeforeCrash".to_string(),
356 Value::Dictionary(Dictionary::from_iter([(
357 "XCSuiteRecordsKey".to_string(),
358 Value::Dictionary(Dictionary::new()),
359 )])),
360 ),
361 ("baselineFileRelativePath".to_string(), ns_null()),
362 ("baselineFileURL".to_string(), ns_null()),
363 ("defaultTestExecutionTimeAllowance".to_string(), ns_null()),
364 (
365 "disablePerformanceMetrics".to_string(),
366 Value::Boolean(false),
367 ),
368 ("emitOSLogs".to_string(), Value::Boolean(false)),
369 (
370 "gatherLocalizableStringsData".to_string(),
371 Value::Boolean(false),
372 ),
373 ("maximumTestExecutionTimeAllowance".to_string(), ns_null()),
374 ("randomExecutionOrderingSeed".to_string(), ns_null()),
375 ("reportActivities".to_string(), Value::Boolean(true)),
376 (
377 "systemAttachmentLifetime".to_string(),
378 Value::Integer(2.into()),
379 ),
380 (
381 "testApplicationDependencies".to_string(),
382 Value::Dictionary(Dictionary::new()),
383 ),
384 ("testApplicationUserOverrides".to_string(), ns_null()),
385 ("testBundleRelativePath".to_string(), ns_null()),
386 (
387 "testExecutionOrdering".to_string(),
388 Value::Integer(0.into()),
389 ),
390 ("testsDrivenByIDE".to_string(), Value::Boolean(false)),
391 (
392 "treatMissingBaselinesAsFailures".to_string(),
393 Value::Boolean(false),
394 ),
395 (
396 "userAttachmentLifetime".to_string(),
397 Value::Integer(0.into()),
398 ),
399 (
400 "preferredScreenCaptureFormat".to_string(),
401 Value::Integer(2.into()),
402 ),
403 ]
404}
405
406fn ns_null() -> Value {
407 Value::Uid(Uid::new(0))
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 fn runner() -> InstalledAppInfo {
415 InstalledAppInfo {
416 bundle_id: "com.example.Runner".to_string(),
417 path: "/private/var/containers/Bundle/Application/Runner.app".to_string(),
418 executable: "DemoAppUITests-Runner".to_string(),
419 container: Some("/private/var/mobile/Containers/Data/Application/Runner".to_string()),
420 }
421 }
422
423 #[test]
424 fn launch_environment_uses_ddi_variant_on_ios17() {
425 let plan = TestLaunchPlan {
426 runner: runner(),
427 target: None,
428 xctest_bundle_name: "DemoAppUITests.xctest".to_string(),
429 is_xctest: false,
430 args: Vec::new(),
431 env: HashMap::new(),
432 tests_to_run: Vec::new(),
433 tests_to_skip: Vec::new(),
434 };
435
436 let env = plan.launch_environment(
437 17,
438 Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff").unwrap(),
439 );
440 assert_eq!(
441 env.get("XCTestManagerVariant").map(String::as_str),
442 Some("DDI")
443 );
444 assert_eq!(
445 env.get("XCTestConfigurationFilePath").map(String::as_str),
446 Some("")
447 );
448 }
449
450 #[test]
451 fn from_scheme_preserves_target_app_context_without_changing_runner_env_behavior() {
452 let scheme = SchemeData {
453 test_host_bundle_identifier: "com.example.Runner".to_string(),
454 test_bundle_path: "DemoAppUITests.xctest".to_string(),
455 skip_test_identifiers: Vec::new(),
456 only_test_identifiers: vec!["DemoAppUITests/LoginTests/testHappyPath".to_string()],
457 is_ui_test_bundle: true,
458 command_line_arguments: vec!["-RunnerFlag".to_string()],
459 environment_variables: HashMap::from([(
460 "RUNNER_ENV".to_string(),
461 Value::String("runner".to_string()),
462 )]),
463 testing_environment_variables: HashMap::new(),
464 ui_target_app_environment_variables: HashMap::from([(
465 "TARGET_ENV".to_string(),
466 Value::String("target".to_string()),
467 )]),
468 ui_target_app_command_line_arguments: vec![
469 "-AppleLanguages".to_string(),
470 "(en)".to_string(),
471 ],
472 ui_target_app_path: "__TESTROOT__/Debug-iphoneos/DemoApp.app".to_string(),
473 };
474 let plan = TestLaunchPlan::from_scheme(
475 &scheme,
476 runner(),
477 Some(InstalledAppInfo {
478 bundle_id: "com.example.Target".to_string(),
479 path: "/private/var/containers/Bundle/Application/Target.app".to_string(),
480 executable: "DemoApp".to_string(),
481 container: None,
482 }),
483 );
484
485 let launch_env = plan.launch_environment(
486 17,
487 Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff").unwrap(),
488 );
489 assert_eq!(
490 launch_env.get("RUNNER_ENV").map(String::as_str),
491 Some("runner")
492 );
493 assert_eq!(
494 launch_env.get("TARGET_ENV").map(String::as_str),
495 Some("target")
496 );
497 assert!(!launch_env.contains_key(TARGET_APP_ENV_KEY));
498 assert!(!launch_env.contains_key(TARGET_APP_ARGS_KEY));
499 }
500
501 #[test]
502 fn configuration_adds_target_application_fields_for_ui_tests() {
503 let mut env = HashMap::new();
504 store_target_app_context(
505 &mut env,
506 &HashMap::from([(
507 "TARGET_ENV".to_string(),
508 Value::String("target".to_string()),
509 )]),
510 &["-AppleLanguages".to_string(), "(en)".to_string()],
511 );
512 let plan = TestLaunchPlan {
513 runner: runner(),
514 target: Some(InstalledAppInfo {
515 bundle_id: "com.example.Target".to_string(),
516 path: "/private/var/containers/Bundle/Application/Target.app".to_string(),
517 executable: "DemoApp".to_string(),
518 container: None,
519 }),
520 xctest_bundle_name: "DemoAppUITests.xctest".to_string(),
521 is_xctest: false,
522 args: Vec::new(),
523 env,
524 tests_to_run: vec!["DemoAppUITests/LoginTests/testHappyPath".to_string()],
525 tests_to_skip: Vec::new(),
526 };
527
528 let config = plan.xctest_configuration(
529 17,
530 Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff").unwrap(),
531 );
532 assert!(config
533 .additional_fields
534 .iter()
535 .any(|(key, _)| key == "targetApplicationBundleID"));
536 assert!(config
537 .additional_fields
538 .iter()
539 .any(|(key, _)| key == "testsToRun"));
540 assert!(config.additional_fields.iter().any(|(key, value)| {
541 key == "targetApplicationArguments"
542 && matches!(
543 value,
544 Value::Array(items)
545 if items
546 == &vec![
547 Value::String("-AppleLanguages".to_string()),
548 Value::String("(en)".to_string()),
549 ]
550 )
551 }));
552 assert!(config.additional_fields.iter().any(|(key, value)| {
553 key == "targetApplicationEnvironment"
554 && matches!(
555 value,
556 Value::Dictionary(items)
557 if items.get("TARGET_ENV") == Some(&Value::String("target".to_string()))
558 )
559 }));
560 }
561
562 #[test]
563 fn configuration_includes_reference_default_fields() {
564 let plan = TestLaunchPlan {
565 runner: runner(),
566 target: None,
567 xctest_bundle_name: "DemoAppUITests.xctest".to_string(),
568 is_xctest: false,
569 args: Vec::new(),
570 env: HashMap::new(),
571 tests_to_run: Vec::new(),
572 tests_to_skip: Vec::new(),
573 };
574
575 let config = plan.xctest_configuration(
576 17,
577 Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff").unwrap(),
578 );
579
580 assert!(config.additional_fields.iter().any(|(key, value)| {
581 key == "aggregateStatisticsBeforeCrash"
582 && matches!(
583 value,
584 Value::Dictionary(stats)
585 if matches!(
586 stats.get("XCSuiteRecordsKey"),
587 Some(Value::Dictionary(suites)) if suites.is_empty()
588 )
589 )
590 }));
591 assert!(config.additional_fields.iter().any(|(key, value)| {
592 key == "disablePerformanceMetrics" && value.as_boolean() == Some(false)
593 }));
594 assert!(config.additional_fields.iter().any(|(key, value)| {
595 key == "systemAttachmentLifetime" && value.as_signed_integer() == Some(2)
596 }));
597 assert!(config.additional_fields.iter().any(|(key, value)| {
598 key == "preferredScreenCaptureFormat" && value.as_signed_integer() == Some(2)
599 }));
600 assert!(config.additional_fields.iter().any(|(key, value)| {
601 key == "testsDrivenByIDE" && value.as_boolean() == Some(false)
602 }));
603 }
604}