cucumber_thirtyfour_worlder/
lib.rs1#[cfg(test)]
104mod tests;
105
106use proc_macro2::{TokenStream, TokenTree};
107use quote::quote;
108use syn::{
109 parse::{Parse, ParseStream},
110 parse_macro_input,
111};
112
113#[proc_macro_attribute]
131pub fn worlder(
132 args: proc_macro::TokenStream,
133 stream: proc_macro::TokenStream,
134) -> proc_macro::TokenStream {
135 assert!(
136 !stream.is_empty(),
137 "#[worlder] macro requires a struct to be passed"
138 );
139
140 let args = parse_macro_input!(args as WorlderArgs);
141 let (check_concurrency_cli_option_when_firefox, check_concurrency_cli_option_when_firefox_fn) =
142 if args.check_concurrency_cli_option_when_firefox {
143 (
144 quote!(Self::__check_firefox_concurrency_cli_option()),
145 build_check_concurrency_cli_option_when_firefox_fn(),
146 )
147 } else {
148 (TokenStream::new(), TokenStream::new())
149 };
150 let cucumber = args.cucumber;
151 let thirtyfour = args.thirtyfour;
152 let serde_json = args.serde_json;
153
154 let mut before_struct = TokenStream::new();
155 let original_struct = TokenStream::from(stream.clone());
156
157 let mut token_stream_iter = original_struct.into_iter();
158 let length = token_stream_iter.clone().count();
159
160 let mut maybe_item_1 = None;
164 for _ in 0..length {
165 let item = token_stream_iter.next();
166 if let Some(item) = item {
167 let item_clone = item.clone();
168 if let TokenTree::Ident(ident) = { item_clone } {
169 let ident_str = ident.to_string();
170 if &ident_str == "pub" || ident_str.starts_with("pub(") || &ident_str == "struct" {
171 maybe_item_1 = Some(TokenTree::Ident(ident));
172 break;
173 }
174 }
175 before_struct.extend(std::iter::once(item));
176 }
177 }
178 let maybe_item_2 = token_stream_iter.next();
179 let maybe_item_3 = token_stream_iter.next();
180 let maybe_item_4 = token_stream_iter.next();
181
182 let mut struct_idents = vec![];
183
184 let item_1_string = match maybe_item_1 {
185 Some(TokenTree::Ident(ident)) => {
186 struct_idents.push(TokenTree::from(ident.clone()));
187 ident.to_string()
188 }
189 _ => String::new(),
190 };
191 let item_2_string = match maybe_item_2 {
192 Some(TokenTree::Ident(ident)) => {
193 struct_idents.push(TokenTree::Ident(ident.clone()));
194 ident.to_string()
195 }
196 _ => String::new(),
197 };
198 let item_3_string = match maybe_item_3 {
199 Some(TokenTree::Ident(ident)) => {
200 struct_idents.push(TokenTree::Ident(ident.clone()));
201 ident.to_string()
202 }
203 Some(TokenTree::Punct(punct)) => punct.to_string(),
204 _ => String::new(),
205 };
206 let item_4_string = match maybe_item_4 {
207 Some(TokenTree::Punct(punct)) => punct.to_string(),
208 _ => String::new(),
209 };
210
211 let item_1_str = item_1_string.as_str();
212 let item_2_str = item_2_string.as_str();
213 let item_3_str = item_3_string.as_str();
214 let item_4_str = item_4_string.as_str();
215
216 let (valid, with_vis) = if (item_1_str == "pub" || item_1_str.starts_with("pub("))
217 && item_2_str == "struct"
218 && item_4_str == ";"
219 {
220 (true, true)
221 } else if item_1_str == "struct" && item_3_str == ";" {
222 (true, false)
223 } else {
224 (false, false)
225 };
226
227 assert!(
228 valid,
229 "#[worlder] macro requires a token stream like `pub struct AppWorld;` or `struct AppWorld;`"
230 );
231
232 let (vis_ident, struct_ident, struct_name_ident) = if with_vis {
233 (
234 struct_idents[0].clone(),
235 struct_idents[1].clone(),
236 struct_idents[2].clone(),
237 )
238 } else {
239 (
240 TokenTree::Ident(proc_macro2::Ident::new("", proc_macro2::Span::call_site())),
241 struct_idents[0].clone(),
242 struct_idents[1].clone(),
243 )
244 };
245
246 let ret = quote! {
247 #before_struct
248 #[derive(Debug, #cucumber::World)]
249 #[world(init = Self::new)]
250 #vis_ident #struct_ident #struct_name_ident {
251 driver: #thirtyfour::WebDriver,
252 driver_url: String,
253 host_url: String,
254 headless: bool,
255 window_size: (u32, u32),
256 downloads_dir: String,
257 }
258
259 impl #struct_name_ident {
260 #[doc(hidden)]
261 pub async fn new() -> Self {
262 Self::__build_driver().await
263 }
264
265 #[doc = "Get the driver of the world."]
266 #[must_use]
267 pub fn driver(&self) -> &#thirtyfour::WebDriver {
268 &self.driver
269 }
270
271 #[doc = "Get the driver URL of the world."]
272 #[doc = ""]
273 #[doc = "It's defined by the `DRIVER_URL` environment variable, which defaults to `\"http://localhost:4444\"`."]
274 #[must_use]
275 pub fn driver_url(&self) -> &str {
276 &self.driver_url
277 }
278
279 #[doc = "Get the host URL of the world."]
280 #[doc = ""]
281 #[doc = "It's defined by the `HOST_URL` environment variable, which defaults to `\"http://localhost:8080\"`."]
282 #[must_use]
283 pub fn host_url(&self) -> &str {
284 &self.host_url
285 }
286
287 #[doc = "Get the headless mode of the world."]
288 #[doc = ""]
289 #[doc = "It's defined by the `HEADLESS` environment variable, which defaults to `true`."]
290 #[must_use]
291 pub fn headless(&self) -> bool {
292 self.headless
293 }
294
295 #[doc = "Get the window size of the world."]
296 #[doc = ""]
297 #[doc = "It's defined by the `WINDOW_SIZE` environment variable, which defaults to `\"1920x1080\"`."]
298 #[must_use]
299 pub fn window_size(&self) -> (u32, u32) {
300 self.window_size
301 }
302
303 #[doc = "Get the downloads directory of the world."]
304 #[doc = ""]
305 #[doc = "It's defined by the `DOWNLOADS_DIR` environment variable, which defaults to a random temporary directory."]
306 #[must_use]
307 pub fn downloads_dir(&self) -> &str {
308 &self.downloads_dir
309 }
310
311 #[doc = "Navigate to the given path inside the host."]
312 pub async fn goto_path(&self, path: &str) -> Result<&Self, #thirtyfour::error::WebDriverError> {
313 let url = format!("{}{}", self.host_url(), path);
314 if let Err(err) = self.driver().goto(&url).await {
315 Err(err)
316 } else {
317 Ok(self)
318 }
319 }
320
321 async fn __build_driver() -> Self {
322 let browser = Self::__discover_browser();
323 let driver_url = Self::__discover_driver_url();
324 let host_url = Self::__discover_host_url();
325 let headless = Self::__discover_headless();
326 let downloads_dir = Self::__discover_downloads_dir();
327 let (window_width, window_height) = Self::__discover_window_size();
328
329 let driver = if &browser == "chrome" {
330 let mut caps = #thirtyfour::DesiredCapabilities::chrome();
331 let mut prefs = ::std::collections::HashMap::<String, #serde_json::Value>::new();
332 prefs.insert(
333 "download.default_directory".to_string(),
334 #serde_json::Value::String(
335 downloads_dir.clone(),
336 ),
337 );
338 <#thirtyfour::ChromeCapabilities
339 as
340 #thirtyfour::BrowserCapabilitiesHelper>::insert_browser_option(
341 &mut caps, "prefs", prefs,
342 )
343 .unwrap_or_else(|err| {
344 panic!("Failed to set Chrome prefs: {err}");
345 });
346 let window_size_opt = format!(
347 "--window-size={window_width},{window_height}",
348 );
349 let mut opts = vec!["--no-sandbox", &window_size_opt];
350 if headless {
351 opts.push("--headless");
352 }
353 <#thirtyfour::ChromeCapabilities
354 as
355 #thirtyfour::BrowserCapabilitiesHelper>::insert_browser_option(
356 &mut caps, "args", opts
357 )
358 .unwrap_or_else(|err| {
359 panic!("Failed to set Chrome options: {err}");
360 });
361 #thirtyfour::WebDriver::new(&driver_url, caps)
362 .await
363 .unwrap_or_else(|err| {
364 panic!(
365 "Failed to create WebDriver for Chrome: {err}. \
366 Make sure that chromedriver server is running at {driver_url}",
367 )
368 })
369 } else if &browser == "firefox" {
370 #check_concurrency_cli_option_when_firefox;
371 let mut caps = #thirtyfour::DesiredCapabilities::firefox();
372 let mut prefs = ::std::collections::HashMap::<String, #serde_json::Value>::new();
373 prefs.insert(
374 "browser.download.folderList".to_string(),
375 #serde_json::Value::Number(2.into())
376 );
377 prefs.insert(
378 "browser.download.dir".to_string(),
379 #serde_json::Value::String(
380 downloads_dir.clone(),
381 ),
382 );
383 prefs.insert(
384 "browser.download.useDownloadDir".to_string(),
385 #serde_json::Value::Bool(true),
386 );
387 prefs.insert(
388 "browser.download.manager.showWhenStarting".to_string(),
389 #serde_json::Value::Bool(false),
390 );
391 prefs.insert(
392 "browser.helperApps.neverAsk.saveToDisk".to_string(),
393 #serde_json::Value::String(
394 "application/octet-stream,application/pdf,image/png,image/jpeg,image/svg+xml,text/plain,text/csv,application/zip".to_string(),
395 ),
396 );
397 prefs.insert(
399 "pdfjs.disabled".to_string(),
400 #serde_json::Value::Bool(true),
401 );
402 <#thirtyfour::FirefoxCapabilities
403 as
404 #thirtyfour::BrowserCapabilitiesHelper>::insert_browser_option(
405 &mut caps, "prefs", prefs,
406 )
407 .unwrap_or_else(|err| {
408 panic!("Failed to set Firefox prefs: {err}");
409 });
410 if headless {
411 caps.set_headless().unwrap_or_else(|err| {
412 panic!("Failed to set Firefox headless mode: {err}");
413 });
414 }
415 let driver = #thirtyfour::WebDriver::new(&driver_url, caps).await.unwrap_or_else(|err| {
416 panic!(
417 "Failed to create WebDriver for Firefox: {err}. \
418 Make sure that geckodriver server is running at {driver_url}",
419 )
420 });
421 driver.set_window_rect(0, 0, window_width, window_height)
424 .await
425 .expect("Failed to set window size to {width}x{height}");
426 driver
427 } else if &browser == "edge" {
428 let mut caps = #thirtyfour::DesiredCapabilities::edge();
429 let mut prefs = ::std::collections::HashMap::<String, #serde_json::Value>::new();
430 prefs.insert(
431 "download.default_directory".to_string(),
432 #serde_json::Value::String(
433 downloads_dir.clone(),
434 ),
435 );
436 prefs.insert(
437 "download.prompt_for_download".to_string(),
438 #serde_json::Value::Bool(false),
439 );
440 prefs.insert(
441 "download.directory_upgrade".to_string(),
442 #serde_json::Value::Bool(true),
443 );
444 prefs.insert(
445 "safebrowsing.enabled".to_string(),
446 #serde_json::Value::Bool(true),
447 );
448 <#thirtyfour::EdgeCapabilities
449 as
450 #thirtyfour::BrowserCapabilitiesHelper>::insert_browser_option(
451 &mut caps, "prefs", prefs,
452 )
453 .unwrap_or_else(|err| {
454 panic!("Failed to set Edge prefs: {err}");
455 });
456 let window_size_opt = format!(
457 "--window-size={window_width},{window_height}",
458 );
459 let mut opts = vec!["--no-sandbox", &window_size_opt];
460 if headless {
461 opts.push("--headless");
462 }
463 <#thirtyfour::EdgeCapabilities
464 as
465 #thirtyfour::BrowserCapabilitiesHelper>::insert_browser_option(&mut caps, "args", opts)
466 .unwrap_or_else(|err| {
467 panic!("Failed to set Edge options: {err}");
468 });
469 #thirtyfour::WebDriver::new(&driver_url, caps).await.unwrap_or_else(|err| {
470 panic!(
471 "Failed to create WebDriver for Edge: {err}. \
472 Make sure that edgedriver server is running at {driver_url}",
473 )
474 })
475 } else {
476 panic!(
477 "Unsupported browser. BROWSER environment variable is: \
478 {browser}. Supported browsers are: \"chrome\", \"firefox\" \
479 and \"edge\"."
480 );
481 };
482
483 Self {
484 driver,
485 driver_url,
486 host_url,
487 headless,
488 window_size: (window_width, window_height),
489 downloads_dir,
490 }
491 }
492
493 fn __discover_browser() -> String {
494 std::env::var("BROWSER").unwrap_or_else(|_| {
495 panic!(
496 "BROWSER environment variable is not set. \
497 Supported browsers are: \"chrome\", \"firefox\" \
498 and \"edge\"."
499 )
500 })
501 }
502
503 fn __discover_driver_url() -> String {
504 std::env::var("DRIVER_URL").unwrap_or("http://localhost:4444".to_string())
505 }
506
507 fn __discover_host_url() -> String {
508 std::env::var("HOST_URL").unwrap_or("http://localhost:8080".to_string())
509 }
510
511 fn __discover_headless() -> bool {
512 std::env::var("HEADLESS").unwrap_or("true".to_string()) == "true"
513 }
514
515 fn __discover_window_size() -> (u32, u32) {
516 let window_size = std::env::var("WINDOW_SIZE").unwrap_or("1920x1080".to_string());
517 let mut parts = window_size.split('x');
518 let width = parts.next().unwrap_or_else(|| {
519 panic!(
520 "Invalid WINDOW_SIZE environment variable format. \
521 Expected format: WIDTHxHEIGHT"
522 );
523 }).parse::<u32>().unwrap_or_else(|_| {
524 panic!(
525 "Invalid WINDOW_SIZE environment variable format. \
526 Expected format: WIDTHxHEIGHT"
527 );
528 });
529 let height = parts.next().unwrap_or_else(|| {
530 panic!(
531 "Invalid WINDOW_SIZE environment variable format. \
532 Expected format: WIDTHxHEIGHT"
533 );
534 }).parse::<u32>().unwrap_or_else(|_| {
535 panic!(
536 "Invalid WINDOW_SIZE environment variable format. \
537 Expected format: WIDTHxHEIGHT"
538 );
539 });
540 (width, height)
541 }
542
543 fn __discover_downloads_dir() -> String {
544 if let Ok(dir) = std::env::var("DOWNLOADS_DIR") {
545 let path = std::path::PathBuf::from(dir);
546 if !path.exists() {
547 std::fs::create_dir_all(&path).unwrap_or_else(|err| {
548 panic!(
549 "Failed to create downloads directory at {:?}: {err}",
550 path,
551 )
552 });
553 }
554 if let Ok(canonical_path) = path.canonicalize() {
555 return canonical_path.display().to_string();
556 }
557 panic!(
558 "Failed to canonicalize downloads directory at {:?}",
559 path,
560 );
561 }
562
563 let generate_random_unique_directory = || -> String {
564 let temp_dir = std::env::temp_dir();
565 let base_path = std::path::PathBuf::from(std::env::temp_dir());
566
567 for attempt in 0..u32::MAX {
568 let nanos = std::time::SystemTime::now()
569 .duration_since(std::time::UNIX_EPOCH)
570 .unwrap()
571 .as_nanos();
572
573 let name = format!("dir_{nanos}_{attempt}");
574 let path = base_path.join(name);
575
576 if !path.exists() {
577 let result = std::fs::create_dir_all(&path);
578 if result.is_ok() {
579 if let Ok(canonical_path) = path.canonicalize() {
580 return canonical_path.display().to_string();
581 }
582 }
583 }
584 }
585
586 panic!(
587 "Failed to generate a unique temporary directory to store downloads. \
588 Set the environment variable DOWNLOADS_DIR to a valid directory path \
589 to avoid this issue."
590 );
591 };
592
593 generate_random_unique_directory()
594 }
595
596 #check_concurrency_cli_option_when_firefox_fn
597 }
598 };
599
600 proc_macro::TokenStream::from(ret)
601}
602
603fn build_check_concurrency_cli_option_when_firefox_fn() -> TokenStream {
604 quote! {
605 fn __check_firefox_concurrency_cli_option() {
606 let lets_panic = || {
607 panic!(
608 "The driver geckodriver requires --concurrency or -c \
609 option to be set to 1 because geckodriver does not allows \
610 multiple sessions in parallel. Pass --concurrency=1 or -c 1 \
611 to the test command, like \
612 `cargo test --test <test-name> -- --concurrency=1`."
613 )
614 };
615
616 let mut reading_arg = false;
617 let mut found = false;
618 let args = std::env::args();
619 for arg in args {
620 if arg == "--concurrency" || arg == "-c" {
621 reading_arg = true;
622 } else if arg.starts_with("--concurrency=")
623 || arg.starts_with("-c=")
624 {
625 let value = arg
626 .split('=')
627 .nth(1)
628 .unwrap_or_else(|| panic!("Invalid argument: {arg}"));
629 let value = value.parse::<u32>();
630 if value.is_ok() && value.unwrap() != 1 {
631 lets_panic();
632 }
633 found = true;
634 break;
635 } else if reading_arg {
636 let value = arg.parse::<u32>();
637 if value.is_ok() && value.unwrap() != 1 {
638 lets_panic();
639 }
640 found = true;
641 break;
642 }
643 }
644
645 if !found {
646 lets_panic();
647 }
648 }
649 }
650}
651
652struct WorlderArgs {
653 check_concurrency_cli_option_when_firefox: bool,
654 cucumber: syn::Path,
655 thirtyfour: syn::Path,
656 serde_json: syn::Path,
657}
658
659impl Default for WorlderArgs {
660 fn default() -> Self {
661 Self {
662 check_concurrency_cli_option_when_firefox: true,
663 cucumber: syn::parse_str::<syn::Path>("::cucumber").unwrap(),
664 thirtyfour: syn::parse_str::<syn::Path>("::thirtyfour").unwrap(),
665 serde_json: syn::parse_str::<syn::Path>("::serde_json").unwrap(),
666 }
667 }
668}
669
670impl Parse for WorlderArgs {
671 fn parse(input: ParseStream) -> syn::parse::Result<Self> {
672 let mut args = WorlderArgs::default();
673 while !input.is_empty() {
674 let ident: syn::Ident = input.parse()?;
675 if ident == "check_concurrency_cli_option_when_firefox" {
676 input.parse::<syn::Token![=]>()?;
677 let value: syn::LitBool = input.parse()?;
678 args.check_concurrency_cli_option_when_firefox = value.value;
679 } else if ident == "cucumber" {
680 input.parse::<syn::Token![=]>()?;
681 args.cucumber = input.parse()?;
682 } else if ident == "thirtyfour" {
683 input.parse::<syn::Token![=]>()?;
684 args.thirtyfour = input.parse()?;
685 } else if ident == "serde_json" {
686 input.parse::<syn::Token![=]>()?;
687 args.serde_json = input.parse()?;
688 } else {
689 return Err(input.error(format!("Unknown argument: {ident}")));
690 }
691 if !input.is_empty() {
692 input.parse::<syn::Token![,]>()?;
693 }
694 }
695 Ok(args)
696 }
697}