use std::any::Any;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use rustc_hash::FxHashMap;
use ferridriver::Browser;
use ferridriver::backend::BackendKind;
use ferridriver::options::{BrowserKind, LaunchPlan};
use ferridriver::state::{BrowserState, ConnectMode};
use crate::config::BrowserConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FixtureScope {
Test,
Worker,
Global,
}
type ArcValue = Arc<dyn Any + Send + Sync>;
pub type SetupFn =
Arc<dyn Fn(FixturePool) -> Pin<Box<dyn Future<Output = ferridriver::error::Result<ArcValue>> + Send>> + Send + Sync>;
pub type TeardownFn = Arc<dyn Fn(ArcValue) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
#[derive(Clone)]
pub struct FixtureDef {
pub name: String,
pub scope: FixtureScope,
pub dependencies: Vec<String>,
pub setup: SetupFn,
pub teardown: Option<TeardownFn>,
pub timeout: Duration,
pub auto: bool,
}
#[derive(Clone)]
pub struct FixturePool {
inner: Arc<FixturePoolInner>,
}
struct FixturePoolInner {
values: DashMap<String, ArcValue>,
defs: Arc<FxHashMap<String, FixtureDef>>,
teardown_stack: std::sync::Mutex<Vec<(String, TeardownFn)>>,
parent: Option<FixturePool>,
scope: FixtureScope,
}
impl FixturePool {
pub fn new(defs: FxHashMap<String, FixtureDef>, scope: FixtureScope) -> Self {
Self {
inner: Arc::new(FixturePoolInner {
values: DashMap::new(),
defs: Arc::new(defs),
teardown_stack: std::sync::Mutex::new(Vec::new()),
parent: None,
scope,
}),
}
}
pub fn child(&self, scope: FixtureScope) -> Self {
Self {
inner: Arc::new(FixturePoolInner {
values: DashMap::new(),
defs: Arc::clone(&self.inner.defs),
teardown_stack: std::sync::Mutex::new(Vec::new()),
parent: Some(self.clone()),
scope,
}),
}
}
pub fn child_with_defs(&self, defs: FxHashMap<String, FixtureDef>, scope: FixtureScope) -> Self {
let mut merged = (*self.inner.defs).clone();
merged.extend(defs);
Self {
inner: Arc::new(FixturePoolInner {
values: DashMap::new(),
defs: Arc::new(merged),
teardown_stack: std::sync::Mutex::new(Vec::new()),
parent: Some(self.clone()),
scope,
}),
}
}
pub fn get<T: Any + Send + Sync>(
&self,
name: &str,
) -> Pin<Box<dyn Future<Output = ferridriver::error::Result<Arc<T>>> + Send>> {
let pool = self.clone();
let name = name.to_string();
Box::pin(async move {
use ferridriver::FerriError;
if let Some(val) = pool.inner.values.get(name.as_str()) {
return val
.value()
.clone()
.downcast::<T>()
.map_err(|_| FerriError::backend(format!("fixture '{name}' type mismatch")));
}
if let Some(def) = pool.inner.defs.get(name.as_str()) {
if scope_rank(def.scope) > scope_rank(pool.inner.scope) {
if let Some(parent) = &pool.inner.parent {
return parent.get::<T>(&name).await;
}
}
} else if let Some(parent) = &pool.inner.parent {
return parent.get::<T>(&name).await;
}
if let Some(def) = pool.inner.defs.get(name.as_str()) {
for dep in &def.dependencies {
ensure_resolved(&pool, dep).await?;
}
}
let def = pool
.inner
.defs
.get(name.as_str())
.ok_or_else(|| FerriError::backend(format!("fixture '{name}' not defined")))?;
let setup = Arc::clone(&def.setup);
let teardown = def.teardown.as_ref().map(Arc::clone);
let timeout = def.timeout;
tracing::debug!(target: "ferridriver::fixture", fixture = name, "setting up fixture");
let arc_val = tokio::time::timeout(timeout, setup(pool.clone()))
.await
.map_err(|_| FerriError::timeout(format!("fixture '{name}' setup"), timeout.as_millis() as u64))?
.map_err(|e| FerriError::backend(format!("fixture '{name}' setup failed: {e}")))?;
pool.inner.values.insert(name.to_string(), Arc::clone(&arc_val));
if let Some(td) = teardown {
let mut stack = pool.inner.teardown_stack.lock().expect("teardown_stack lock poisoned");
stack.push((name.to_string(), td));
}
arc_val
.downcast::<T>()
.map_err(|_| FerriError::backend(format!("fixture '{name}' type mismatch")))
})
}
pub fn try_get_cached<T: Any + Send + Sync>(&self, name: &str) -> Option<Arc<T>> {
if let Some(val) = self.inner.values.get(name) {
val.value().clone().downcast::<T>().ok()
} else if let Some(parent) = &self.inner.parent {
parent.try_get_cached::<T>(name)
} else {
None
}
}
pub fn inject<T: Any + Send + Sync>(&self, name: &str, value: Arc<T>) {
self.inner.values.insert(name.to_string(), value as ArcValue);
}
pub async fn resolve(&self, name: &str) -> ferridriver::error::Result<()> {
ensure_resolved(self, name).await
}
#[must_use]
pub fn auto_fixture_names_for(&self, scope: FixtureScope) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
let want_rank = scope_rank(scope);
for (name, def) in self.inner.defs.iter() {
if def.auto && scope_rank(def.scope) <= want_rank {
names.push(name.clone());
}
}
if let Some(parent) = &self.inner.parent {
for n in parent.auto_fixture_names_for(scope) {
if !names.contains(&n) {
names.push(n);
}
}
}
names
}
pub async fn teardown_all(&self) {
let items: Vec<(String, TeardownFn)> = {
let mut stack = self.inner.teardown_stack.lock().expect("teardown_stack lock poisoned");
stack.drain(..).rev().collect()
};
for (name, teardown_fn) in items {
let value = self.inner.values.remove(&name).map(|(_, v)| v);
if let Some(val) = value {
tracing::debug!(target: "ferridriver::fixture", "tearing down fixture: {name}");
teardown_fn(val).await;
}
}
}
}
fn ensure_resolved(
pool: &FixturePool,
name: &str,
) -> Pin<Box<dyn Future<Output = ferridriver::error::Result<()>> + Send>> {
let pool = pool.clone();
let name = name.to_string();
Box::pin(async move {
if pool.inner.values.contains_key(name.as_str()) {
return Ok(());
}
if let Some(def) = pool.inner.defs.get(name.as_str()) {
if scope_rank(def.scope) > scope_rank(pool.inner.scope) {
if let Some(parent) = &pool.inner.parent {
return ensure_resolved(parent, &name).await;
}
}
} else if let Some(parent) = &pool.inner.parent {
return ensure_resolved(parent, &name).await;
}
if let Some(def) = pool.inner.defs.get(name.as_str()) {
for dep in &def.dependencies {
ensure_resolved(&pool, dep).await?;
}
}
use ferridriver::FerriError;
let def = pool
.inner
.defs
.get(name.as_str())
.ok_or_else(|| FerriError::backend(format!("fixture '{name}' not defined")))?;
let setup = Arc::clone(&def.setup);
let teardown = def.teardown.as_ref().map(Arc::clone);
let timeout = def.timeout;
let arc_val = tokio::time::timeout(timeout, setup(pool.clone()))
.await
.map_err(|_| FerriError::timeout(format!("fixture '{name}' setup"), timeout.as_millis() as u64))?
.map_err(|e| FerriError::backend(format!("fixture '{name}' setup failed: {e}")))?;
pool.inner.values.insert(name.to_string(), arc_val);
if let Some(td) = teardown {
let mut stack = pool.inner.teardown_stack.lock().expect("teardown_stack lock poisoned");
stack.push((name.to_string(), td));
}
Ok(())
})
}
fn scope_rank(scope: FixtureScope) -> u8 {
match scope {
FixtureScope::Test => 0,
FixtureScope::Worker => 1,
FixtureScope::Global => 2,
}
}
pub fn validate_dag(defs: &FxHashMap<String, FixtureDef>) -> ferridriver::error::Result<()> {
use ferridriver::FerriError;
use std::collections::HashSet;
fn visit(
name: &str,
defs: &FxHashMap<String, FixtureDef>,
visiting: &mut HashSet<String>,
visited: &mut HashSet<String>,
) -> ferridriver::error::Result<()> {
if visited.contains(name) {
return Ok(());
}
if !visiting.insert(name.to_string()) {
return Err(FerriError::invalid_argument(
"fixture",
format!("circular fixture dependency involving '{name}'"),
));
}
if let Some(def) = defs.get(name) {
for dep in &def.dependencies {
visit(dep, defs, visiting, visited)?;
}
}
visiting.remove(name);
visited.insert(name.to_string());
Ok(())
}
let mut visiting = HashSet::new();
let mut visited = HashSet::new();
for name in defs.keys() {
visit(name, defs, &mut visiting, &mut visited)?;
}
Ok(())
}
pub fn builtin_fixtures(browser_config: &BrowserConfig) -> FxHashMap<String, FixtureDef> {
let mut defs = FxHashMap::default();
let backend = match browser_config.backend.as_str() {
"cdp-raw" => BackendKind::CdpRaw,
"webkit" => BackendKind::WebKit,
"bidi" => BackendKind::Bidi,
_ => BackendKind::CdpPipe,
};
let kind = match browser_config.browser.as_str() {
"firefox" => BrowserKind::Firefox,
"webkit" => BrowserKind::WebKit,
_ => BrowserKind::Chromium,
};
let headless = browser_config.headless;
let executable_path = browser_config.executable_path.clone();
let args = browser_config.args.clone();
let viewport = browser_config
.viewport
.as_ref()
.map(|v| ferridriver::options::ViewportConfig {
width: v.width,
height: v.height,
..Default::default()
});
defs.insert(
"browser".into(),
FixtureDef {
name: "browser".into(),
scope: FixtureScope::Worker,
dependencies: vec![],
setup: Arc::new(move |_pool| {
let exec = executable_path.clone();
let extra_args = args.clone();
let vp = viewport.clone();
Box::pin(async move {
let plan = LaunchPlan {
backend,
kind,
headless,
executable_path: exec,
args: extra_args,
default_viewport: vp,
..Default::default()
};
let mut state = BrowserState::with_plan(ConnectMode::Launch, plan);
Box::pin(state.ensure_browser()).await?;
let browser = Browser::from_state(state);
Ok(Arc::new(browser) as ArcValue)
})
}),
teardown: Some(Arc::new(|val| {
Box::pin(async move {
if let Ok(browser) = val.downcast::<Browser>() {
let _ = browser.close(None).await;
}
})
})),
timeout: Duration::from_secs(30),
auto: false,
},
);
defs.insert(
"context".into(),
FixtureDef {
name: "context".into(),
scope: FixtureScope::Test,
dependencies: vec!["browser".into()],
setup: Arc::new(|pool| {
Box::pin(async move {
let browser: Arc<Browser> = pool.get("browser").await?;
let context = browser.new_context(None);
Ok(Arc::new(context) as ArcValue)
})
}),
teardown: Some(Arc::new(|val| {
Box::pin(async move {
if let Ok(ctx) = val.downcast::<ferridriver::ContextRef>() {
let _ = ctx.close().await;
}
})
})),
timeout: Duration::from_secs(10),
auto: false,
},
);
defs.insert(
"page".into(),
FixtureDef {
name: "page".into(),
scope: FixtureScope::Test,
dependencies: vec!["context".into()],
setup: Arc::new(|pool| {
Box::pin(async move {
let context: Arc<ferridriver::ContextRef> = pool.get("context").await?;
let page = context.new_page().await?;
Ok(Arc::new(page) as ArcValue)
})
}),
teardown: None,
timeout: Duration::from_secs(10),
auto: false,
},
);
defs
}