Skip to main content

ferridriver_test_macros/
lib.rs

1//! Proc macros for the ferridriver test framework.
2//!
3//! Provides `#[ferritest]` to register async browser test functions. The
4//! annotated function takes a single `TestContext`; built-in fixtures
5//! (`page`, `browser`, `context`, ...) resolve lazily through it.
6//!
7//! ```ignore
8//! use ferridriver_test::prelude::*;
9//!
10//! #[ferritest]
11//! async fn basic_navigation(ctx: TestContext) {
12//!     let page = ctx.page().await?;
13//!     page.goto("https://example.com", None).await?;
14//!     expect(&page).to_have_title("Example").await?;
15//! }
16//!
17//! #[ferritest(retries = 2, timeout = "30s", tag = "smoke")]
18//! async fn flaky_test(ctx: TestContext) {
19//!     let page = ctx.page().await?;
20//!     let context = ctx.browser_context().await?;
21//!     // ...
22//! }
23//! ```
24
25use proc_macro::TokenStream;
26use quote::{format_ident, quote};
27use syn::parse::{Parse, ParseStream};
28use syn::punctuated::Punctuated;
29use syn::{Expr, FnArg, ItemFn, ItemMod, Lit, Meta, Pat, Token, Type, parse_macro_input, parse_quote};
30
31/// Attribute arguments: `#[ferritest(retries = 2, timeout = "30s", tag = "smoke")]`
32struct FerritestArgs {
33  retries: Option<u32>,
34  timeout_ms: Option<u64>,
35  tags: Vec<String>,
36  /// None = not set, Some(None) = unconditional, Some(Some("firefox")) = conditional
37  skip: Option<Option<String>>,
38  /// None = not set, Some(None) = unconditional, Some(Some("ci")) = conditional
39  slow: Option<Option<String>>,
40  /// None = not set, Some(None) = unconditional, Some(Some("linux")) = conditional
41  fixme: Option<Option<String>>,
42  /// None = not set, Some(None) = unconditional, Some(Some("webkit")) = conditional
43  fail: Option<Option<String>>,
44  only: bool,
45  /// Structured metadata annotations: `info = "type:description"`.
46  infos: Vec<(String, String)>,
47  /// Raw JSON string for fixture/context overrides (viewport, locale, etc.)
48  use_options: Option<String>,
49}
50
51impl Parse for FerritestArgs {
52  fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
53    let mut args = Self {
54      retries: None,
55      timeout_ms: None,
56      tags: Vec::new(),
57      skip: None,
58      slow: None,
59      fixme: None,
60      fail: None,
61      only: false,
62      infos: Vec::new(),
63      use_options: None,
64    };
65
66    let metas = Punctuated::<Meta, Token![,]>::parse_terminated(input)?;
67    for meta in metas {
68      match &meta {
69        Meta::NameValue(nv) => {
70          let ident = nv.path.get_ident().map(ToString::to_string).unwrap_or_default();
71          match ident.as_str() {
72            "retries" => {
73              if let syn::Expr::Lit(lit) = &nv.value {
74                if let Lit::Int(i) = &lit.lit {
75                  args.retries = Some(i.base10_parse()?);
76                }
77              }
78            },
79            "timeout" => {
80              if let syn::Expr::Lit(lit) = &nv.value {
81                if let Lit::Str(s) = &lit.lit {
82                  args.timeout_ms = Some(parse_duration_str(&s.value())?);
83                }
84              }
85            },
86            "tag" => {
87              if let syn::Expr::Lit(lit) = &nv.value {
88                if let Lit::Str(s) = &lit.lit {
89                  args.tags.push(s.value());
90                }
91              }
92            },
93            "skip" => {
94              if let syn::Expr::Lit(lit) = &nv.value {
95                if let Lit::Str(s) = &lit.lit {
96                  args.skip = Some(Some(s.value()));
97                }
98              }
99            },
100            "slow" => {
101              if let syn::Expr::Lit(lit) = &nv.value {
102                if let Lit::Str(s) = &lit.lit {
103                  args.slow = Some(Some(s.value()));
104                }
105              }
106            },
107            "fixme" => {
108              if let syn::Expr::Lit(lit) = &nv.value {
109                if let Lit::Str(s) = &lit.lit {
110                  args.fixme = Some(Some(s.value()));
111                }
112              }
113            },
114            "fail" => {
115              if let syn::Expr::Lit(lit) = &nv.value {
116                if let Lit::Str(s) = &lit.lit {
117                  args.fail = Some(Some(s.value()));
118                }
119              }
120            },
121            "use_options" => {
122              if let syn::Expr::Lit(lit) = &nv.value {
123                if let Lit::Str(s) = &lit.lit {
124                  args.use_options = Some(s.value());
125                }
126              }
127            },
128            "info" => {
129              if let syn::Expr::Lit(lit) = &nv.value {
130                if let Lit::Str(s) = &lit.lit {
131                  let val = s.value();
132                  if let Some((type_name, desc)) = val.split_once(':') {
133                    args.infos.push((type_name.trim().to_string(), desc.trim().to_string()));
134                  } else {
135                    args.infos.push((val, String::new()));
136                  }
137                }
138              }
139            },
140            _ => {
141              return Err(syn::Error::new_spanned(
142                &nv.path,
143                format!("unknown ferritest attribute: {ident}"),
144              ));
145            },
146          }
147        },
148        Meta::Path(p) => {
149          let ident = p.get_ident().map(ToString::to_string).unwrap_or_default();
150          match ident.as_str() {
151            "skip" => args.skip = Some(None),
152            "slow" => args.slow = Some(None),
153            "fixme" => args.fixme = Some(None),
154            "fail" => args.fail = Some(None),
155            "only" => args.only = true,
156            _ => return Err(syn::Error::new_spanned(p, format!("unknown ferritest flag: {ident}"))),
157          }
158        },
159        Meta::List(_) => {
160          return Err(syn::Error::new_spanned(&meta, "unexpected nested attribute"));
161        },
162      }
163    }
164    Ok(args)
165  }
166}
167
168fn parse_duration_str(s: &str) -> syn::Result<u64> {
169  let s = s.trim();
170  if let Some(secs) = s.strip_suffix('s') {
171    secs
172      .trim()
173      .parse::<u64>()
174      .map(|v| v * 1000)
175      .map_err(|e| syn::Error::new(proc_macro2::Span::call_site(), format!("invalid timeout: {e}")))
176  } else if let Some(ms) = s.strip_suffix("ms") {
177    ms.trim()
178      .parse::<u64>()
179      .map_err(|e| syn::Error::new(proc_macro2::Span::call_site(), format!("invalid timeout: {e}")))
180  } else {
181    s.parse::<u64>().map_err(|e| {
182      syn::Error::new(
183        proc_macro2::Span::call_site(),
184        format!("invalid timeout (use '30s' or '5000ms'): {e}"),
185      )
186    })
187  }
188}
189
190/// `#[ferritest]` attribute macro.
191///
192/// Transforms an async function into a registered test case with automatic
193/// fixture injection based on parameter types.
194#[proc_macro_attribute]
195pub fn ferritest(attr: TokenStream, item: TokenStream) -> TokenStream {
196  let args = parse_macro_input!(attr as FerritestArgs);
197  let input = parse_macro_input!(item as ItemFn);
198
199  let fn_name = &input.sig.ident;
200  let fn_name_str = fn_name.to_string();
201  let vis = &input.vis;
202  let block = &input.block;
203  let attrs = &input.attrs;
204
205  // The function receives a TestContext. Extract the parameter name the user chose
206  // (e.g., `ctx`, `context`, `t`, etc.)
207  let ctx_param_name = if let Some(FnArg::Typed(pt)) = input.sig.inputs.first() {
208    if let Pat::Ident(pi) = pt.pat.as_ref() {
209      pi.ident.clone()
210    } else {
211      format_ident!("ctx")
212    }
213  } else {
214    format_ident!("ctx")
215  };
216
217  // Rust tests resolve built-in fixtures lazily via TestContext getters.
218  let fixture_names: Vec<String> = Vec::new();
219  let fixture_array = fixture_names.iter().map(|f| quote! { #f });
220
221  // Build annotations.
222  // Helper: parse "condition" or "condition | reason" into (condition, reason) tokens.
223  fn annotation_tokens(variant: &str, arg: &Option<Option<String>>, annotations: &mut Vec<proc_macro2::TokenStream>) {
224    let variant_ident = quote::format_ident!("{}", variant);
225    match arg {
226      Some(None) => {
227        annotations
228          .push(quote! { ferridriver_test::model::TestAnnotation::#variant_ident { reason: None, condition: None } });
229      },
230      Some(Some(val)) => {
231        // Support "condition | reason" format.
232        if let Some((cond, reason)) = val.split_once('|') {
233          let cond = cond.trim();
234          let reason = reason.trim();
235          annotations.push(quote! { ferridriver_test::model::TestAnnotation::#variant_ident {
236            reason: Some(#reason.to_string()),
237            condition: Some(#cond.to_string()),
238          } });
239        } else {
240          annotations.push(quote! { ferridriver_test::model::TestAnnotation::#variant_ident {
241            reason: None,
242            condition: Some(#val.to_string()),
243          } });
244        }
245      },
246      None => {},
247    }
248  }
249
250  let mut annotations = Vec::new();
251  annotation_tokens("Skip", &args.skip, &mut annotations);
252  annotation_tokens("Slow", &args.slow, &mut annotations);
253  annotation_tokens("Fixme", &args.fixme, &mut annotations);
254  annotation_tokens("Fail", &args.fail, &mut annotations);
255  if args.only {
256    annotations.push(quote! { ferridriver_test::model::TestAnnotation::Only });
257  }
258  for tag in &args.tags {
259    annotations.push(quote! { ferridriver_test::model::TestAnnotation::Tag(#tag.to_string()) });
260  }
261  for (type_name, desc) in &args.infos {
262    annotations.push(
263      quote! { ferridriver_test::model::TestAnnotation::Info { type_name: #type_name.to_string(), description: #desc.to_string() } },
264    );
265  }
266
267  let retries_expr = match args.retries {
268    Some(r) => quote! { Some(#r) },
269    None => quote! { None },
270  };
271  let timeout_ms_expr = match args.timeout_ms {
272    Some(ms) => quote! { Some(#ms) },
273    None => quote! { None },
274  };
275  let use_options_expr = match &args.use_options {
276    Some(json) => quote! { Some(#json) },
277    None => quote! { None },
278  };
279
280  let expanded = quote! {
281    #(#attrs)*
282    #[allow(clippy::unused_async)]
283    #vis async fn #fn_name(__pool: ferridriver_test::fixture::FixturePool) -> Result<(), ferridriver_test::model::TestFailure> {
284      let #ctx_param_name = ferridriver_test::TestContext::new(__pool);
285      #block
286      Ok(())
287    }
288
289    inventory::submit! {
290      ferridriver_test::discovery::TestRegistration {
291        file: file!(),
292        module_path: module_path!(),
293        name: #fn_name_str,
294        fixture_requests: &[#(#fixture_array),*],
295        annotations: &[#(#annotations),*],
296        timeout_ms: #timeout_ms_expr,
297        retries: #retries_expr,
298        use_options: #use_options_expr,
299        test_fn: |pool| Box::pin(#fn_name(pool)),
300      }
301    }
302  };
303
304  expanded.into()
305}
306
307/// Arguments for `#[ferritest_each]`: `data = [(...), (...)]`.
308struct FerritestEachArgs {
309  data: Vec<Vec<Expr>>,
310}
311
312impl Parse for FerritestEachArgs {
313  fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
314    // Parse: data = [(...), (...)]
315    let ident: syn::Ident = input.parse()?;
316    if ident != "data" {
317      return Err(syn::Error::new_spanned(&ident, "expected `data = [...]`"));
318    }
319    let _: Token![=] = input.parse()?;
320
321    let content;
322    syn::bracketed!(content in input);
323
324    let mut data = Vec::new();
325    while !content.is_empty() {
326      let inner;
327      syn::parenthesized!(inner in content);
328      let exprs: Punctuated<Expr, Token![,]> = Punctuated::parse_terminated(&inner)?;
329      data.push(exprs.into_iter().collect());
330
331      if content.peek(Token![,]) {
332        let _: Token![,] = content.parse()?;
333      }
334    }
335
336    Ok(Self { data })
337  }
338}
339
340/// `#[ferritest_each(data = [("a", 1), ("b", 2)])]` — parameterized test macro.
341///
342/// Expands a single async test function into N registered tests, one per data row.
343/// First parameter is `TestContext`, remaining parameters receive the data values.
344///
345/// ```ignore
346/// #[ferritest_each(data = [("admin", "admin@example.com"), ("guest", "guest@example.com")])]
347/// async fn login(ctx: TestContext, role: &str, email: &str) {
348///     let page = ctx.page().await?;
349///     page.goto(&format!("/login?role={role}"), None).await?;
350/// }
351/// ```
352/// Registers: `login (admin, admin@example.com)` and `login (guest, guest@example.com)`.
353#[proc_macro_attribute]
354pub fn ferritest_each(attr: TokenStream, item: TokenStream) -> TokenStream {
355  let args = parse_macro_input!(attr as FerritestEachArgs);
356  let input = parse_macro_input!(item as ItemFn);
357
358  let fn_name = &input.sig.ident;
359  let fn_name_str = fn_name.to_string();
360  let block = &input.block;
361  let attrs = &input.attrs;
362
363  // First param is TestContext, rest are data params.
364  let all_params: Vec<_> = input.sig.inputs.iter().collect();
365  let ctx_param_name = if let Some(FnArg::Typed(pt)) = all_params.first() {
366    if let Pat::Ident(pi) = pt.pat.as_ref() {
367      pi.ident.clone()
368    } else {
369      format_ident!("ctx")
370    }
371  } else {
372    format_ident!("ctx")
373  };
374
375  let data_params: Vec<(&syn::Ident, &Type)> = all_params
376    .iter()
377    .skip(1) // skip FixturePool
378    .filter_map(|arg| {
379      if let FnArg::Typed(pat_type) = arg {
380        if let Pat::Ident(pat_ident) = pat_type.pat.as_ref() {
381          return Some((&pat_ident.ident, &*pat_type.ty));
382        }
383      }
384      None
385    })
386    .collect();
387
388  let fixture_names: Vec<String> = Vec::new();
389
390  // Generate one inventory::submit! per data row.
391  let mut submissions = Vec::new();
392  for (row_idx, row) in args.data.iter().enumerate() {
393    if row.len() != data_params.len() {
394      return syn::Error::new_spanned(
395        &input.sig.ident,
396        format!(
397          "data row {} has {} values but function expects {} data parameters",
398          row_idx,
399          row.len(),
400          data_params.len()
401        ),
402      )
403      .to_compile_error()
404      .into();
405    }
406
407    // Build name suffix: "(val1, val2)"
408    let row_values_str: Vec<String> = row.iter().map(|e| quote!(#e).to_string().replace('"', "")).collect();
409    let suffix = row_values_str.join(", ");
410    let test_name = format!("{fn_name_str} ({suffix})");
411
412    // Build let bindings for data params.
413    let data_bindings: Vec<_> = data_params
414      .iter()
415      .zip(row.iter())
416      .map(|((param_name, param_type), value)| {
417        quote! { let #param_name: #param_type = #value; }
418      })
419      .collect();
420
421    let inner_fn_name = format_ident!("__ferritest_each_{}_{}", fn_name, row_idx);
422    let fixture_array = fixture_names.iter().map(|f| quote! { #f });
423    let ctx_param = ctx_param_name.clone();
424
425    submissions.push(quote! {
426      #[allow(clippy::unused_async)]
427      async fn #inner_fn_name(__pool: ferridriver_test::fixture::FixturePool) -> Result<(), ferridriver_test::model::TestFailure> {
428        let #ctx_param = ferridriver_test::TestContext::new(__pool);
429        #(#data_bindings)*
430        #block
431        Ok(())
432      }
433
434      inventory::submit! {
435        ferridriver_test::discovery::TestRegistration {
436          file: file!(),
437          module_path: module_path!(),
438          name: #test_name,
439          fixture_requests: &[#(#fixture_array),*],
440          annotations: &[],
441          timeout_ms: None,
442          retries: None,
443          test_fn: |pool| Box::pin(#inner_fn_name(pool)),
444        }
445      }
446    });
447  }
448
449  let expanded = quote! {
450    #(#attrs)*
451    #(#submissions)*
452  };
453
454  expanded.into()
455}
456
457// ── Fixture macro ──
458
459/// Fixture lifecycle scope, parsed from `scope = "..."`.
460enum FixtureScopeArg {
461  Test,
462  Worker,
463  Global,
464}
465
466/// Attribute arguments: `#[fixture(scope = "worker", auto, timeout = "10s")]`.
467struct FixtureArgs {
468  scope: FixtureScopeArg,
469  auto: bool,
470  timeout_ms: Option<u64>,
471}
472
473impl Parse for FixtureArgs {
474  fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
475    let mut args = Self {
476      scope: FixtureScopeArg::Test,
477      auto: false,
478      timeout_ms: None,
479    };
480    let metas = Punctuated::<Meta, Token![,]>::parse_terminated(input)?;
481    for meta in metas {
482      match &meta {
483        Meta::NameValue(nv) => {
484          let ident = nv.path.get_ident().map(ToString::to_string).unwrap_or_default();
485          match ident.as_str() {
486            "scope" => {
487              if let syn::Expr::Lit(lit) = &nv.value {
488                if let Lit::Str(s) = &lit.lit {
489                  args.scope = match s.value().as_str() {
490                    "test" => FixtureScopeArg::Test,
491                    "worker" => FixtureScopeArg::Worker,
492                    "global" => FixtureScopeArg::Global,
493                    other => {
494                      return Err(syn::Error::new_spanned(
495                        &nv.value,
496                        format!("unknown fixture scope '{other}' (use \"test\", \"worker\", or \"global\")"),
497                      ));
498                    },
499                  };
500                }
501              }
502            },
503            "timeout" => {
504              if let syn::Expr::Lit(lit) = &nv.value {
505                if let Lit::Str(s) = &lit.lit {
506                  args.timeout_ms = Some(parse_duration_str(&s.value())?);
507                }
508              }
509            },
510            _ => {
511              return Err(syn::Error::new_spanned(
512                &nv.path,
513                format!("unknown fixture attribute: {ident}"),
514              ));
515            },
516          }
517        },
518        Meta::Path(p) => {
519          let ident = p.get_ident().map(ToString::to_string).unwrap_or_default();
520          match ident.as_str() {
521            "auto" => args.auto = true,
522            _ => return Err(syn::Error::new_spanned(p, format!("unknown fixture flag: {ident}"))),
523          }
524        },
525        Meta::List(_) => {
526          return Err(syn::Error::new_spanned(&meta, "unexpected nested attribute"));
527        },
528      }
529    }
530    Ok(args)
531  }
532}
533
534/// `#[fixture]` — register a custom, dependency-injected, scoped fixture.
535///
536/// The function takes a single `TestContext` and returns
537/// `ferridriver_test::Result<T>`. The resolved value is shared as `Arc<T>`
538/// and retrieved from a test (or another fixture) via
539/// `ctx.get::<T>("fixture_name").await?`. Custom fixtures may depend on the
540/// built-ins (`page`, `context`, `browser`, `request`, `test_info`) and on
541/// each other — dependencies resolve lazily through `ctx.get`.
542///
543/// ```ignore
544/// use ferridriver_test::prelude::*;
545/// use std::sync::Arc;
546///
547/// #[fixture(scope = "test")]
548/// async fn authed_page(ctx: TestContext) -> ferridriver_test::Result<Arc<Page>> {
549///     let page = ctx.page().await?;
550///     page.goto("https://app.example.com/login", None).await?;
551///     page.locator("#email", None).fill("user@example.com", None).await?;
552///     page.locator("button[type=submit]", None).click(None).await?;
553///     Ok(page)
554/// }
555///
556/// #[ferritest]
557/// async fn shows_dashboard(ctx: TestContext) {
558///     let page = ctx.get::<Arc<Page>>("authed_page").await?;
559///     expect(&page.locator("h1", None)).to_have_text("Dashboard").await?;
560/// }
561/// ```
562#[proc_macro_attribute]
563pub fn fixture(attr: TokenStream, item: TokenStream) -> TokenStream {
564  let args = parse_macro_input!(attr as FixtureArgs);
565  let input = parse_macro_input!(item as ItemFn);
566
567  // Require exactly one parameter (the TestContext).
568  if input.sig.inputs.len() != 1 {
569    return syn::Error::new_spanned(
570      &input.sig,
571      "#[fixture] functions take exactly one parameter: `ctx: TestContext`",
572    )
573    .to_compile_error()
574    .into();
575  }
576
577  let fn_name = &input.sig.ident;
578  let fn_name_str = fn_name.to_string();
579  let builder_ident = format_ident!("__ferridriver_fixture_build_{}", fn_name);
580
581  let scope_tok = match args.scope {
582    FixtureScopeArg::Test => quote! { ferridriver_test::fixture::FixtureScope::Test },
583    FixtureScopeArg::Worker => quote! { ferridriver_test::fixture::FixtureScope::Worker },
584    FixtureScopeArg::Global => quote! { ferridriver_test::fixture::FixtureScope::Global },
585  };
586  let timeout_ms = args.timeout_ms.unwrap_or(10_000);
587  let auto = args.auto;
588
589  let expanded = quote! {
590    // Keep the user's function callable. Fixtures are async by contract
591    // (most await a built-in or another fixture); a data-only fixture that
592    // never awaits is still valid, so silence the no-await lint here rather
593    // than forcing every author to add a workaround.
594    #[allow(clippy::unused_async)]
595    #input
596
597    #[doc(hidden)]
598    fn #builder_ident() -> ferridriver_test::fixture::FixtureDef {
599      ferridriver_test::fixture::FixtureDef {
600        name: #fn_name_str.to_string(),
601        scope: #scope_tok,
602        dependencies: ::std::vec::Vec::new(),
603        setup: ::std::sync::Arc::new(|__pool: ferridriver_test::fixture::FixturePool| {
604          ::std::boxed::Box::pin(async move {
605            let __ctx = ferridriver_test::TestContext::new(__pool);
606            let __value = #fn_name(__ctx).await.map_err(|__e| {
607              ferridriver::error::FerriError::backend(::std::format!("fixture '{}' failed: {}", #fn_name_str, __e))
608            })?;
609            ::std::result::Result::Ok(
610              ::std::sync::Arc::new(__value)
611                as ::std::sync::Arc<dyn ::std::any::Any + ::std::marker::Send + ::std::marker::Sync>,
612            )
613          })
614        }),
615        teardown: ::std::option::Option::None,
616        timeout: ::std::time::Duration::from_millis(#timeout_ms),
617        auto: #auto,
618      }
619    }
620
621    ferridriver_test::inventory::submit! {
622      ferridriver_test::discovery::FixtureRegistration {
623        name: #fn_name_str,
624        module_path: ::core::module_path!(),
625        build: #builder_ident,
626      }
627    }
628  };
629
630  expanded.into()
631}
632
633// ── Suite mode macro ──
634
635/// Suite execution mode parsed from `mode = "..."`.
636enum SuiteModeArg {
637  Serial,
638  Parallel,
639}
640
641struct SuiteArgs {
642  mode: SuiteModeArg,
643}
644
645impl Parse for SuiteArgs {
646  fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
647    let mut mode = SuiteModeArg::Parallel;
648    let metas = Punctuated::<Meta, Token![,]>::parse_terminated(input)?;
649    for meta in metas {
650      match &meta {
651        Meta::NameValue(nv) if nv.path.is_ident("mode") => {
652          if let syn::Expr::Lit(lit) = &nv.value {
653            if let Lit::Str(s) = &lit.lit {
654              mode = match s.value().as_str() {
655                "serial" => SuiteModeArg::Serial,
656                "parallel" => SuiteModeArg::Parallel,
657                other => {
658                  return Err(syn::Error::new_spanned(
659                    &nv.value,
660                    format!("unknown suite mode '{other}' (use \"serial\" or \"parallel\")"),
661                  ));
662                },
663              };
664            }
665          }
666        },
667        _ => {
668          return Err(syn::Error::new_spanned(
669            &meta,
670            "expected `mode = \"serial\" | \"parallel\"`",
671          ));
672        },
673      }
674    }
675    Ok(Self { mode })
676  }
677}
678
679/// `#[ferritest_suite(mode = "serial")]` — set the execution mode of every
680/// `#[ferritest]` in the annotated module. A serial suite is dispatched as
681/// one batch to a single worker, runs in source order, and skips the rest
682/// on first failure. The default (no attribute) is parallel.
683///
684/// ```ignore
685/// #[ferritest_suite(mode = "serial")]
686/// mod payment_flow {
687///     use ferridriver_test::prelude::*;
688///
689///     #[ferritest]
690///     async fn initiate(ctx: TestContext) { /* ... */ }
691///     #[ferritest]
692///     async fn verify_receipt(ctx: TestContext) { /* runs only if initiate passed */ }
693/// }
694/// ```
695#[proc_macro_attribute]
696pub fn ferritest_suite(attr: TokenStream, item: TokenStream) -> TokenStream {
697  let args = parse_macro_input!(attr as SuiteArgs);
698  let mut module = parse_macro_input!(item as ItemMod);
699
700  let Some((_, ref mut items)) = module.content else {
701    return syn::Error::new_spanned(
702      &module,
703      "#[ferritest_suite] requires an inline module body `mod name { ... }`",
704    )
705    .to_compile_error()
706    .into();
707  };
708
709  let mode_tok = match args.mode {
710    SuiteModeArg::Serial => quote! { ferridriver_test::model::SuiteMode::Serial },
711    SuiteModeArg::Parallel => quote! { ferridriver_test::model::SuiteMode::Parallel },
712  };
713
714  // Inject the registration INSIDE the module so `module_path!()` resolves
715  // to this module's path — the same key `#[ferritest]` registrations derive
716  // their suite name from.
717  let submit: syn::Item = parse_quote! {
718    ferridriver_test::inventory::submit! {
719      ferridriver_test::discovery::SuiteModeRegistration {
720        module_path: ::core::module_path!(),
721        mode: #mode_tok,
722      }
723    }
724  };
725  items.push(submit);
726
727  quote! { #module }.into()
728}
729
730// ── Hook macros ──
731
732/// Shared implementation for all four hook macros.
733fn hook_impl(kind_tag: &str, is_suite_hook: bool, item: TokenStream) -> TokenStream {
734  let input = parse_macro_input!(item as ItemFn);
735  let fn_name = &input.sig.ident;
736  let vis = &input.vis;
737  let block = &input.block;
738  let attrs = &input.attrs;
739
740  let kind_ident = format_ident!("{}", kind_tag);
741
742  // Extract parameter name for TestContext.
743  let ctx_param_name = if let Some(FnArg::Typed(pt)) = input.sig.inputs.first() {
744    if let Pat::Ident(pi) = pt.pat.as_ref() {
745      pi.ident.clone()
746    } else {
747      format_ident!("ctx")
748    }
749  } else {
750    format_ident!("ctx")
751  };
752
753  if is_suite_hook {
754    // before_all / after_all: fn(FixturePool) -> Result
755    let expanded = quote! {
756      #(#attrs)*
757      #vis fn #fn_name(__pool: ferridriver_test::fixture::FixturePool)
758        -> ::std::pin::Pin<Box<dyn ::std::future::Future<Output = Result<(), ferridriver_test::model::TestFailure>> + Send>>
759      {
760        Box::pin(async move {
761          let #ctx_param_name = ferridriver_test::TestContext::new(__pool);
762          #block
763          Ok(())
764        })
765      }
766
767      inventory::submit! {
768        ferridriver_test::discovery::HookRegistration {
769          module_path: module_path!(),
770          suite_hook_fn: Some(#fn_name),
771          each_hook_fn: None,
772          kind: ferridriver_test::discovery::HookKindTag::#kind_ident,
773        }
774      }
775    };
776    expanded.into()
777  } else {
778    // before_each / after_each: fn(FixturePool, Arc<TestInfo>) -> Result
779    let expanded = quote! {
780      #(#attrs)*
781      #vis fn #fn_name(
782        __pool: ferridriver_test::fixture::FixturePool,
783        __info: ::std::sync::Arc<ferridriver_test::model::TestInfo>,
784      ) -> ::std::pin::Pin<Box<dyn ::std::future::Future<Output = Result<(), ferridriver_test::model::TestFailure>> + Send>>
785      {
786        Box::pin(async move {
787          let #ctx_param_name = ferridriver_test::TestContext::new(__pool);
788          #block
789          Ok(())
790        })
791      }
792
793      inventory::submit! {
794        ferridriver_test::discovery::HookRegistration {
795          module_path: module_path!(),
796          suite_hook_fn: None,
797          each_hook_fn: Some(#fn_name),
798          kind: ferridriver_test::discovery::HookKindTag::#kind_ident,
799        }
800      }
801    };
802    expanded.into()
803  }
804}
805
806/// Runs once before all tests in the containing module (suite).
807///
808/// ```ignore
809/// mod my_suite {
810///     use ferridriver_test::prelude::*;
811///
812///     #[before_all]
813///     async fn setup(ctx: TestContext) {
814///         // seed database, etc.
815///     }
816///
817///     #[ferritest]
818///     async fn test_one(ctx: TestContext) { ... }
819/// }
820/// ```
821#[proc_macro_attribute]
822pub fn before_all(_attr: TokenStream, item: TokenStream) -> TokenStream {
823  hook_impl("BeforeAll", true, item)
824}
825
826/// Runs once after all tests in the containing module (suite).
827#[proc_macro_attribute]
828pub fn after_all(_attr: TokenStream, item: TokenStream) -> TokenStream {
829  hook_impl("AfterAll", true, item)
830}
831
832/// Runs before each test in the containing module (suite).
833///
834/// ```ignore
835/// mod my_suite {
836///     use ferridriver_test::prelude::*;
837///
838///     #[before_each]
839///     async fn login(ctx: TestContext) {
840///         let page = ctx.page().await?;
841///         page.goto("/login", None).await?;
842///     }
843///
844///     #[ferritest]
845///     async fn dashboard_test(ctx: TestContext) { ... }
846/// }
847/// ```
848#[proc_macro_attribute]
849pub fn before_each(_attr: TokenStream, item: TokenStream) -> TokenStream {
850  hook_impl("BeforeEach", false, item)
851}
852
853/// Runs after each test in the containing module (suite), even on failure.
854#[proc_macro_attribute]
855pub fn after_each(_attr: TokenStream, item: TokenStream) -> TokenStream {
856  hook_impl("AfterEach", false, item)
857}