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