1use std::sync::Arc;
13use std::time::{Duration, Instant};
14
15use rustc_hash::FxHashMap;
16use tokio::sync::{Mutex, mpsc};
17
18use crate::config::{ContextConfig, TestConfig, ViewportConfig};
19use crate::dispatcher::{SerialBatch, TestAssignment, WorkItem};
20use crate::fixture::{FixtureDef, FixturePool, FixtureScope};
21use crate::model::{
22 Attachment, AttachmentBody, ExpectedStatus, Hooks, StepCategory, TestAnnotation, TestFailure, TestInfo, TestOutcome,
23 TestStatus,
24};
25use crate::reporter::{EventBus, ReporterEvent};
26
27#[derive(Clone)]
28struct EffectiveContextConfig {
29 context: ContextConfig,
30 default_viewport: Option<ViewportConfig>,
31 viewport_override: Option<ViewportConfig>,
32 request_base_url: Option<String>,
33}
34
35enum TestBrowserState {
36 Empty,
37 Context(Arc<ferridriver::ContextRef>),
38 Page {
39 ctx: Arc<ferridriver::ContextRef>,
40 page: Arc<ferridriver::Page>,
41 },
42 Failed(ferridriver::FerriError),
43}
44
45struct TestBrowserResources {
46 handle: Arc<crate::runner::BrowserHandle>,
47 effective: EffectiveContextConfig,
48 output_dir: std::path::PathBuf,
49 state: Mutex<TestBrowserState>,
50}
51
52fn is_retryable_bidi_page_error(err: &ferridriver::FerriError) -> bool {
53 let s = err.to_string();
54 s.contains("DiscardedBrowsingContextError")
55 || s.contains("BrowsingContext does no longer exist")
56 || s.contains("BiDi error 'no such frame'")
57 || s.contains("BiDi error 'no such window'")
58}
59
60async fn ensure_page_alive(page: &Arc<ferridriver::Page>) -> ferridriver::Result<()> {
61 page.inner().evaluate("1").await.map(|_| ())
71}
72
73fn needs_alive_check(backend: ferridriver::backend::BackendKind) -> bool {
78 matches!(backend, ferridriver::backend::BackendKind::Bidi)
79}
80
81async fn create_ready_page(
82 ctx: &ferridriver::ContextRef,
83 backend: ferridriver::backend::BackendKind,
84) -> ferridriver::error::Result<Arc<ferridriver::Page>> {
85 let page = ctx.new_page().await?;
86 if needs_alive_check(backend) {
87 ensure_page_alive(&page).await?;
88 }
89 Ok(page)
90}
91
92impl TestBrowserResources {
93 fn new(
94 handle: Arc<crate::runner::BrowserHandle>,
95 effective: EffectiveContextConfig,
96 output_dir: std::path::PathBuf,
97 ) -> Self {
98 Self {
99 handle,
100 effective,
101 output_dir,
102 state: Mutex::new(TestBrowserState::Empty),
103 }
104 }
105
106 async fn context(&self) -> ferridriver::error::Result<Arc<ferridriver::ContextRef>> {
107 let mut state = self.state.lock().await;
108 match &mut *state {
109 TestBrowserState::Context(ctx) => Ok(Arc::clone(ctx)),
110 TestBrowserState::Page { ctx, .. } => Ok(Arc::clone(ctx)),
111 TestBrowserState::Failed(err) => Err(err.clone()),
112 TestBrowserState::Empty => {
113 let browser = self.handle.get().await?;
114 let ctx = Arc::new(new_test_context(&browser));
115 *state = TestBrowserState::Context(Arc::clone(&ctx));
116 Ok(ctx)
117 },
118 }
119 }
120
121 #[tracing::instrument(skip_all, name = "page_fixture")]
122 async fn page(&self) -> ferridriver::error::Result<Arc<ferridriver::Page>> {
123 let mut state = self.state.lock().await;
124 match &mut *state {
125 TestBrowserState::Page { page, .. } => Ok(Arc::clone(page)),
126 TestBrowserState::Failed(err) => Err(err.clone()),
127 TestBrowserState::Context(ctx) => {
128 let browser = self.handle.get().await?;
129 let backend = browser.backend_kind();
130 let page = create_ready_page(ctx, backend).await?;
131 apply_page_config(&page, &self.effective, &self.output_dir, backend).await?;
132 let ctx = Arc::clone(ctx);
133 *state = TestBrowserState::Page {
134 ctx,
135 page: Arc::clone(&page),
136 };
137 Ok(page)
138 },
139 TestBrowserState::Empty => {
140 let browser = self.handle.get().await?;
141 let backend = browser.backend_kind();
142 let ctx = Arc::new(new_test_context(&browser));
143 match create_ready_page(&ctx, backend).await {
144 Ok(page) => {
145 apply_page_config(&page, &self.effective, &self.output_dir, backend).await?;
146 *state = TestBrowserState::Page {
147 ctx: Arc::clone(&ctx),
148 page: Arc::clone(&page),
149 };
150 Ok(page)
151 },
152 Err(err) => {
153 if is_retryable_bidi_page_error(&err) {
154 let _ = ctx.close().await;
155 let ctx = Arc::new(new_test_context(&browser));
156 let page = create_ready_page(&ctx, backend).await?;
157 apply_page_config(&page, &self.effective, &self.output_dir, backend).await?;
158 *state = TestBrowserState::Page {
159 ctx,
160 page: Arc::clone(&page),
161 };
162 return Ok(page);
163 }
164 *state = TestBrowserState::Failed(err.clone());
165 Err(err)
166 },
167 }
168 },
169 }
170 }
171
172 async fn close(&self) {
173 let mut state = self.state.lock().await;
174 match std::mem::replace(&mut *state, TestBrowserState::Empty) {
175 TestBrowserState::Context(ctx) => {
176 close_test_context(&ctx).await;
177 },
178 TestBrowserState::Page { ctx, page } => {
179 if ctx.name() == "default" {
188 let _ = page.close(None).await;
189 } else {
190 drop(page);
191 }
192 close_test_context(&ctx).await;
193 },
194 TestBrowserState::Empty | TestBrowserState::Failed(_) => {},
195 }
196 }
197}
198
199fn new_test_context(browser: &Arc<ferridriver::Browser>) -> ferridriver::ContextRef {
205 if browser.supports_isolated_contexts() {
206 browser.new_context(None)
207 } else {
208 browser.default_context()
209 }
210}
211
212async fn close_test_context(ctx: &ferridriver::ContextRef) {
217 if ctx.name() == "default" {
218 return;
219 }
220 let _ = ctx.close().await;
221}
222
223fn build_effective_context_config(config: &TestConfig, test: &crate::model::TestCase) -> EffectiveContextConfig {
224 let mut ctx_config = config.browser.use_options.clone();
225 if let Some(ref opts) = test.use_options {
226 if let Some(v) = opts.get("locale").and_then(|v| v.as_str()) {
227 ctx_config.locale = Some(v.to_string());
228 }
229 if let Some(v) = opts.get("colorScheme").and_then(|v| v.as_str()) {
230 ctx_config.color_scheme = Some(v.to_string());
231 }
232 if let Some(v) = opts.get("timezoneId").and_then(|v| v.as_str()) {
233 ctx_config.timezone_id = Some(v.to_string());
234 }
235 if let Some(v) = opts.get("isMobile").and_then(|v| v.as_bool()) {
236 ctx_config.is_mobile = v;
237 }
238 if let Some(v) = opts.get("hasTouch").and_then(|v| v.as_bool()) {
239 ctx_config.has_touch = v;
240 }
241 if let Some(v) = opts.get("offline").and_then(|v| v.as_bool()) {
242 ctx_config.offline = v;
243 }
244 if let Some(v) = opts.get("javaScriptEnabled").and_then(|v| v.as_bool()) {
245 ctx_config.java_script_enabled = v;
246 }
247 if let Some(v) = opts.get("bypassCSP").and_then(|v| v.as_bool()) {
248 ctx_config.bypass_csp = v;
249 }
250 if let Some(v) = opts.get("userAgent").and_then(|v| v.as_str()) {
251 ctx_config.user_agent = Some(v.to_string());
252 }
253 if let Some(v) = opts.get("deviceScaleFactor").and_then(|v| v.as_f64()) {
254 ctx_config.device_scale_factor = Some(v);
255 }
256 if let Some(v) = opts.get("reducedMotion").and_then(|v| v.as_str()) {
257 ctx_config.reduced_motion = Some(v.to_string());
258 }
259 if let Some(v) = opts.get("forcedColors").and_then(|v| v.as_str()) {
260 ctx_config.forced_colors = Some(v.to_string());
261 }
262 if let Some(v) = opts.get("serviceWorkers").and_then(|v| v.as_str()) {
263 ctx_config.service_workers = Some(v.to_string());
264 }
265 if let Some(v) = opts.get("storageState").and_then(|v| v.as_str()) {
266 ctx_config.storage_state = Some(v.to_string());
267 }
268 if let Some(v) = opts.get("acceptDownloads").and_then(|v| v.as_bool()) {
269 ctx_config.accept_downloads = v;
270 }
271 if let Some(v) = opts.get("ignoreHTTPSErrors").and_then(|v| v.as_bool()) {
272 ctx_config.ignore_https_errors = v;
273 }
274 if let Some(geo) = opts.get("geolocation").and_then(|v| v.as_object()) {
275 if let (Some(lat), Some(lon)) = (
276 geo.get("latitude").and_then(|v| v.as_f64()),
277 geo.get("longitude").and_then(|v| v.as_f64()),
278 ) {
279 ctx_config.geolocation = Some(crate::config::GeolocationConfig {
280 latitude: lat,
281 longitude: lon,
282 accuracy: geo.get("accuracy").and_then(|v| v.as_f64()),
283 });
284 }
285 }
286 if let Some(arr) = opts.get("permissions").and_then(|v| v.as_array()) {
287 let perms: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
288 if !perms.is_empty() {
289 ctx_config.permissions = perms;
290 }
291 }
292 if let Some(obj) = opts.get("extraHTTPHeaders").and_then(|v| v.as_object()) {
293 let headers: std::collections::BTreeMap<String, String> = obj
294 .iter()
295 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
296 .collect();
297 if !headers.is_empty() {
298 ctx_config.extra_http_headers = headers;
299 }
300 }
301 if let Some(creds) = opts.get("httpCredentials").and_then(|v| v.as_object()) {
302 if let (Some(user), Some(pass)) = (
303 creds.get("username").and_then(|v| v.as_str()),
304 creds.get("password").and_then(|v| v.as_str()),
305 ) {
306 ctx_config.http_credentials = Some(crate::config::HttpCredentialsConfig {
307 username: user.to_string(),
308 password: pass.to_string(),
309 origin: creds.get("origin").and_then(|v| v.as_str()).map(String::from),
310 });
311 }
312 }
313 }
314
315 let viewport_override = test.use_options.as_ref().and_then(|opts| {
316 opts.get("viewport").and_then(|v| {
317 let w = v.get("width").and_then(|w| w.as_i64());
318 let h = v.get("height").and_then(|h| h.as_i64());
319 match (w, h) {
320 (Some(w), Some(h)) => Some(ViewportConfig { width: w, height: h }),
321 _ => None,
322 }
323 })
324 });
325
326 let request_base_url = test
327 .use_options
328 .as_ref()
329 .and_then(|opts| opts.get("baseURL").and_then(|v| v.as_str()).map(String::from))
330 .or_else(|| config.base_url.clone());
331
332 if ctx_config.storage_state.is_none() {
333 ctx_config.storage_state.clone_from(&config.storage_state);
334 }
335
336 EffectiveContextConfig {
337 context: ctx_config,
338 default_viewport: config.browser.viewport.clone(),
339 viewport_override,
340 request_base_url,
341 }
342}
343
344fn build_suite_effective_context_config(config: &TestConfig) -> EffectiveContextConfig {
345 let mut ctx_config = config.browser.use_options.clone();
346 if ctx_config.storage_state.is_none() {
347 ctx_config.storage_state.clone_from(&config.storage_state);
348 }
349
350 EffectiveContextConfig {
351 context: ctx_config,
352 default_viewport: config.browser.viewport.clone(),
353 viewport_override: None,
354 request_base_url: config.base_url.clone(),
355 }
356}
357
358async fn apply_page_config(
359 page: &Arc<ferridriver::Page>,
360 effective: &EffectiveContextConfig,
361 output_dir: &std::path::Path,
362 backend_kind: ferridriver::backend::BackendKind,
363) -> ferridriver::error::Result<()> {
364 let ctx_config = &effective.context;
365 let mut opts = ferridriver::options::BrowserContextOptions::default();
366 let is_webkit = matches!(backend_kind, ferridriver::backend::BackendKind::WebKit);
370
371 let viewport = effective
372 .viewport_override
373 .as_ref()
374 .or(effective.default_viewport.as_ref());
375 if let Some(vp) = viewport {
376 opts.viewport = ferridriver::options::ViewportOption::Size {
377 width: vp.width,
378 height: vp.height,
379 };
380 }
381 opts.device_scale_factor = ctx_config.device_scale_factor;
382 if ctx_config.is_mobile {
383 opts.is_mobile = Some(true);
384 }
385 if ctx_config.has_touch {
386 opts.has_touch = Some(true);
387 }
388 opts.color_scheme = ctx_config.color_scheme.clone().into();
389 opts.reduced_motion = ctx_config.reduced_motion.clone().into();
390 opts.forced_colors = ctx_config.forced_colors.clone().into();
391 opts.locale = ctx_config.locale.clone();
392 opts.timezone_id = ctx_config.timezone_id.clone();
393 if let Some(ref geo) = ctx_config.geolocation {
394 opts.geolocation = Some(ferridriver::options::Geolocation {
395 latitude: geo.latitude,
396 longitude: geo.longitude,
397 accuracy: geo.accuracy.unwrap_or(0.0),
398 });
399 }
400 if ctx_config.offline {
401 opts.offline = Some(true);
402 }
403 if !ctx_config.permissions.is_empty() {
404 opts.permissions = Some(ctx_config.permissions.clone());
405 }
406 if !ctx_config.extra_http_headers.is_empty() {
407 opts.extra_http_headers = Some(
408 ctx_config
409 .extra_http_headers
410 .iter()
411 .map(|(k, v)| (k.clone(), v.clone()))
412 .collect(),
413 );
414 }
415 opts.user_agent = ctx_config.user_agent.clone();
416 if opts.base_url.is_none() {
423 opts.base_url = effective.request_base_url.clone();
424 }
425 if !ctx_config.java_script_enabled {
426 opts.java_script_enabled = Some(false);
427 }
428 if ctx_config.bypass_csp && !is_webkit {
429 opts.bypass_csp = Some(true);
430 }
431 if ctx_config.ignore_https_errors && !is_webkit {
432 opts.ignore_https_errors = Some(true);
433 }
434 if !ctx_config.accept_downloads && !is_webkit {
445 opts.accept_downloads = Some(false);
446 }
447 if ctx_config.accept_downloads && !is_webkit {
448 let _ = std::fs::create_dir_all(output_dir.join("downloads"));
449 }
450 if let Some(ref creds) = ctx_config.http_credentials {
451 opts.http_credentials = Some(ferridriver::options::HttpCredentials {
452 username: creds.username.clone(),
453 password: creds.password.clone(),
454 origin: None,
455 send: None,
456 });
457 }
458 if ctx_config.service_workers.as_deref() == Some("block") {
459 opts.service_workers = Some(ferridriver::options::ServiceWorkerPolicy::Block);
460 }
461
462 if let Some(ss_path) = ctx_config.storage_state.as_deref() {
467 let path = std::path::Path::new(ss_path);
468 match std::fs::read_to_string(path) {
469 Ok(json_str) => match serde_json::from_str::<serde_json::Value>(&json_str) {
470 Ok(state) => tracing::warn!(
471 target: "ferridriver::worker",
472 "storage state not yet wired through apply_context_options — skipping hydration from {}: {state:?}",
473 path.display()
474 ),
475 Err(e) => tracing::warn!(target: "ferridriver::worker", "parse storage state {}: {e}", path.display()),
476 },
477 Err(e) => tracing::warn!(target: "ferridriver::worker", "read storage state {}: {e}", path.display()),
478 }
479 }
480
481 page.apply_context_options(&opts).await
482}
483
484fn build_worker_browser_def(handle: Arc<crate::runner::BrowserHandle>) -> FixtureDef {
488 FixtureDef {
489 name: "browser".into(),
490 scope: FixtureScope::Worker,
491 dependencies: vec![],
492 setup: Arc::new(move |_pool| {
493 let handle = Arc::clone(&handle);
494 Box::pin(async move {
495 let browser = handle.get().await?;
496 Ok(browser as Arc<dyn std::any::Any + Send + Sync>)
497 })
498 }),
499 teardown: None,
500 timeout: Duration::from_secs(30),
501 auto: false,
502 }
503}
504
505fn build_browser_fixture_defs(
506 resources: Arc<TestBrowserResources>,
507 scope: FixtureScope,
508) -> FxHashMap<String, FixtureDef> {
509 let mut defs = FxHashMap::default();
510
511 defs.insert(
512 "context".into(),
513 FixtureDef {
514 name: "context".into(),
515 scope,
516 dependencies: vec![],
517 setup: Arc::new({
518 let resources = Arc::clone(&resources);
519 move |_pool| {
520 let resources = Arc::clone(&resources);
521 Box::pin(async move {
522 let ctx = resources.context().await?;
523 Ok(ctx as Arc<dyn std::any::Any + Send + Sync>)
524 })
525 }
526 }),
527 teardown: None,
528 timeout: Duration::from_secs(10),
529 auto: false,
530 },
531 );
532
533 defs.insert(
534 "page".into(),
535 FixtureDef {
536 name: "page".into(),
537 scope,
538 dependencies: vec![],
539 setup: Arc::new({
540 let resources = Arc::clone(&resources);
541 move |_pool| {
542 let resources = Arc::clone(&resources);
543 Box::pin(async move {
544 let page = resources.page().await?;
545 Ok(page as Arc<dyn std::any::Any + Send + Sync>)
546 })
547 }
548 }),
549 teardown: None,
550 timeout: Duration::from_secs(10),
551 auto: false,
552 },
553 );
554
555 defs
556}
557
558fn build_worker_request_def(base_url: Option<String>) -> FixtureDef {
570 FixtureDef {
571 name: "request".into(),
572 scope: FixtureScope::Worker,
573 dependencies: vec![],
574 setup: Arc::new(move |_pool| {
575 let base_url = base_url.clone();
576 Box::pin(async move {
577 Ok(Arc::new(ferridriver::http_client::HttpClient::new(
578 ferridriver::http_client::HttpClientOptions {
579 base_url,
580 ..Default::default()
581 },
582 )) as Arc<dyn std::any::Any + Send + Sync>)
583 })
584 }),
585 teardown: None,
586 timeout: Duration::from_secs(10),
587 auto: false,
588 }
589}
590
591fn build_test_fixture_defs(resources: Arc<TestBrowserResources>) -> FxHashMap<String, FixtureDef> {
592 build_browser_fixture_defs(resources, FixtureScope::Test)
593}
594
595fn build_suite_fixture_defs(resources: Arc<TestBrowserResources>) -> FxHashMap<String, FixtureDef> {
596 build_browser_fixture_defs(resources, FixtureScope::Worker)
597}
598
599pub struct WorkerTestResult {
601 pub outcome: TestOutcome,
602 pub should_retry: bool,
603 pub test_fn: crate::model::TestFn,
604 pub test_id: crate::model::TestId,
605 pub fixture_requests: Vec<String>,
606 pub suite_key: String,
607 pub hooks: Arc<Hooks>,
608}
609
610struct SuiteState {
612 before_all_ran: bool,
613 before_all_failed: bool,
614 hooks: Arc<Hooks>,
615 fixture_pool: FixturePool,
616}
617
618pub struct Worker {
620 pub id: u32,
621 config: Arc<TestConfig>,
622 event_bus: EventBus,
623}
624
625impl Worker {
626 pub fn new(id: u32, config: Arc<TestConfig>, event_bus: EventBus) -> Self {
627 Self { id, config, event_bus }
628 }
629
630 fn create_suite_test_info(&self, suite_key: &str) -> Arc<TestInfo> {
631 Arc::new(TestInfo {
632 test_id: crate::model::TestId {
633 file: suite_key.to_string(),
634 suite: None,
635 name: "suite hooks".to_string(),
636 line: None,
637 },
638 title_path: vec![suite_key.to_string(), "suite hooks".to_string()],
639 retry: 0,
640 worker_index: self.id,
641 parallel_index: self.id,
642 repeat_each_index: 0,
643 output_dir: self
644 .config
645 .output_dir
646 .join("__suite_hooks__")
647 .join(sanitize_filename(suite_key)),
648 snapshot_dir: self
649 .config
650 .snapshot_dir
651 .as_ref()
652 .map(std::path::PathBuf::from)
653 .unwrap_or_else(|| std::path::PathBuf::from("__snapshots__")),
654 snapshot_path_template: self.config.snapshot_path_template.clone(),
655 update_snapshots: self.config.update_snapshots,
656 ignore_snapshots: self.config.ignore_snapshots,
657 attachments: Arc::new(Mutex::new(Vec::new())),
658 steps: Arc::new(Mutex::new(Vec::new())),
659 soft_errors: Arc::new(Mutex::new(Vec::new())),
660 errors: Arc::new(Mutex::new(Vec::new())),
661 snapshot_suffix: Arc::new(Mutex::new(String::new())),
662 column: None,
663 project: None,
664 config_snapshot: Some(Arc::clone(&self.config)),
665 timeout: Duration::from_millis(self.config.timeout),
666 tags: Vec::new(),
667 start_time: Instant::now(),
668 event_bus: Some(self.event_bus.clone()),
669 annotations: Arc::new(Mutex::new(Vec::new())),
670 })
671 }
672
673 #[tracing::instrument(skip_all, fields(worker_id = self.id))]
674 pub async fn run(
675 &self,
676 browser_handle: Arc<crate::runner::BrowserHandle>,
677 custom_fixture_pool: FixturePool,
678 rx: async_channel::Receiver<WorkItem>,
679 result_tx: mpsc::Sender<WorkerTestResult>,
680 stop_flag: Arc<std::sync::atomic::AtomicBool>,
681 ) {
682 self
683 .event_bus
684 .emit(ReporterEvent::WorkerStarted { worker_id: self.id })
685 .await;
686
687 let mut worker_defs: FxHashMap<String, FixtureDef> = FxHashMap::default();
694 worker_defs.insert("browser".into(), build_worker_browser_def(Arc::clone(&browser_handle)));
695 worker_defs.insert("request".into(), build_worker_request_def(self.config.base_url.clone()));
696 let custom_fixture_pool = custom_fixture_pool.child_with_defs(worker_defs, FixtureScope::Worker);
697
698 {
705 let handle = Arc::clone(&browser_handle);
706 tokio::spawn(async move {
707 let _ = handle.get().await;
708 });
709 }
710
711 let mut active_suites: FxHashMap<String, SuiteState> = FxHashMap::default();
712
713 while let Ok(item) = rx.recv().await {
714 if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
717 break;
718 }
719 match item {
720 WorkItem::Single(assignment) => {
721 let result =
722 Box::pin(self.run_single(&browser_handle, &custom_fixture_pool, &mut active_suites, assignment)).await;
723 if result_tx.send(result).await.is_err() {
724 break;
725 }
726 },
727 WorkItem::Serial(batch) => {
728 let results =
729 Box::pin(self.run_serial_batch(&browser_handle, &custom_fixture_pool, &mut active_suites, batch)).await;
730 for result in results {
731 if result_tx.send(result).await.is_err() {
732 break;
733 }
734 }
735 },
736 }
737 tokio::task::yield_now().await;
741 }
742
743 for (suite_key, state) in &active_suites {
745 if state.before_all_ran {
746 for (i, hook) in state.hooks.after_all.iter().enumerate() {
747 let step_title = if state.hooks.after_all.len() == 1 {
748 "afterAll".to_string()
749 } else {
750 format!("afterAll [{i}]")
751 };
752 let step_id = format!("hook:afterAll:{suite_key}:{i}");
754 let synthetic_id = crate::model::TestId {
756 file: suite_key.clone(),
757 suite: None,
758 name: step_title.clone(),
759 line: None,
760 };
761 self
762 .event_bus
763 .emit(ReporterEvent::StepStarted(Box::new(
764 crate::reporter::StepStartedEvent {
765 test_id: synthetic_id.clone(),
766 step_id: step_id.clone(),
767 parent_step_id: None,
768 title: step_title.clone(),
769 category: StepCategory::Hook,
770 },
771 )))
772 .await;
773 let start = Instant::now();
774 let result = hook(state.fixture_pool.clone()).await;
775 let duration = start.elapsed();
776 let error = result.as_ref().err().map(|e| format!("{e}"));
777 self
778 .event_bus
779 .emit(ReporterEvent::StepFinished(Box::new(
780 crate::reporter::StepFinishedEvent {
781 test_id: synthetic_id,
782 step_id,
783 title: step_title,
784 category: StepCategory::Hook,
785 duration,
786 error: error.clone(),
787 metadata: None,
788 },
789 )))
790 .await;
791 if let Err(e) = result {
792 tracing::warn!(target: "ferridriver::worker", "afterAll error: {e}");
793 }
794 }
795 }
796 }
797
798 for state in active_suites.values() {
799 state.fixture_pool.teardown_all().await;
800 }
801 custom_fixture_pool.teardown_all().await;
802
803 browser_handle.close().await;
808
809 self
810 .event_bus
811 .emit(ReporterEvent::WorkerFinished { worker_id: self.id })
812 .await;
813 }
814
815 async fn run_serial_batch(
817 &self,
818 browser: &Arc<crate::runner::BrowserHandle>,
819 custom_pool: &FixturePool,
820 active_suites: &mut FxHashMap<String, SuiteState>,
821 batch: SerialBatch,
822 ) -> Vec<WorkerTestResult> {
823 let mut results = Vec::with_capacity(batch.assignments.len());
824 let mut serial_failed = false;
825
826 for assignment in batch.assignments {
827 if serial_failed {
828 let test = &assignment.test;
830 let outcome = TestOutcome {
831 test_id: test.id.clone(),
832 status: TestStatus::Skipped,
833 duration: Duration::ZERO,
834 attempt: assignment.attempt,
835 max_attempts: test.retries.unwrap_or(self.config.retries) + 1,
836 error: Some(TestFailure {
837 message: "skipped due to previous failure in serial suite".into(),
838 stack: None,
839 diff: None,
840 screenshot: None,
841 }),
842 attachments: Vec::new(),
843 steps: Vec::new(),
844 stdout: String::new(),
845 stderr: String::new(),
846 annotations: test.annotations.clone(),
847 metadata: self.config.metadata.clone(),
848 };
849 self
850 .event_bus
851 .emit(ReporterEvent::TestFinished {
852 test_id: test.id.clone(),
853 outcome: outcome.clone(),
854 })
855 .await;
856 results.push(WorkerTestResult {
857 outcome,
858 should_retry: false,
859 test_fn: Arc::clone(&test.test_fn),
860 test_id: test.id.clone(),
861 fixture_requests: test.fixture_requests.clone(),
862 suite_key: assignment.suite_key,
863 hooks: assignment.hooks,
864 });
865 continue;
866 }
867
868 let result = Box::pin(self.run_single(browser, custom_pool, active_suites, assignment)).await;
869 if result.outcome.status == TestStatus::Failed || result.outcome.status == TestStatus::TimedOut {
870 serial_failed = true;
871 }
872 results.push(result);
873 }
874
875 results
876 }
877
878 #[tracing::instrument(skip_all, fields(worker_id = self.id, test, attempt = assignment.attempt))]
880 async fn run_single(
881 &self,
882 browser: &Arc<crate::runner::BrowserHandle>,
883 custom_pool: &FixturePool,
884 active_suites: &mut FxHashMap<String, SuiteState>,
885 assignment: TestAssignment,
886 ) -> WorkerTestResult {
887 let test = &assignment.test;
888 let test_id = test.id.clone();
889 tracing::Span::current().record("test", test_id.full_name().as_str());
890 let test_fn = Arc::clone(&test.test_fn);
891 let fixture_requests = test.fixture_requests.clone();
892 let attempt = assignment.attempt;
893 let max_retries = test.retries.unwrap_or(self.config.retries);
894 let max_attempts = max_retries + 1;
895 let suite_key = assignment.suite_key.clone();
896
897 tracing::debug!(
898 target: "ferridriver::worker",
899 worker = self.id,
900 test = test_id.full_name(),
901 attempt,
902 max_attempts,
903 "dispatching test",
904 );
905 let hooks = Arc::clone(&assignment.hooks);
906
907 let suite_state = active_suites.entry(suite_key.clone()).or_insert_with(|| {
909 let suite_test_info = self.create_suite_test_info(&suite_key);
910 let suite_resources = Arc::new(TestBrowserResources::new(
911 Arc::clone(browser),
912 build_suite_effective_context_config(&self.config),
913 suite_test_info.output_dir.clone(),
914 ));
915 let suite_pool = custom_pool.child_with_defs(build_suite_fixture_defs(suite_resources), FixtureScope::Worker);
916 suite_pool.inject("test_info", suite_test_info);
917
918 SuiteState {
919 before_all_ran: false,
920 before_all_failed: false,
921 hooks: Arc::clone(&hooks),
922 fixture_pool: suite_pool,
923 }
924 });
925
926 for name in suite_state.fixture_pool.auto_fixture_names_for(FixtureScope::Worker) {
928 if let Err(e) = suite_state.fixture_pool.resolve(&name).await {
929 tracing::warn!(target: "ferridriver::worker", "auto fixture '{name}' (suite) failed: {e}");
930 }
931 }
932
933 if !suite_state.before_all_ran && !hooks.before_all.is_empty() {
934 for (i, hook) in hooks.before_all.iter().enumerate() {
935 let step_title = if hooks.before_all.len() == 1 {
936 "beforeAll".to_string()
937 } else {
938 format!("beforeAll [{i}]")
939 };
940 self
941 .event_bus
942 .emit(ReporterEvent::StepStarted(Box::new(
943 crate::reporter::StepStartedEvent {
944 test_id: test_id.clone(),
945 step_id: format!("hook:beforeAll:{suite_key}:{i}"),
946 parent_step_id: None,
947 title: step_title.clone(),
948 category: StepCategory::Hook,
949 },
950 )))
951 .await;
952 let start = Instant::now();
953 let result = hook(suite_state.fixture_pool.clone()).await;
954 let duration = start.elapsed();
955 let error = result.as_ref().err().map(|e| e.message.clone());
956 self
957 .event_bus
958 .emit(ReporterEvent::StepFinished(Box::new(
959 crate::reporter::StepFinishedEvent {
960 test_id: test_id.clone(),
961 step_id: format!("hook:beforeAll:{suite_key}:{i}"),
962 title: step_title,
963 category: StepCategory::Hook,
964 duration,
965 error: error.clone(),
966 metadata: None,
967 },
968 )))
969 .await;
970 if let Err(e) = result {
971 tracing::error!(target: "ferridriver::worker", "beforeAll failed for {suite_key}: {e}");
972 suite_state.before_all_failed = true;
973 break;
974 }
975 }
976 suite_state.before_all_ran = true;
977 }
978
979 if suite_state.before_all_failed {
981 let outcome = TestOutcome {
982 test_id: test_id.clone(),
983 status: TestStatus::Skipped,
984 duration: Duration::ZERO,
985 attempt,
986 max_attempts,
987 error: Some(TestFailure {
988 message: format!("skipped: beforeAll failed for suite '{suite_key}'"),
989 stack: None,
990 diff: None,
991 screenshot: None,
992 }),
993 attachments: Vec::new(),
994 steps: Vec::new(),
995 stdout: String::new(),
996 stderr: String::new(),
997 annotations: test.annotations.clone(),
998 metadata: self.config.metadata.clone(),
999 };
1000 self
1001 .event_bus
1002 .emit(ReporterEvent::TestFinished {
1003 test_id: test_id.clone(),
1004 outcome: outcome.clone(),
1005 })
1006 .await;
1007 return WorkerTestResult {
1008 outcome,
1009 should_retry: false,
1010 test_fn,
1011 test_id,
1012 fixture_requests,
1013 suite_key,
1014 hooks,
1015 };
1016 }
1017
1018 let browser_config = &self.config.browser;
1020 let should_skip = test.annotations.iter().any(|a| match a {
1021 TestAnnotation::Skip { condition: None, .. } => true,
1022 TestAnnotation::Skip {
1023 condition: Some(cond), ..
1024 } => evaluate_condition(cond, browser_config),
1025 TestAnnotation::Fixme { condition: None, .. } => true,
1026 TestAnnotation::Fixme {
1027 condition: Some(cond), ..
1028 } => evaluate_condition(cond, browser_config),
1029 _ => false,
1030 });
1031 if should_skip {
1032 let outcome = TestOutcome {
1033 test_id: test_id.clone(),
1034 status: TestStatus::Skipped,
1035 duration: Duration::ZERO,
1036 attempt,
1037 max_attempts,
1038 error: None,
1039 attachments: Vec::new(),
1040 steps: Vec::new(),
1041 stdout: String::new(),
1042 stderr: String::new(),
1043 annotations: test.annotations.clone(),
1044 metadata: self.config.metadata.clone(),
1045 };
1046 self
1047 .event_bus
1048 .emit(ReporterEvent::TestFinished {
1049 test_id: test_id.clone(),
1050 outcome: outcome.clone(),
1051 })
1052 .await;
1053 return WorkerTestResult {
1054 outcome,
1055 should_retry: false,
1056 test_fn,
1057 test_id,
1058 fixture_requests,
1059 suite_key,
1060 hooks,
1061 };
1062 }
1063
1064 self
1065 .event_bus
1066 .emit(ReporterEvent::TestStarted {
1067 test_id: test_id.clone(),
1068 attempt,
1069 })
1070 .await;
1071
1072 let mut expected_status = test.expected_status.clone();
1074 for ann in &test.annotations {
1075 if let TestAnnotation::Fail { condition, .. } = ann {
1076 let applies = match condition {
1077 None => true,
1078 Some(cond) => evaluate_condition(cond, browser_config),
1079 };
1080 if applies {
1081 expected_status = ExpectedStatus::Fail;
1082 }
1083 }
1084 }
1085
1086 let mut timeout_dur = test.timeout.unwrap_or(Duration::from_millis(self.config.timeout));
1088 let is_slow = test.annotations.iter().any(|a| match a {
1089 TestAnnotation::Slow { condition: None, .. } => true,
1090 TestAnnotation::Slow {
1091 condition: Some(cond), ..
1092 } => evaluate_condition(cond, browser_config),
1093 _ => false,
1094 });
1095 if is_slow {
1096 timeout_dur *= 3;
1097 }
1098
1099 let start = Instant::now();
1100 let effective_config = build_effective_context_config(&self.config, test);
1101
1102 let test_info = Arc::new(TestInfo {
1104 test_id: test_id.clone(),
1105 title_path: {
1106 let mut path = Vec::new();
1107 path.push(test_id.file.clone());
1108 if let Some(ref s) = test_id.suite {
1109 path.push(s.clone());
1110 }
1111 path.push(test_id.name.clone());
1112 path
1113 },
1114 retry: attempt.saturating_sub(1),
1115 worker_index: self.id,
1116 parallel_index: self.id,
1117 repeat_each_index: 0,
1118 output_dir: self.config.output_dir.join(test_id.full_name()),
1119 snapshot_dir: self
1120 .config
1121 .snapshot_dir
1122 .as_ref()
1123 .map(std::path::PathBuf::from)
1124 .unwrap_or_else(|| std::path::PathBuf::from("__snapshots__")),
1125 snapshot_path_template: self.config.snapshot_path_template.clone(),
1126 update_snapshots: self.config.update_snapshots,
1127 ignore_snapshots: self.config.ignore_snapshots,
1128 attachments: Arc::new(Mutex::new(Vec::new())),
1129 steps: Arc::new(Mutex::new(Vec::new())),
1130 soft_errors: Arc::new(Mutex::new(Vec::new())),
1131 errors: Arc::new(Mutex::new(Vec::new())),
1132 snapshot_suffix: Arc::new(Mutex::new(String::new())),
1133 column: None,
1134 project: None,
1135 config_snapshot: Some(Arc::clone(&self.config)),
1136 timeout: timeout_dur,
1137 tags: test
1138 .annotations
1139 .iter()
1140 .filter_map(|a| match a {
1141 TestAnnotation::Tag(t) => Some(t.clone()),
1142 _ => None,
1143 })
1144 .collect(),
1145 start_time: start,
1146 event_bus: Some(self.event_bus.clone()),
1147 annotations: Arc::new(Mutex::new(Vec::new())),
1148 });
1149 let resources = Arc::new(TestBrowserResources::new(
1150 Arc::clone(browser),
1151 effective_config,
1152 test_info.output_dir.clone(),
1153 ));
1154 let test_pool = custom_pool.child_with_defs(build_test_fixture_defs(Arc::clone(&resources)), FixtureScope::Test);
1155 test_pool.inject("test_info", Arc::clone(&test_info));
1156
1157 for name in test_pool.auto_fixture_names_for(FixtureScope::Test) {
1161 if let Err(e) = test_pool.resolve(&name).await {
1162 tracing::warn!(target: "ferridriver::worker", "auto fixture '{name}' failed: {e}");
1163 }
1164 }
1165
1166 enum VideoHandle {
1167 Eager(ferridriver::video::VideoRecordingHandle),
1168 Buffered(ferridriver::video::BufferedRecordingHandle),
1169 }
1170
1171 let mut page_for_artifacts = None;
1172 let video_handle: Option<VideoHandle> = match self.config.video.mode {
1173 crate::config::VideoMode::Off => None,
1174 crate::config::VideoMode::On | crate::config::VideoMode::RetainOnFailure => {
1175 match test_pool.get::<ferridriver::Page>("page").await {
1176 Ok(page) => {
1177 page_for_artifacts = Some(Arc::clone(&page));
1178 let _ = std::fs::create_dir_all(&test_info.output_dir);
1179 match self.config.video.mode {
1180 crate::config::VideoMode::On => {
1181 let ext = ferridriver::video::video_extension();
1182 let video_path =
1183 test_info
1184 .output_dir
1185 .join(format!("{}-attempt{}.{ext}", sanitize_filename(&test_id.name), attempt));
1186 match ferridriver::video::start_recording(
1187 &page,
1188 video_path,
1189 self.config.video.width,
1190 self.config.video.height,
1191 80,
1192 )
1193 .await
1194 {
1195 Ok(h) => Some(VideoHandle::Eager(h)),
1196 Err(e) => {
1197 tracing::warn!(target: "ferridriver::worker", "video start failed: {e}");
1198 None
1199 },
1200 }
1201 },
1202 crate::config::VideoMode::RetainOnFailure => {
1203 match ferridriver::video::start_buffered_recording(
1204 &page,
1205 self.config.video.width,
1206 self.config.video.height,
1207 80,
1208 )
1209 .await
1210 {
1211 Ok(h) => Some(VideoHandle::Buffered(h)),
1212 Err(e) => {
1213 tracing::warn!(target: "ferridriver::worker", "video start failed: {e}");
1214 None
1215 },
1216 }
1217 },
1218 crate::config::VideoMode::Off => None,
1219 }
1220 },
1221 Err(e) => {
1222 let () = resources.close().await;
1223 let duration = start.elapsed();
1224 let outcome = TestOutcome {
1225 test_id: test_id.clone(),
1226 status: TestStatus::Failed,
1227 duration,
1228 attempt,
1229 max_attempts,
1230 error: Some(TestFailure::wrap("failed to create page", e)),
1231 attachments: Vec::new(),
1232 steps: Vec::new(),
1233 stdout: String::new(),
1234 stderr: String::new(),
1235 annotations: test.annotations.clone(),
1236 metadata: self.config.metadata.clone(),
1237 };
1238 self
1239 .event_bus
1240 .emit(ReporterEvent::TestFinished {
1241 test_id: test_id.clone(),
1242 outcome: outcome.clone(),
1243 })
1244 .await;
1245 return WorkerTestResult {
1246 outcome,
1247 should_retry: attempt <= max_retries,
1248 test_fn,
1249 test_id,
1250 fixture_requests,
1251 suite_key,
1252 hooks,
1253 };
1254 },
1255 }
1256 },
1257 };
1258
1259 let mut before_each_err = None;
1260 for (i, hook) in hooks.before_each.iter().enumerate() {
1261 let title = if hooks.before_each.len() == 1 {
1262 "beforeEach".to_string()
1263 } else {
1264 format!("beforeEach [{i}]")
1265 };
1266 let step_handle = test_info.begin_step(&title, StepCategory::Hook).await;
1267 let result = hook(test_pool.clone(), Arc::clone(&test_info)).await;
1268 let err_msg = result.as_ref().err().map(|e| e.message.clone());
1269 step_handle.end(err_msg).await;
1270 if let Err(e) = result {
1271 before_each_err = Some(e);
1272 break;
1273 }
1274 }
1275
1276 let timeout_result = if let Some(err) = before_each_err {
1277 Ok(Err(err))
1278 } else {
1279 tokio::time::timeout(timeout_dur, (test.test_fn)(test_pool.clone())).await
1280 };
1281
1282 for (i, hook) in hooks.after_each.iter().enumerate() {
1283 let title = if hooks.after_each.len() == 1 {
1284 "afterEach".to_string()
1285 } else {
1286 format!("afterEach [{i}]")
1287 };
1288 let step_handle = test_info.begin_step(&title, StepCategory::Hook).await;
1289 let result = hook(test_pool.clone(), Arc::clone(&test_info)).await;
1290 let err_msg = result.as_ref().err().map(|e| e.message.clone());
1291 step_handle.end(err_msg).await;
1292 if let Err(e) = result {
1293 tracing::warn!(target: "ferridriver::worker", "afterEach error: {e}");
1294 }
1295 }
1296
1297 if page_for_artifacts.is_none() {
1298 page_for_artifacts = test_pool.try_get_cached::<ferridriver::Page>("page");
1299 }
1300 let test_failed = timeout_result.as_ref().is_err() || timeout_result.as_ref().is_ok_and(|r| r.is_err());
1301 let screenshot = if test_failed {
1302 if let Some(ref page) = page_for_artifacts {
1303 capture_screenshot(page).await
1304 } else {
1305 None
1306 }
1307 } else {
1308 None
1309 };
1310 let video_path = match (video_handle, page_for_artifacts.as_ref()) {
1311 (Some(VideoHandle::Eager(handle)), Some(page)) => match handle.stop(page).await {
1312 Ok(path) => Some(path),
1313 Err(e) => {
1314 tracing::warn!(target: "ferridriver::worker", "video stop failed: {e}");
1315 None
1316 },
1317 },
1318 (Some(VideoHandle::Buffered(handle)), Some(page)) => {
1319 if test_failed {
1320 let ext = ferridriver::video::video_extension();
1321 let video_path =
1322 test_info
1323 .output_dir
1324 .join(format!("{}-attempt{}.{ext}", sanitize_filename(&test_id.name), attempt));
1325 let _ = std::fs::create_dir_all(&test_info.output_dir);
1326 match handle.encode(page, video_path).await {
1327 Ok(path) => Some(path),
1328 Err(e) => {
1329 tracing::warn!(target: "ferridriver::worker", "video encode failed: {e}");
1330 None
1331 },
1332 }
1333 } else {
1334 handle.discard(page).await;
1335 None
1336 }
1337 },
1338 _ => None,
1339 };
1340 resources.close().await;
1341
1342 let duration = start.elapsed();
1343 let result = (timeout_result, screenshot, video_path, Some(test_pool));
1344 let (timeout_result, screenshot, video_path, test_pool) = result;
1345
1346 let mut attachments = Vec::new();
1347 if let Some(ref png) = screenshot {
1348 attachments.push(Attachment {
1349 name: "screenshot-on-failure".into(),
1350 content_type: "image/png".into(),
1351 body: AttachmentBody::Bytes(png.clone()),
1352 });
1353 }
1354
1355 let (raw_status, raw_error) = match timeout_result {
1356 Ok(Ok(())) => (TestStatus::Passed, None),
1357 Ok(Err(failure)) => {
1358 if failure.message.contains("__FERRIDRIVER_SKIP__:") {
1361 let reason = failure.message.split("__FERRIDRIVER_SKIP__:").nth(1).unwrap_or("");
1362 tracing::debug!(target: "ferridriver::worker", "test skipped at runtime: {reason}");
1363 let outcome = TestOutcome {
1364 test_id: test_id.clone(),
1365 status: TestStatus::Skipped,
1366 duration: start.elapsed(),
1367 attempt,
1368 max_attempts,
1369 error: None,
1370 attachments: Vec::new(),
1371 steps: Vec::new(),
1372 stdout: String::new(),
1373 stderr: String::new(),
1374 annotations: test.annotations.clone(),
1375 metadata: self.config.metadata.clone(),
1376 };
1377 self
1378 .event_bus
1379 .emit(ReporterEvent::TestFinished {
1380 test_id: test_id.clone(),
1381 outcome: outcome.clone(),
1382 })
1383 .await;
1384 return WorkerTestResult {
1385 outcome,
1386 should_retry: false,
1387 test_fn,
1388 test_id,
1389 fixture_requests,
1390 suite_key,
1391 hooks,
1392 };
1393 }
1394
1395 let mut failure = failure;
1396 if failure.screenshot.is_none() {
1397 failure.screenshot = screenshot;
1398 }
1399 (TestStatus::Failed, Some(failure))
1400 },
1401 Err(_) => (
1402 TestStatus::TimedOut,
1403 Some(TestFailure {
1404 message: format!("test timed out after {timeout_dur:?}"),
1405 stack: None,
1406 diff: None,
1407 screenshot,
1408 }),
1409 ),
1410 };
1411
1412 if let Some(ref pool) = test_pool {
1415 if let Ok(modifiers) = pool.get::<crate::TestModifiers>("__test_modifiers").await {
1416 if modifiers.expected_failure.load(std::sync::atomic::Ordering::Relaxed) {
1417 expected_status = ExpectedStatus::Fail;
1418 }
1419 if modifiers.slow.load(std::sync::atomic::Ordering::Relaxed) {
1421 test_info.annotate("slow", "test.slow() called at runtime").await;
1422 }
1423 if let Ok(guard) = modifiers.timeout_override.lock() {
1425 if let Some(ms) = *guard {
1426 tracing::debug!(target: "ferridriver::worker", "test.setTimeout({ms}ms) called at runtime");
1427 }
1428 }
1429 }
1430 }
1431
1432 let (status, error) = match (&raw_status, &expected_status) {
1434 (TestStatus::Failed | TestStatus::TimedOut, ExpectedStatus::Fail) => (TestStatus::Passed, None),
1435 (TestStatus::Passed, ExpectedStatus::Fail) => (
1436 TestStatus::Failed,
1437 Some(TestFailure {
1438 message: "expected test to fail, but it passed".into(),
1439 stack: None,
1440 diff: None,
1441 screenshot: None,
1442 }),
1443 ),
1444 _ => (raw_status, raw_error),
1445 };
1446
1447 let soft_errs = test_info.drain_soft_errors().await;
1449 let (status, error) = if !soft_errs.is_empty() && status == TestStatus::Passed {
1450 let msg = soft_errs
1451 .iter()
1452 .map(|e| format!(" - {}", e.message))
1453 .collect::<Vec<_>>()
1454 .join("\n");
1455 (
1456 TestStatus::Failed,
1457 Some(TestFailure {
1458 message: format!("{} soft assertion(s) failed:\n{msg}", soft_errs.len()),
1459 stack: None,
1460 diff: None,
1461 screenshot: None,
1462 }),
1463 )
1464 } else {
1465 (status, error)
1466 };
1467
1468 let steps = test_info.steps.lock().await.clone();
1470 let info_attachments = test_info.attachments.lock().await.clone();
1471 attachments.extend(info_attachments);
1472
1473 let trace_mode = self.config.trace;
1478 let test_failed = status == TestStatus::Failed || status == TestStatus::TimedOut;
1479 if trace_mode.should_write(attempt, test_failed) {
1480 let mut recorder = crate::tracing::TraceRecorder::for_steps(&steps);
1481 recorder.record_steps(&steps);
1482 match recorder.into_zip_bytes() {
1484 Ok(zip_bytes) => {
1485 let trace_path = test_info.output_dir.join(format!(
1486 "{}-attempt{}.trace.zip",
1487 sanitize_filename(&test_id.name),
1488 attempt
1489 ));
1490 let write_path = trace_path.clone();
1492 let write_result =
1493 tokio::task::spawn_blocking(move || crate::tracing::write_trace_file(&write_path, &zip_bytes)).await;
1494 match write_result {
1495 Ok(Ok(())) => {
1496 attachments.push(Attachment {
1497 name: "trace".into(),
1498 content_type: "application/zip".into(),
1499 body: AttachmentBody::Path(trace_path),
1500 });
1501 },
1502 Ok(Err(e)) => tracing::warn!(target: "ferridriver::worker", "trace write failed: {e}"),
1503 Err(e) => tracing::warn!(target: "ferridriver::worker", "trace task panicked: {e}"),
1504 }
1505 },
1506 Err(e) => tracing::warn!(target: "ferridriver::worker", "trace serialize failed: {e}"),
1507 }
1508 }
1509
1510 if let Some(ref path) = video_path {
1514 let keep = match self.config.video.mode {
1515 crate::config::VideoMode::On => true,
1516 crate::config::VideoMode::RetainOnFailure => true, crate::config::VideoMode::Off => false,
1518 };
1519 if keep && path.exists() {
1520 attachments.push(Attachment {
1521 name: "video".into(),
1522 content_type: ferridriver::video::video_content_type().into(),
1523 body: AttachmentBody::Path(path.clone()),
1524 });
1525 } else {
1526 let _ = std::fs::remove_file(path);
1527 }
1528 }
1529
1530 let mut annotations = test.annotations.clone();
1532 annotations.extend(test_info.get_annotations().await);
1533
1534 let outcome = TestOutcome {
1535 test_id: test_id.clone(),
1536 status,
1537 duration,
1538 attempt,
1539 max_attempts,
1540 error,
1541 attachments,
1542 steps,
1543 stdout: String::new(),
1544 stderr: String::new(),
1545 annotations,
1546 metadata: self.config.metadata.clone(),
1547 };
1548
1549 self
1550 .event_bus
1551 .emit(ReporterEvent::TestFinished {
1552 test_id: test_id.clone(),
1553 outcome: outcome.clone(),
1554 })
1555 .await;
1556
1557 let should_retry =
1558 outcome.status != TestStatus::Passed && outcome.status != TestStatus::Skipped && attempt < max_attempts;
1559
1560 WorkerTestResult {
1561 outcome,
1562 should_retry,
1563 test_fn,
1564 test_id,
1565 fixture_requests,
1566 suite_key,
1567 hooks,
1568 }
1569 }
1570}
1571
1572fn sanitize_filename(name: &str) -> String {
1574 name
1575 .chars()
1576 .map(|c| {
1577 if c.is_alphanumeric() || c == '-' || c == '_' {
1578 c
1579 } else {
1580 '_'
1581 }
1582 })
1583 .collect()
1584}
1585
1586async fn capture_screenshot(page: &ferridriver::Page) -> Option<Vec<u8>> {
1587 let opts = ferridriver::options::ScreenshotOptions {
1588 full_page: Some(true),
1589 format: Some("png".into()),
1590 ..Default::default()
1591 };
1592 page.screenshot(opts).await.ok()
1593}
1594
1595fn evaluate_condition(condition: &str, browser: &crate::config::BrowserConfig) -> bool {
1634 let condition = condition.trim();
1635
1636 if let Some(inner) = condition.strip_prefix('!') {
1638 return !evaluate_condition(inner, browser);
1639 }
1640
1641 if condition.contains('+') {
1643 return condition.split('+').all(|part| evaluate_condition(part, browser));
1644 }
1645
1646 match condition {
1647 "linux" => cfg!(target_os = "linux"),
1649 "macos" | "darwin" => cfg!(target_os = "macos"),
1650 "windows" | "win32" => cfg!(target_os = "windows"),
1651
1652 "chromium" | "chrome" => browser.browser == "chromium",
1654 "webkit" => browser.browser == "webkit",
1655 "firefox" => browser.browser == "firefox",
1656
1657 "msedge" => browser.channel.as_deref() == Some("msedge"),
1659 "chrome-beta" => browser.channel.as_deref() == Some("chrome-beta"),
1660 "chrome-canary" => browser.channel.as_deref() == Some("chrome-canary"),
1661
1662 "headed" => !browser.headless,
1664 "headless" => browser.headless,
1665
1666 "mobile" => browser.use_options.is_mobile,
1668 "touch" => browser.use_options.has_touch,
1669 "dark" => browser.use_options.color_scheme.as_deref() == Some("dark"),
1670 "light" => browser.use_options.color_scheme.as_deref() == Some("light"),
1671 "offline" => browser.use_options.offline,
1672 "bypass-csp" => browser.use_options.bypass_csp,
1673
1674 "ci" => std::env::var("CI").is_ok(),
1676 "debug" => cfg!(debug_assertions),
1677
1678 other if other.starts_with("env:") => {
1681 let var_name = &other[4..];
1682 std::env::var(var_name).is_ok_and(|v| !v.is_empty())
1683 },
1684
1685 _ => false,
1687 }
1688}