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}