Skip to main content

ios_core/services/testmanager/
workflow.rs

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            // iOS 17+ uses DDI path; clear the container-based config file path set above.
211            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        // Safety: serde_json::to_string on HashMap<String, String> is infallible
272        // (no non-string keys, no recursive structures, no unsupported types).
273        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        // Safety: serde_json::to_string on &[String] is infallible.
280        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}