Skip to main content

ferridriver_test/
fixture.rs

1//! Fixture system: dependency-injected, scoped, auto-teardown.
2//!
3//! Built-in fixtures: `browser` (worker scope), `context` (test scope), `page` (test scope).
4//! Custom fixtures can depend on built-ins and each other, forming a DAG.
5//!
6//! Uses lock-free DashMap for fixture values — zero contention on concurrent reads.
7
8use std::any::Any;
9use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12use std::time::Duration;
13
14use dashmap::DashMap;
15use rustc_hash::FxHashMap;
16
17use ferridriver::Browser;
18use ferridriver::backend::BackendKind;
19use ferridriver::options::{BrowserKind, LaunchPlan};
20use ferridriver::state::{BrowserState, ConnectMode};
21
22use crate::config::BrowserConfig;
23
24// ── Types ──
25
26/// Fixture lifecycle scope.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum FixtureScope {
29  /// Created fresh for each test, torn down after.
30  Test,
31  /// Shared across all tests in a single worker.
32  Worker,
33  /// Shared across all workers (global setup/teardown).
34  Global,
35}
36
37/// Type-erased fixture value stored in the pool.
38type ArcValue = Arc<dyn Any + Send + Sync>;
39
40/// Async setup function: receives the `FixturePool` (to resolve deps), returns the value.
41pub type SetupFn =
42  Arc<dyn Fn(FixturePool) -> Pin<Box<dyn Future<Output = ferridriver::error::Result<ArcValue>> + Send>> + Send + Sync>;
43
44/// Async teardown function: receives the Arc value to clean up.
45pub type TeardownFn = Arc<dyn Fn(ArcValue) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
46
47/// Definition of a fixture.
48#[derive(Clone)]
49pub struct FixtureDef {
50  pub name: String,
51  pub scope: FixtureScope,
52  /// Names of fixtures this one depends on.
53  pub dependencies: Vec<String>,
54  pub setup: SetupFn,
55  pub teardown: Option<TeardownFn>,
56  /// Timeout for setup.
57  pub timeout: Duration,
58  /// Playwright `auto: true` semantic — the fixture must resolve for
59  /// every test (and every hook at the matching scope) regardless of
60  /// whether the body asks for it. The worker enumerates all auto
61  /// fixtures at scope-entry time and resolves them before the test
62  /// body runs.
63  pub auto: bool,
64}
65
66// ── Fixture Pool ──
67
68/// Runtime cache of instantiated fixtures with scoped lifecycle management.
69///
70/// Uses lock-free DashMap for fixture values — concurrent reads never block.
71/// Each scope level (global, worker, test) has its own pool instance.
72/// Child pools inherit from parent pools for cross-scope fixture access.
73#[derive(Clone)]
74pub struct FixturePool {
75  inner: Arc<FixturePoolInner>,
76}
77
78struct FixturePoolInner {
79  /// Cached fixture values — lock-free concurrent map.
80  values: DashMap<String, ArcValue>,
81  /// Fixture definitions (shared reference).
82  defs: Arc<FxHashMap<String, FixtureDef>>,
83  /// Teardown stack: LIFO order for cleanup. std::sync::Mutex — only locked briefly.
84  teardown_stack: std::sync::Mutex<Vec<(String, TeardownFn)>>,
85  /// Parent pool (for cross-scope access).
86  parent: Option<FixturePool>,
87  /// This pool's scope.
88  scope: FixtureScope,
89}
90
91impl FixturePool {
92  /// Create a new root fixture pool.
93  pub fn new(defs: FxHashMap<String, FixtureDef>, scope: FixtureScope) -> Self {
94    Self {
95      inner: Arc::new(FixturePoolInner {
96        values: DashMap::new(),
97        defs: Arc::new(defs),
98        teardown_stack: std::sync::Mutex::new(Vec::new()),
99        parent: None,
100        scope,
101      }),
102    }
103  }
104
105  /// Create a child pool that inherits parent fixtures for cross-scope access.
106  pub fn child(&self, scope: FixtureScope) -> Self {
107    Self {
108      inner: Arc::new(FixturePoolInner {
109        values: DashMap::new(),
110        defs: Arc::clone(&self.inner.defs),
111        teardown_stack: std::sync::Mutex::new(Vec::new()),
112        parent: Some(self.clone()),
113        scope,
114      }),
115    }
116  }
117
118  /// Create a child pool with additional or overridden fixture definitions.
119  ///
120  /// This is the core building block for per-test fixture graphs: worker/global
121  /// fixtures live in the parent pool, while test-scoped fixtures can be
122  /// specialized for a single test execution without mutating shared state.
123  pub fn child_with_defs(&self, defs: FxHashMap<String, FixtureDef>, scope: FixtureScope) -> Self {
124    let mut merged = (*self.inner.defs).clone();
125    merged.extend(defs);
126    Self {
127      inner: Arc::new(FixturePoolInner {
128        values: DashMap::new(),
129        defs: Arc::new(merged),
130        teardown_stack: std::sync::Mutex::new(Vec::new()),
131        parent: Some(self.clone()),
132        scope,
133      }),
134    }
135  }
136
137  /// Get or lazily create a fixture by name.
138  ///
139  /// Returns `Arc<T>` since fixture values are shared and not cloneable.
140  /// Resolves dependencies recursively (DAG walk).
141  pub fn get<T: Any + Send + Sync>(
142    &self,
143    name: &str,
144  ) -> Pin<Box<dyn Future<Output = ferridriver::error::Result<Arc<T>>> + Send>> {
145    let pool = self.clone();
146    let name = name.to_string();
147    Box::pin(async move {
148      use ferridriver::FerriError;
149      // Check local cache first (lock-free read).
150      if let Some(val) = pool.inner.values.get(name.as_str()) {
151        return val
152          .value()
153          .clone()
154          .downcast::<T>()
155          .map_err(|_| FerriError::backend(format!("fixture '{name}' type mismatch")));
156      }
157
158      // Check if this fixture belongs to a parent scope.
159      if let Some(def) = pool.inner.defs.get(name.as_str()) {
160        if scope_rank(def.scope) > scope_rank(pool.inner.scope) {
161          if let Some(parent) = &pool.inner.parent {
162            return parent.get::<T>(&name).await;
163          }
164        }
165      } else if let Some(parent) = &pool.inner.parent {
166        return parent.get::<T>(&name).await;
167      }
168
169      // Resolve dependencies first.
170      if let Some(def) = pool.inner.defs.get(name.as_str()) {
171        for dep in &def.dependencies {
172          ensure_resolved(&pool, dep).await?;
173        }
174      }
175
176      // Set up the fixture.
177      let def = pool
178        .inner
179        .defs
180        .get(name.as_str())
181        .ok_or_else(|| FerriError::backend(format!("fixture '{name}' not defined")))?;
182
183      let setup = Arc::clone(&def.setup);
184      let teardown = def.teardown.as_ref().map(Arc::clone);
185      let timeout = def.timeout;
186
187      tracing::debug!(target: "ferridriver::fixture", fixture = name, "setting up fixture");
188      let arc_val = tokio::time::timeout(timeout, setup(pool.clone()))
189        .await
190        .map_err(|_| FerriError::timeout(format!("fixture '{name}' setup"), timeout.as_millis() as u64))?
191        .map_err(|e| FerriError::backend(format!("fixture '{name}' setup failed: {e}")))?;
192
193      // Cache (lock-free insert).
194      pool.inner.values.insert(name.to_string(), Arc::clone(&arc_val));
195
196      // Register teardown.
197      if let Some(td) = teardown {
198        let mut stack = pool.inner.teardown_stack.lock().expect("teardown_stack lock poisoned");
199        stack.push((name.to_string(), td));
200      }
201
202      arc_val
203        .downcast::<T>()
204        .map_err(|_| FerriError::backend(format!("fixture '{name}' type mismatch")))
205    })
206  }
207
208  /// Synchronously get an already-resolved fixture from the cache.
209  /// Returns None if the fixture hasn't been resolved yet.
210  /// Lock-free DashMap read — no async needed.
211  /// Used by NAPI lazy fixture getters to avoid redundant async resolution.
212  pub fn try_get_cached<T: Any + Send + Sync>(&self, name: &str) -> Option<Arc<T>> {
213    if let Some(val) = self.inner.values.get(name) {
214      val.value().clone().downcast::<T>().ok()
215    } else if let Some(parent) = &self.inner.parent {
216      parent.try_get_cached::<T>(name)
217    } else {
218      None
219    }
220  }
221
222  /// Inject a pre-created fixture value into the pool (skips setup).
223  /// Lock-free DashMap insert — no async needed.
224  pub fn inject<T: Any + Send + Sync>(&self, name: &str, value: Arc<T>) {
225    self.inner.values.insert(name.to_string(), value as ArcValue);
226  }
227
228  /// Resolve a fixture by name without knowing its concrete type.
229  pub async fn resolve(&self, name: &str) -> ferridriver::error::Result<()> {
230    ensure_resolved(self, name).await
231  }
232
233  /// Names of every fixture marked `auto: true` whose scope matches the
234  /// argument or any narrower scope (Test fixtures get included for
235  /// Test pools; Worker auto fixtures get included for Worker pools).
236  /// Walks the parent chain so worker-scope auto fixtures are visible
237  /// from a test-scope child pool.
238  #[must_use]
239  pub fn auto_fixture_names_for(&self, scope: FixtureScope) -> Vec<String> {
240    let mut names: Vec<String> = Vec::new();
241    let want_rank = scope_rank(scope);
242    for (name, def) in self.inner.defs.iter() {
243      if def.auto && scope_rank(def.scope) <= want_rank {
244        names.push(name.clone());
245      }
246    }
247    if let Some(parent) = &self.inner.parent {
248      for n in parent.auto_fixture_names_for(scope) {
249        if !names.contains(&n) {
250          names.push(n);
251        }
252      }
253    }
254    names
255  }
256
257  /// Tear down all fixtures in this pool (reverse order).
258  pub async fn teardown_all(&self) {
259    let items: Vec<(String, TeardownFn)> = {
260      let mut stack = self.inner.teardown_stack.lock().expect("teardown_stack lock poisoned");
261      stack.drain(..).rev().collect()
262    };
263
264    for (name, teardown_fn) in items {
265      let value = self.inner.values.remove(&name).map(|(_, v)| v);
266      if let Some(val) = value {
267        tracing::debug!(target: "ferridriver::fixture", "tearing down fixture: {name}");
268        teardown_fn(val).await;
269      }
270    }
271  }
272}
273
274/// Ensure a fixture is resolved (trigger creation without needing a concrete type).
275fn ensure_resolved(
276  pool: &FixturePool,
277  name: &str,
278) -> Pin<Box<dyn Future<Output = ferridriver::error::Result<()>> + Send>> {
279  let pool = pool.clone();
280  let name = name.to_string();
281  Box::pin(async move {
282    // Check if already cached (lock-free read).
283    if pool.inner.values.contains_key(name.as_str()) {
284      return Ok(());
285    }
286
287    // Check parent scope.
288    if let Some(def) = pool.inner.defs.get(name.as_str()) {
289      if scope_rank(def.scope) > scope_rank(pool.inner.scope) {
290        if let Some(parent) = &pool.inner.parent {
291          return ensure_resolved(parent, &name).await;
292        }
293      }
294    } else if let Some(parent) = &pool.inner.parent {
295      return ensure_resolved(parent, &name).await;
296    }
297
298    // Resolve dependencies.
299    if let Some(def) = pool.inner.defs.get(name.as_str()) {
300      for dep in &def.dependencies {
301        ensure_resolved(&pool, dep).await?;
302      }
303    }
304
305    // Set up.
306    use ferridriver::FerriError;
307    let def = pool
308      .inner
309      .defs
310      .get(name.as_str())
311      .ok_or_else(|| FerriError::backend(format!("fixture '{name}' not defined")))?;
312    let setup = Arc::clone(&def.setup);
313    let teardown = def.teardown.as_ref().map(Arc::clone);
314    let timeout = def.timeout;
315
316    let arc_val = tokio::time::timeout(timeout, setup(pool.clone()))
317      .await
318      .map_err(|_| FerriError::timeout(format!("fixture '{name}' setup"), timeout.as_millis() as u64))?
319      .map_err(|e| FerriError::backend(format!("fixture '{name}' setup failed: {e}")))?;
320
321    pool.inner.values.insert(name.to_string(), arc_val);
322    if let Some(td) = teardown {
323      let mut stack = pool.inner.teardown_stack.lock().expect("teardown_stack lock poisoned");
324      stack.push((name.to_string(), td));
325    }
326    Ok(())
327  })
328}
329
330fn scope_rank(scope: FixtureScope) -> u8 {
331  match scope {
332    FixtureScope::Test => 0,
333    FixtureScope::Worker => 1,
334    FixtureScope::Global => 2,
335  }
336}
337
338/// Validate that fixture definitions form a DAG (no cycles).
339pub fn validate_dag(defs: &FxHashMap<String, FixtureDef>) -> ferridriver::error::Result<()> {
340  use ferridriver::FerriError;
341  use std::collections::HashSet;
342
343  fn visit(
344    name: &str,
345    defs: &FxHashMap<String, FixtureDef>,
346    visiting: &mut HashSet<String>,
347    visited: &mut HashSet<String>,
348  ) -> ferridriver::error::Result<()> {
349    if visited.contains(name) {
350      return Ok(());
351    }
352    if !visiting.insert(name.to_string()) {
353      return Err(FerriError::invalid_argument(
354        "fixture",
355        format!("circular fixture dependency involving '{name}'"),
356      ));
357    }
358    if let Some(def) = defs.get(name) {
359      for dep in &def.dependencies {
360        visit(dep, defs, visiting, visited)?;
361      }
362    }
363    visiting.remove(name);
364    visited.insert(name.to_string());
365    Ok(())
366  }
367
368  let mut visiting = HashSet::new();
369  let mut visited = HashSet::new();
370  for name in defs.keys() {
371    visit(name, defs, &mut visiting, &mut visited)?;
372  }
373  Ok(())
374}
375
376/// Built-in fixture definitions for the ferridriver test runner.
377pub fn builtin_fixtures(browser_config: &BrowserConfig) -> FxHashMap<String, FixtureDef> {
378  let mut defs = FxHashMap::default();
379
380  let backend = match browser_config.backend.as_str() {
381    "cdp-raw" => BackendKind::CdpRaw,
382    "webkit" => BackendKind::WebKit,
383    "bidi" => BackendKind::Bidi,
384    _ => BackendKind::CdpPipe,
385  };
386  let kind = match browser_config.browser.as_str() {
387    "firefox" => BrowserKind::Firefox,
388    "webkit" => BrowserKind::WebKit,
389    _ => BrowserKind::Chromium,
390  };
391  let headless = browser_config.headless;
392  let executable_path = browser_config.executable_path.clone();
393  let args = browser_config.args.clone();
394  let viewport = browser_config
395    .viewport
396    .as_ref()
397    .map(|v| ferridriver::options::ViewportConfig {
398      width: v.width,
399      height: v.height,
400      ..Default::default()
401    });
402
403  // browser (Worker scope)
404  defs.insert(
405    "browser".into(),
406    FixtureDef {
407      name: "browser".into(),
408      scope: FixtureScope::Worker,
409      dependencies: vec![],
410      setup: Arc::new(move |_pool| {
411        let exec = executable_path.clone();
412        let extra_args = args.clone();
413        let vp = viewport.clone();
414        Box::pin(async move {
415          let plan = LaunchPlan {
416            backend,
417            kind,
418            headless,
419            executable_path: exec,
420            args: extra_args,
421            default_viewport: vp,
422            ..Default::default()
423          };
424          let mut state = BrowserState::with_plan(ConnectMode::Launch, plan);
425          Box::pin(state.ensure_browser()).await?;
426          let browser = Browser::from_state(state);
427          Ok(Arc::new(browser) as ArcValue)
428        })
429      }),
430      teardown: Some(Arc::new(|val| {
431        Box::pin(async move {
432          if let Ok(browser) = val.downcast::<Browser>() {
433            let _ = browser.close(None).await;
434          }
435        })
436      })),
437      timeout: Duration::from_secs(30),
438      auto: false,
439    },
440  );
441
442  // context (Test scope, depends on browser)
443  defs.insert(
444    "context".into(),
445    FixtureDef {
446      name: "context".into(),
447      scope: FixtureScope::Test,
448      dependencies: vec!["browser".into()],
449      setup: Arc::new(|pool| {
450        Box::pin(async move {
451          let browser: Arc<Browser> = pool.get("browser").await?;
452          let context = browser.new_context(None);
453          Ok(Arc::new(context) as ArcValue)
454        })
455      }),
456      teardown: Some(Arc::new(|val| {
457        Box::pin(async move {
458          if let Ok(ctx) = val.downcast::<ferridriver::ContextRef>() {
459            let _ = ctx.close().await;
460          }
461        })
462      })),
463      timeout: Duration::from_secs(10),
464      auto: false,
465    },
466  );
467
468  // page (Test scope, depends on context)
469  defs.insert(
470    "page".into(),
471    FixtureDef {
472      name: "page".into(),
473      scope: FixtureScope::Test,
474      dependencies: vec!["context".into()],
475      setup: Arc::new(|pool| {
476        Box::pin(async move {
477          let context: Arc<ferridriver::ContextRef> = pool.get("context").await?;
478          let page = context.new_page().await?;
479          Ok(Arc::new(page) as ArcValue)
480        })
481      }),
482      teardown: None,
483      timeout: Duration::from_secs(10),
484      auto: false,
485    },
486  );
487
488  defs
489}