Skip to main content

allwright/
client_config.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use super::command::merge_retry_config;
5use super::launch::launch_browser;
6use super::runtime::set_server_addr;
7use super::types::{
8    AllwrightConfig, BrowserKind, ConfigApp, ConfigDesktop, ConfigDesktopTarget, ConfigMobile,
9    ConfigMobileTarget, Error, LaunchOptions, ResolveConfigOptions, ResolvedAppConfig,
10    ResolvedConfig, ResolvedDesktopConfig, ResolvedDesktopTargetConfig, ResolvedMobileConfig,
11    ResolvedMobileTargetConfig, Result,
12};
13
14const CONFIG_FILENAMES: [&str; 6] = [
15    "allwright.config.yaml",
16    "allwright.config.yml",
17    "allwright.config.json",
18    ".allwright/config.yaml",
19    ".allwright/config.yml",
20    ".allwright/config.json",
21];
22
23pub fn find_config_file(start_dir: impl AsRef<Path>) -> Option<PathBuf> {
24    let mut current_dir = start_dir.as_ref().to_path_buf();
25
26    loop {
27        for filename in CONFIG_FILENAMES {
28            let candidate = current_dir.join(filename);
29            if candidate.is_file() {
30                return Some(candidate);
31            }
32        }
33
34        if !current_dir.pop() {
35            return None;
36        }
37    }
38}
39
40pub fn load_config_file(config_file: impl AsRef<Path>) -> Result<AllwrightConfig> {
41    let resolved = config_file.as_ref().to_path_buf();
42    let raw = fs::read_to_string(&resolved).map_err(|error| {
43        Error::new(format!(
44            "failed to read allwright config {}: {error}",
45            resolved.display()
46        ))
47    })?;
48
49    let extension = resolved
50        .extension()
51        .and_then(|value| value.to_str())
52        .unwrap_or_default()
53        .to_ascii_lowercase();
54
55    let config = match extension.as_str() {
56        "json" => serde_json::from_str::<AllwrightConfig>(&raw).map_err(|error| {
57            Error::new(format!(
58                "failed to parse allwright config {} as JSON: {error}",
59                resolved.display()
60            ))
61        })?,
62        "yaml" | "yml" => serde_yaml::from_str::<AllwrightConfig>(&raw).map_err(|error| {
63            Error::new(format!(
64                "failed to parse allwright config {} as YAML: {error}",
65                resolved.display()
66            ))
67        })?,
68        _ => {
69            return Err(Error::new(format!(
70                "unsupported allwright config file extension .{} for {}",
71                if extension.is_empty() {
72                    "<none>"
73                } else {
74                    &extension
75                },
76                resolved.display()
77            )));
78        }
79    };
80
81    validate_config_shape(&config, &resolved)?;
82    Ok(config)
83}
84
85pub fn resolve_config(options: ResolveConfigOptions) -> Result<ResolvedConfig> {
86    let cwd = options
87        .cwd
88        .unwrap_or(std::env::current_dir().map_err(|error| {
89            Error::new(format!(
90                "failed to determine current working directory: {error}"
91            ))
92        })?);
93    let config_file_path = match options.config_file {
94        Some(path) => Some(path),
95        None => find_config_file(cwd),
96    };
97    let file_config = match &config_file_path {
98        Some(path) => load_config_file(path)?,
99        None => AllwrightConfig::default(),
100    };
101    let suite_name = options.suite.and_then(|suite| {
102        let trimmed = suite.trim().to_owned();
103        if trimmed.is_empty() {
104            None
105        } else {
106            Some(trimmed)
107        }
108    });
109    let suite_config = match &suite_name {
110        Some(name) => {
111            let suite = file_config
112                .suites
113                .as_ref()
114                .and_then(|suites| suites.get(name))
115                .cloned();
116            if suite.is_none() {
117                return Err(Error::new(format!(
118                    "allwright config suite \"{}\" was not found in {}",
119                    name,
120                    config_file_path
121                        .as_ref()
122                        .map(|path| path.display().to_string())
123                        .unwrap_or_else(|| "the resolved config file".to_string())
124                )));
125            }
126            suite
127        }
128        None => None,
129    };
130
131    let server_addr = suite_config
132        .as_ref()
133        .and_then(|suite| suite.server.as_ref())
134        .and_then(|server| server.addr.clone())
135        .or_else(|| {
136            file_config
137                .server
138                .as_ref()
139                .and_then(|server| server.addr.clone())
140        });
141    let browser_name = suite_config
142        .as_ref()
143        .and_then(|suite| suite.web.as_ref())
144        .and_then(|web| web.browser.as_ref())
145        .and_then(|browser| browser.name)
146        .or_else(|| {
147            file_config
148                .web
149                .as_ref()
150                .and_then(|web| web.browser.as_ref())
151                .and_then(|browser| browser.name)
152        });
153    let browser_binary = suite_config
154        .as_ref()
155        .and_then(|suite| suite.web.as_ref())
156        .and_then(|web| web.browser.as_ref())
157        .and_then(|browser| browser.binary.clone())
158        .or_else(|| {
159            file_config
160                .web
161                .as_ref()
162                .and_then(|web| web.browser.as_ref())
163                .and_then(|browser| browser.binary.clone())
164        });
165    let mut launch_options = merge_launch_options(
166        file_config
167            .web
168            .as_ref()
169            .and_then(|web| web.browser.as_ref())
170            .and_then(|browser| browser.launch_options.clone()),
171        suite_config
172            .as_ref()
173            .and_then(|suite| suite.web.as_ref())
174            .and_then(|web| web.browser.as_ref())
175            .and_then(|browser| browser.launch_options.clone()),
176    );
177    if let Some(binary) = &browser_binary {
178        launch_options.browser_binary = Some(binary.clone());
179    }
180    let expect = merge_retry_config(
181        file_config.expect.clone(),
182        suite_config.as_ref().and_then(|suite| suite.expect.clone()),
183    );
184    let mobile = resolve_mobile_config(
185        file_config.mobile.as_ref(),
186        suite_config
187            .as_ref()
188            .and_then(|suite| suite.mobile.as_ref()),
189    );
190    let desktop = resolve_desktop_config(
191        file_config.desktop.as_ref(),
192        suite_config
193            .as_ref()
194            .and_then(|suite| suite.desktop.as_ref()),
195    );
196    let browser_name = browser_name.or_else(|| {
197        if mobile.android.is_some()
198            || mobile.ios.is_some()
199            || desktop.mac.is_some()
200            || desktop.windows.is_some()
201            || desktop.linux.is_some()
202        {
203            None
204        } else {
205            Some(BrowserKind::Chromium)
206        }
207    });
208
209    Ok(ResolvedConfig {
210        config_file_path,
211        suite_name,
212        server_addr,
213        browser_name,
214        browser_binary,
215        launch_options,
216        expect,
217        mobile,
218        desktop,
219    })
220}
221
222pub async fn launch_configured_browser(config: &ResolvedConfig) -> Result<super::types::Browser> {
223    if let Some(server_addr) = &config.server_addr {
224        set_server_addr(server_addr.clone())?;
225    }
226    let browser_name = config.browser_name.ok_or_else(|| {
227        Error::new(
228            "resolved config does not define web.browser.name and includes only non-web surfaces",
229        )
230    })?;
231    launch_browser(browser_name, config.launch_options.clone()).await
232}
233
234fn merge_launch_options(
235    base: Option<LaunchOptions>,
236    override_options: Option<LaunchOptions>,
237) -> LaunchOptions {
238    let mut merged = base.unwrap_or_default();
239    if let Some(override_options) = override_options {
240        if override_options.browser_binary.is_some() {
241            merged.browser_binary = override_options.browser_binary;
242        }
243        if override_options.timeout_ms.is_some() {
244            merged.timeout_ms = override_options.timeout_ms;
245        }
246    }
247    merged
248}
249
250fn validate_config_shape(config: &AllwrightConfig, source: &Path) -> Result<()> {
251    if let Some(schema_version) = config.schema_version {
252        if schema_version != 1 {
253            return Err(Error::new(format!(
254                "allwright config {} has unsupported schemaVersion {}; expected 1",
255                source.display(),
256                schema_version
257            )));
258        }
259    }
260    Ok(())
261}
262
263fn resolve_mobile_config(
264    base: Option<&ConfigMobile>,
265    override_config: Option<&ConfigMobile>,
266) -> ResolvedMobileConfig {
267    ResolvedMobileConfig {
268        android: resolve_mobile_target(
269            base.and_then(|mobile| mobile.android.as_ref()),
270            override_config.and_then(|mobile| mobile.android.as_ref()),
271        ),
272        ios: resolve_mobile_target(
273            base.and_then(|mobile| mobile.ios.as_ref()),
274            override_config.and_then(|mobile| mobile.ios.as_ref()),
275        ),
276    }
277}
278
279fn resolve_desktop_config(
280    base: Option<&ConfigDesktop>,
281    override_config: Option<&ConfigDesktop>,
282) -> ResolvedDesktopConfig {
283    ResolvedDesktopConfig {
284        mac: resolve_desktop_target(
285            base.and_then(|desktop| desktop.mac.as_ref()),
286            override_config.and_then(|desktop| desktop.mac.as_ref()),
287        ),
288        windows: resolve_desktop_target(
289            base.and_then(|desktop| desktop.windows.as_ref()),
290            override_config.and_then(|desktop| desktop.windows.as_ref()),
291        ),
292        linux: resolve_desktop_target(
293            base.and_then(|desktop| desktop.linux.as_ref()),
294            override_config.and_then(|desktop| desktop.linux.as_ref()),
295        ),
296    }
297}
298
299fn resolve_mobile_target(
300    base: Option<&ConfigMobileTarget>,
301    override_config: Option<&ConfigMobileTarget>,
302) -> Option<ResolvedMobileTargetConfig> {
303    let app = resolve_app_config(
304        base.and_then(|target| target.app.as_ref()),
305        override_config.and_then(|target| target.app.as_ref()),
306    );
307    let resolved = ResolvedMobileTargetConfig {
308        device: override_config
309            .and_then(|target| target.device.clone())
310            .or_else(|| base.and_then(|target| target.device.clone())),
311        app,
312    };
313    if resolved.device.is_some() || resolved.app.is_some() {
314        Some(resolved)
315    } else {
316        None
317    }
318}
319
320fn resolve_desktop_target(
321    base: Option<&ConfigDesktopTarget>,
322    override_config: Option<&ConfigDesktopTarget>,
323) -> Option<ResolvedDesktopTargetConfig> {
324    let resolved = ResolvedDesktopTargetConfig {
325        app: resolve_app_config(
326            base.and_then(|target| target.app.as_ref()),
327            override_config.and_then(|target| target.app.as_ref()),
328        ),
329    };
330    if resolved.app.is_some() {
331        Some(resolved)
332    } else {
333        None
334    }
335}
336
337fn resolve_app_config(
338    base: Option<&ConfigApp>,
339    override_config: Option<&ConfigApp>,
340) -> Option<ResolvedAppConfig> {
341    let resolved = ResolvedAppConfig {
342        id: override_config
343            .and_then(|app| app.id.clone())
344            .or_else(|| base.and_then(|app| app.id.clone())),
345        binary: override_config
346            .and_then(|app| app.binary.clone())
347            .or_else(|| base.and_then(|app| app.binary.clone())),
348        activity: override_config
349            .and_then(|app| app.activity.clone())
350            .or_else(|| base.and_then(|app| app.activity.clone())),
351    };
352    if resolved.id.is_some() || resolved.binary.is_some() || resolved.activity.is_some() {
353        Some(resolved)
354    } else {
355        None
356    }
357}