1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum FixtureScope {
29 Test,
31 Worker,
33 Global,
35}
36
37type ArcValue = Arc<dyn Any + Send + Sync>;
39
40pub type SetupFn =
42 Arc<dyn Fn(FixturePool) -> Pin<Box<dyn Future<Output = ferridriver::error::Result<ArcValue>> + Send>> + Send + Sync>;
43
44pub type TeardownFn = Arc<dyn Fn(ArcValue) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
46
47#[derive(Clone)]
49pub struct FixtureDef {
50 pub name: String,
51 pub scope: FixtureScope,
52 pub dependencies: Vec<String>,
54 pub setup: SetupFn,
55 pub teardown: Option<TeardownFn>,
56 pub timeout: Duration,
58 pub auto: bool,
64}
65
66#[derive(Clone)]
74pub struct FixturePool {
75 inner: Arc<FixturePoolInner>,
76}
77
78struct FixturePoolInner {
79 values: DashMap<String, ArcValue>,
81 defs: Arc<FxHashMap<String, FixtureDef>>,
83 teardown_stack: std::sync::Mutex<Vec<(String, TeardownFn)>>,
85 parent: Option<FixturePool>,
87 scope: FixtureScope,
89}
90
91impl FixturePool {
92 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 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 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 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 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 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 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 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 pool.inner.values.insert(name.to_string(), Arc::clone(&arc_val));
195
196 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 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 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 pub async fn resolve(&self, name: &str) -> ferridriver::error::Result<()> {
230 ensure_resolved(self, name).await
231 }
232
233 #[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 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
274fn 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 if pool.inner.values.contains_key(name.as_str()) {
284 return Ok(());
285 }
286
287 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 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 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
338pub 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
376pub 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 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 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 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}