1use std::collections::HashMap;
2
3use crate::proto::nskeyedarchiver_encode::{NsUrl, XcTestConfiguration, XctCapabilities};
4use plist::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 = vec![
76 ("reportActivities".to_string(), Value::Boolean(true)),
77 (
78 "testApplicationDependencies".to_string(),
79 Value::Dictionary(Default::default()),
80 ),
81 ];
82
83 if let Some(target) = &self.target {
84 additional_fields.push((
85 "productModuleName".to_string(),
86 Value::String(product_module_name(&self.xctest_bundle_name)),
87 ));
88 additional_fields.push((
89 "targetApplicationBundleID".to_string(),
90 Value::String(target.bundle_id.clone()),
91 ));
92 additional_fields.push((
93 "targetApplicationPath".to_string(),
94 Value::String(target.path.clone()),
95 ));
96 additional_fields.push((
97 "targetApplicationArguments".to_string(),
98 Value::Array(
99 self.target_application_arguments()
100 .into_iter()
101 .map(Value::String)
102 .collect(),
103 ),
104 ));
105 additional_fields.push((
106 "targetApplicationEnvironment".to_string(),
107 Value::Dictionary(self.target_application_environment()),
108 ));
109 }
110
111 if !self.tests_to_run.is_empty() {
112 additional_fields.push((
113 "testsToRun".to_string(),
114 Value::Array(
115 self.tests_to_run
116 .iter()
117 .cloned()
118 .map(Value::String)
119 .collect(),
120 ),
121 ));
122 }
123 if !self.tests_to_skip.is_empty() {
124 additional_fields.push((
125 "testsToSkip".to_string(),
126 Value::Array(
127 self.tests_to_skip
128 .iter()
129 .cloned()
130 .map(Value::String)
131 .collect(),
132 ),
133 ));
134 }
135
136 XcTestConfiguration {
137 session_identifier,
138 test_bundle_url: NsUrl {
139 path: self.test_bundle_path(),
140 },
141 ide_capabilities: default_capabilities(),
142 automation_framework_path: automation_framework_path.to_string(),
143 initialize_for_ui_testing: !self.is_xctest,
144 report_results_to_ide: true,
145 tests_must_run_on_main_thread: true,
146 test_timeouts_enabled: false,
147 additional_fields,
148 }
149 }
150
151 pub fn launch_environment(
152 &self,
153 product_major_version: u64,
154 session_identifier: Uuid,
155 ) -> HashMap<String, String> {
156 let mut env = HashMap::from([
157 (
158 "CA_ASSERT_MAIN_THREAD_TRANSACTIONS".to_string(),
159 "0".to_string(),
160 ),
161 ("CA_DEBUG_TRANSACTIONS".to_string(), "0".to_string()),
162 (
163 "DYLD_FRAMEWORK_PATH".to_string(),
164 format!("{}/Frameworks:", self.runner.path),
165 ),
166 (
167 "DYLD_LIBRARY_PATH".to_string(),
168 format!("{}/Frameworks", self.runner.path),
169 ),
170 ("MTC_CRASH_ON_REPORT".to_string(), "1".to_string()),
171 ("NSUnbufferedIO".to_string(), "YES".to_string()),
172 (
173 "SQLITE_ENABLE_THREAD_ASSERTIONS".to_string(),
174 "1".to_string(),
175 ),
176 ("WDA_PRODUCT_BUNDLE_IDENTIFIER".to_string(), String::new()),
177 ("XCTestBundlePath".to_string(), self.test_bundle_path()),
178 (
179 "XCTestSessionIdentifier".to_string(),
180 session_identifier.to_string().to_uppercase(),
181 ),
182 (
183 "XCODE_DBG_XPC_EXCLUSIONS".to_string(),
184 "com.apple.dt.xctestSymbolicator".to_string(),
185 ),
186 ]);
187
188 if let Some(container) = &self.runner.container {
189 env.insert(
190 "XCTestConfigurationFilePath".to_string(),
191 format!(
192 "{container}/tmp/{}.xctestconfiguration",
193 session_identifier.to_string().to_uppercase()
194 ),
195 );
196 }
197 if product_major_version >= 11 {
198 env.insert(
199 "DYLD_INSERT_LIBRARIES".to_string(),
200 "/Developer/usr/lib/libMainThreadChecker.dylib".to_string(),
201 );
202 env.insert("OS_ACTIVITY_DT_MODE".to_string(), "YES".to_string());
203 }
204 if product_major_version >= 17 {
205 env.insert(
206 "DYLD_FRAMEWORK_PATH".to_string(),
207 format!(
208 "{}/Frameworks:/System/Developer/Library/Frameworks:",
209 self.runner.path
210 ),
211 );
212 env.insert(
213 "DYLD_LIBRARY_PATH".to_string(),
214 format!("{}/Frameworks:/System/Developer/usr/lib", self.runner.path),
215 );
216 env.insert("XCTestConfigurationFilePath".to_string(), String::new());
218 env.insert("XCTestManagerVariant".to_string(), "DDI".to_string());
219 }
220
221 for (key, value) in &self.env {
222 if is_internal_target_app_key(key) {
223 continue;
224 }
225 env.insert(key.clone(), value.clone());
226 }
227 env
228 }
229
230 pub fn launch_arguments(&self) -> Vec<String> {
231 let mut args = vec![
232 "-NSTreatUnknownArgumentsAsOpen".to_string(),
233 "NO".to_string(),
234 "-ApplePersistenceIgnoreState".to_string(),
235 "YES".to_string(),
236 ];
237 args.extend(self.args.clone());
238 args
239 }
240
241 pub fn launch_options(&self, product_major_version: u64) -> Vec<(String, Value)> {
242 let mut options = vec![("StartSuspendedKey".to_string(), Value::Boolean(false))];
243 if product_major_version >= 12 {
244 options.push(("ActivateSuspended".to_string(), Value::Boolean(true)));
245 }
246 options
247 }
248
249 fn target_application_arguments(&self) -> Vec<String> {
250 self.env
251 .get(TARGET_APP_ARGS_KEY)
252 .and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
253 .unwrap_or_default()
254 }
255
256 fn target_application_environment(&self) -> plist::Dictionary {
257 self.env
258 .get(TARGET_APP_ENV_KEY)
259 .and_then(|value| serde_json::from_str::<HashMap<String, String>>(value).ok())
260 .map(|env| {
261 env.into_iter()
262 .map(|(key, value)| (key, Value::String(value)))
263 .collect()
264 })
265 .unwrap_or_default()
266 }
267}
268
269fn store_target_app_context(
270 dst: &mut HashMap<String, String>,
271 env: &HashMap<String, Value>,
272 args: &[String],
273) {
274 let mut target_env = HashMap::new();
275 merge_string_values(&mut target_env, env);
276 if !target_env.is_empty() {
277 dst.insert(
280 TARGET_APP_ENV_KEY.to_string(),
281 serde_json::to_string(&target_env).unwrap(),
282 );
283 }
284 if !args.is_empty() {
285 dst.insert(
287 TARGET_APP_ARGS_KEY.to_string(),
288 serde_json::to_string(args).unwrap(),
289 );
290 }
291}
292
293fn merge_string_values(dst: &mut HashMap<String, String>, src: &HashMap<String, Value>) {
294 for (key, value) in src {
295 if let Some(value) = value_as_string(value) {
296 dst.insert(key.clone(), value);
297 }
298 }
299}
300
301fn value_as_string(value: &Value) -> Option<String> {
302 match value {
303 Value::String(s) => Some(s.clone()),
304 Value::Boolean(flag) => Some(if *flag { "true" } else { "false" }.to_string()),
305 Value::Integer(n) => Some(n.to_string()),
306 Value::Real(n) => Some(n.to_string()),
307 _ => None,
308 }
309}
310
311fn is_internal_target_app_key(key: &str) -> bool {
312 matches!(key, TARGET_APP_ENV_KEY | TARGET_APP_ARGS_KEY)
313}
314
315fn bundle_name_from_path(path: &str) -> String {
316 path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
317}
318
319fn product_module_name(xctest_bundle_name: &str) -> String {
320 xctest_bundle_name.trim_end_matches(".xctest").to_string()
321}
322
323fn default_capabilities() -> XctCapabilities {
324 XctCapabilities {
325 capabilities: vec![
326 (
327 "expected failure test capability".to_string(),
328 Value::Boolean(true),
329 ),
330 (
331 "test case run configurations".to_string(),
332 Value::Boolean(true),
333 ),
334 ("test timeout capability".to_string(), Value::Boolean(true)),
335 ("test iterations".to_string(), Value::Boolean(true)),
336 (
337 "request diagnostics for specific devices".to_string(),
338 Value::Boolean(true),
339 ),
340 (
341 "delayed attachment transfer".to_string(),
342 Value::Boolean(true),
343 ),
344 ("skipped test capability".to_string(), Value::Boolean(true)),
345 (
346 "daemon container sandbox extension".to_string(),
347 Value::Boolean(true),
348 ),
349 (
350 "ubiquitous test identifiers".to_string(),
351 Value::Boolean(true),
352 ),
353 ("XCTIssue capability".to_string(), Value::Boolean(true)),
354 ],
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 fn runner() -> InstalledAppInfo {
363 InstalledAppInfo {
364 bundle_id: "com.example.Runner".to_string(),
365 path: "/private/var/containers/Bundle/Application/Runner.app".to_string(),
366 executable: "DemoAppUITests-Runner".to_string(),
367 container: Some("/private/var/mobile/Containers/Data/Application/Runner".to_string()),
368 }
369 }
370
371 #[test]
372 fn launch_environment_uses_ddi_variant_on_ios17() {
373 let plan = TestLaunchPlan {
374 runner: runner(),
375 target: None,
376 xctest_bundle_name: "DemoAppUITests.xctest".to_string(),
377 is_xctest: false,
378 args: Vec::new(),
379 env: HashMap::new(),
380 tests_to_run: Vec::new(),
381 tests_to_skip: Vec::new(),
382 };
383
384 let env = plan.launch_environment(
385 17,
386 Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff").unwrap(),
387 );
388 assert_eq!(
389 env.get("XCTestManagerVariant").map(String::as_str),
390 Some("DDI")
391 );
392 assert_eq!(
393 env.get("XCTestConfigurationFilePath").map(String::as_str),
394 Some("")
395 );
396 }
397
398 #[test]
399 fn from_scheme_preserves_target_app_context_without_changing_runner_env_behavior() {
400 let scheme = SchemeData {
401 test_host_bundle_identifier: "com.example.Runner".to_string(),
402 test_bundle_path: "DemoAppUITests.xctest".to_string(),
403 skip_test_identifiers: Vec::new(),
404 only_test_identifiers: vec!["DemoAppUITests/LoginTests/testHappyPath".to_string()],
405 is_ui_test_bundle: true,
406 command_line_arguments: vec!["-RunnerFlag".to_string()],
407 environment_variables: HashMap::from([(
408 "RUNNER_ENV".to_string(),
409 Value::String("runner".to_string()),
410 )]),
411 testing_environment_variables: HashMap::new(),
412 ui_target_app_environment_variables: HashMap::from([(
413 "TARGET_ENV".to_string(),
414 Value::String("target".to_string()),
415 )]),
416 ui_target_app_command_line_arguments: vec![
417 "-AppleLanguages".to_string(),
418 "(en)".to_string(),
419 ],
420 ui_target_app_path: "__TESTROOT__/Debug-iphoneos/DemoApp.app".to_string(),
421 };
422 let plan = TestLaunchPlan::from_scheme(
423 &scheme,
424 runner(),
425 Some(InstalledAppInfo {
426 bundle_id: "com.example.Target".to_string(),
427 path: "/private/var/containers/Bundle/Application/Target.app".to_string(),
428 executable: "DemoApp".to_string(),
429 container: None,
430 }),
431 );
432
433 let launch_env = plan.launch_environment(
434 17,
435 Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff").unwrap(),
436 );
437 assert_eq!(
438 launch_env.get("RUNNER_ENV").map(String::as_str),
439 Some("runner")
440 );
441 assert_eq!(
442 launch_env.get("TARGET_ENV").map(String::as_str),
443 Some("target")
444 );
445 assert!(!launch_env.contains_key(TARGET_APP_ENV_KEY));
446 assert!(!launch_env.contains_key(TARGET_APP_ARGS_KEY));
447 }
448
449 #[test]
450 fn configuration_adds_target_application_fields_for_ui_tests() {
451 let mut env = HashMap::new();
452 store_target_app_context(
453 &mut env,
454 &HashMap::from([(
455 "TARGET_ENV".to_string(),
456 Value::String("target".to_string()),
457 )]),
458 &["-AppleLanguages".to_string(), "(en)".to_string()],
459 );
460 let plan = TestLaunchPlan {
461 runner: runner(),
462 target: Some(InstalledAppInfo {
463 bundle_id: "com.example.Target".to_string(),
464 path: "/private/var/containers/Bundle/Application/Target.app".to_string(),
465 executable: "DemoApp".to_string(),
466 container: None,
467 }),
468 xctest_bundle_name: "DemoAppUITests.xctest".to_string(),
469 is_xctest: false,
470 args: Vec::new(),
471 env,
472 tests_to_run: vec!["DemoAppUITests/LoginTests/testHappyPath".to_string()],
473 tests_to_skip: Vec::new(),
474 };
475
476 let config = plan.xctest_configuration(
477 17,
478 Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff").unwrap(),
479 );
480 assert!(config
481 .additional_fields
482 .iter()
483 .any(|(key, _)| key == "targetApplicationBundleID"));
484 assert!(config
485 .additional_fields
486 .iter()
487 .any(|(key, _)| key == "testsToRun"));
488 assert!(config.additional_fields.iter().any(|(key, value)| {
489 key == "targetApplicationArguments"
490 && matches!(
491 value,
492 Value::Array(items)
493 if items
494 == &vec![
495 Value::String("-AppleLanguages".to_string()),
496 Value::String("(en)".to_string()),
497 ]
498 )
499 }));
500 assert!(config.additional_fields.iter().any(|(key, value)| {
501 key == "targetApplicationEnvironment"
502 && matches!(
503 value,
504 Value::Dictionary(items)
505 if items.get("TARGET_ENV") == Some(&Value::String("target".to_string()))
506 )
507 }));
508 }
509}