1use std::sync::Arc;
5use std::time::Instant;
6
7use rustc_hash::FxHashMap;
8use tokio::sync::mpsc;
9
10use crate::config::{CliOverrides, ProjectConfig, TestConfig};
11use crate::dispatcher::Dispatcher;
12use crate::fixture::{FixturePool, FixtureScope, builtin_fixtures, validate_dag};
13use crate::model::{Hooks, TestHooks, TestPlan, TestStatus};
14use crate::reporter::{EventBus, EventBusBuilder, ReporterDriver, ReporterEvent, ReporterSet};
15use crate::shard;
16use crate::worker::{Worker, WorkerTestResult};
17
18use ferridriver::Browser;
19use ferridriver::backend::BackendKind;
20use ferridriver::options::{BrowserKind, LaunchPlan};
21use ferridriver::state::{BrowserState, ConnectMode};
22
23#[derive(Clone, Copy, Default)]
26pub struct ExecuteSummary {
27 pub exit_code: i32,
28 pub total: usize,
29 pub passed: usize,
30 pub failed: usize,
31 pub skipped: usize,
32 pub flaky: usize,
33}
34
35pub struct TestRunner {
37 config: Arc<TestConfig>,
38 hooks: TestHooks,
39 reporters: ReporterSet,
40 overrides: CliOverrides,
41 shared_browser: Option<Arc<Browser>>,
43 suppress_run_boundary: bool,
49}
50
51impl TestRunner {
52 pub fn new(config: TestConfig, overrides: CliOverrides) -> Self {
55 Self::with_hooks(config, TestHooks::default(), overrides)
56 }
57
58 pub fn with_hooks(config: TestConfig, hooks: TestHooks, overrides: CliOverrides) -> Self {
60 let reporters = crate::reporter::create_reporters(
61 &config.reporter,
62 &config.output_dir,
63 config.has_bdd,
64 config.quiet,
65 config.report_slow_tests.clone(),
66 );
67 Self {
68 config: Arc::new(config),
69 hooks,
70 reporters,
71 overrides,
72 shared_browser: None,
73 suppress_run_boundary: false,
74 }
75 }
76
77 pub fn add_reporter(&mut self, reporter: Box<dyn crate::reporter::Reporter>) {
79 self.reporters.add(reporter);
80 }
81
82 pub async fn run(&mut self, plan: TestPlan) -> i32 {
92 let global_timeout = self.config.global_timeout;
93 let inner = async move {
94 if !self.config.projects.is_empty() {
96 return Box::pin(self.run_projects(plan)).await;
97 }
98
99 let mut builder = EventBusBuilder::new();
101 let driver_handle = if self.reporters.is_empty() {
102 None
103 } else {
104 let reporter_sub = builder.subscribe();
105 let reporters = std::mem::take(&mut self.reporters);
106 let driver = ReporterDriver::new(reporters, reporter_sub);
107 Some(tokio::spawn(driver.run()))
108 };
109 let bus = builder.build();
110
111 let exit_code = self.execute(plan, bus.clone()).await;
112
113 bus.close();
117
118 if let Some(driver_handle) = driver_handle {
119 if let Ok(reporters) = driver_handle.await {
120 self.reporters = reporters;
121 }
122 }
123
124 exit_code
125 };
126
127 if global_timeout > 0 {
128 if let Ok(code) = tokio::time::timeout(std::time::Duration::from_millis(global_timeout), inner).await {
129 code
130 } else {
131 tracing::error!(
132 target: "ferridriver::runner",
133 global_timeout_ms = global_timeout,
134 "global timeout exceeded — aborting run",
135 );
136 eprintln!("Error: global timeout of {global_timeout}ms exceeded");
137 1
138 }
139 } else {
140 inner.await
141 }
142 }
143
144 async fn run_projects(&mut self, plan: TestPlan) -> i32 {
156 let projects = self.config.projects.clone();
157
158 let sorted = match topo_sort_projects(&projects) {
159 Ok(order) => order,
160 Err(e) => {
161 tracing::error!(target: "ferridriver::runner", "project dependency error: {e}");
162 return 1;
163 },
164 };
165
166 let allowed_indices: rustc_hash::FxHashSet<usize> = if self.overrides.project_filter.is_empty() {
171 (0..projects.len()).collect()
172 } else {
173 let mut wanted: rustc_hash::FxHashSet<usize> = rustc_hash::FxHashSet::default();
174 for name in &self.overrides.project_filter {
175 if let Some(idx) = projects.iter().position(|p| &p.name == name) {
176 wanted.insert(idx);
177 } else {
178 tracing::warn!(target: "ferridriver::runner", "--project {name}: no matching project");
179 }
180 }
181 if !self.overrides.no_deps {
183 let mut frontier: Vec<usize> = wanted.iter().copied().collect();
184 while let Some(idx) = frontier.pop() {
185 for dep_name in &projects[idx].dependencies {
186 if let Some(dep_idx) = projects.iter().position(|p| &p.name == dep_name) {
187 if wanted.insert(dep_idx) {
188 frontier.push(dep_idx);
189 }
190 }
191 }
192 }
193 }
194 let kept: Vec<usize> = wanted.iter().copied().collect();
196 for idx in kept {
197 if let Some(t) = &projects[idx].teardown {
198 if let Some(t_idx) = projects.iter().position(|p| &p.name == t) {
199 wanted.insert(t_idx);
200 }
201 }
202 }
203 wanted
204 };
205 let sorted: Vec<usize> = sorted.into_iter().filter(|idx| allowed_indices.contains(idx)).collect();
206
207 let cli_teardown_idx: Option<usize> = self
210 .overrides
211 .teardown
212 .as_deref()
213 .and_then(|name| projects.iter().position(|p| p.name == name));
214
215 tracing::info!(
216 target: "ferridriver::runner",
217 projects = sorted.len(),
218 order = ?sorted.iter().map(|i| &projects[*i].name).collect::<Vec<_>>(),
219 "running projects in dependency order",
220 );
221
222 let mut scheduled: Vec<usize> = sorted.clone();
227 if let Some(td_idx) = cli_teardown_idx {
228 if !scheduled.contains(&td_idx) {
229 scheduled.push(td_idx);
230 }
231 }
232
233 let teardown_parent: FxHashMap<usize, usize> = projects
245 .iter()
246 .enumerate()
247 .filter_map(|(parent_idx, p)| {
248 p.teardown
249 .as_deref()
250 .and_then(|name| projects.iter().position(|q| q.name == name))
251 .map(|td_idx| (td_idx, parent_idx))
252 })
253 .collect();
254
255 let prereqs: FxHashMap<usize, Vec<(usize, bool)>> = scheduled
257 .iter()
258 .map(|&idx| {
259 let mut reqs: Vec<(usize, bool)> = Vec::new();
260 for dep_name in &projects[idx].dependencies {
262 if let Some(dep_idx) = projects.iter().position(|p| &p.name == dep_name) {
263 if scheduled.contains(&dep_idx) {
264 reqs.push((dep_idx, true));
265 }
266 }
267 }
268 if let Some(&parent_idx) = teardown_parent.get(&idx) {
270 if scheduled.contains(&parent_idx) {
271 reqs.push((parent_idx, false));
272 }
273 }
274 if Some(idx) == cli_teardown_idx {
276 for &other in &scheduled {
277 if other != idx {
278 reqs.push((other, false));
279 }
280 }
281 }
282 (idx, reqs)
283 })
284 .collect();
285
286 let web_server_manager = if self.config.web_server.is_empty() {
292 None
293 } else {
294 match crate::server::WebServerManager::start(&self.config.web_server).await {
295 Ok(mgr) => {
296 if let Some(url) = mgr.first_url() {
297 if self.config.base_url.is_none() {
298 #[allow(unsafe_code)]
300 unsafe {
301 std::env::set_var("FERRIDRIVER_BASE_URL", &url)
302 };
303 tracing::info!(target: "ferridriver::runner", "webServer base_url={url}");
304 }
305 }
306 Some(mgr)
307 },
308 Err(e) => {
309 tracing::error!(target: "ferridriver::runner", "webServer start failed: {e}");
310 return 1;
311 },
312 }
313 };
314
315 let mut merged: FxHashMap<usize, Arc<TestConfig>> = FxHashMap::default();
318 let mut plans: FxHashMap<usize, TestPlan> = FxHashMap::default();
319 let mut total_tests = 0usize;
320 for &idx in &scheduled {
321 let mut mc = self.config.merge_project(&projects[idx]);
322 mc.web_server = Vec::new();
323 let mut p = plan.clone();
324 filter_plan_for_project(&mut p, &mc, &projects[idx]);
325 total_tests += p.total_tests;
326 merged.insert(idx, Arc::new(mc));
327 plans.insert(idx, p);
328 }
329
330 let mut builder = EventBusBuilder::new();
332 let driver_handle = if self.reporters.is_empty() {
333 None
334 } else {
335 let sub = builder.subscribe();
336 let reporters = std::mem::take(&mut self.reporters);
337 Some(tokio::spawn(ReporterDriver::new(reporters, sub).run()))
338 };
339 let bus = builder.build();
340 let reporting_enabled = bus.has_subscribers();
341
342 let num_workers = (self.config.workers as usize).min(total_tests.max(1)).max(1) as u32;
345 if reporting_enabled {
346 bus.emit(ReporterEvent::RunStarted {
347 total_tests,
348 num_workers,
349 metadata: self.config.metadata.clone(),
350 });
351 }
352 let run_start = Instant::now();
353
354 let cap = if self.config.max_parallel_projects == 0 {
361 scheduled.len().max(1)
362 } else {
363 self.config.max_parallel_projects as usize
364 };
365
366 let mut passed_projects: rustc_hash::FxHashSet<usize> = rustc_hash::FxHashSet::default();
367 let mut terminal: rustc_hash::FxHashSet<usize> = rustc_hash::FxHashSet::default();
368 let mut remaining: Vec<usize> = scheduled.clone();
369 let mut join_set: tokio::task::JoinSet<(usize, Option<ExecuteSummary>)> = tokio::task::JoinSet::new();
370 let mut in_flight = 0usize;
371
372 let mut exit_code = 0i32;
373 let mut agg = ExecuteSummary::default();
374
375 loop {
376 while in_flight < cap {
380 let next = remaining.iter().copied().find(|&idx| {
382 prereqs
383 .get(&idx)
384 .map(|rs| rs.iter().all(|(dep, _)| terminal.contains(dep)))
385 .unwrap_or(true)
386 });
387 let Some(idx) = next else { break };
388 remaining.retain(|&i| i != idx);
389
390 let blocked = prereqs
393 .get(&idx)
394 .map(|rs| {
395 rs.iter()
396 .any(|&(dep, must_pass)| must_pass && !passed_projects.contains(&dep))
397 })
398 .unwrap_or(false);
399 if blocked {
400 tracing::warn!(
401 target: "ferridriver::runner",
402 project = projects[idx].name,
403 "skipping — dependency failed",
404 );
405 terminal.insert(idx);
406 exit_code = 1;
407 continue;
408 }
409
410 let Some(project_plan) = plans.remove(&idx) else {
411 terminal.insert(idx);
412 passed_projects.insert(idx);
413 continue;
414 };
415 if project_plan.total_tests == 0 {
416 tracing::debug!(
417 target: "ferridriver::runner",
418 project = projects[idx].name,
419 "no tests matched, skipping",
420 );
421 terminal.insert(idx);
422 passed_projects.insert(idx);
423 continue;
424 }
425
426 tracing::info!(
427 target: "ferridriver::runner",
428 project = projects[idx].name,
429 tests = project_plan.total_tests,
430 "running project",
431 );
432
433 let sub_runner = TestRunner {
434 config: merged.get(&idx).cloned().unwrap_or_else(|| Arc::clone(&self.config)),
435 hooks: self.hooks.clone(),
436 reporters: ReporterSet::default(),
437 overrides: self.overrides.clone(),
438 shared_browser: self.shared_browser.clone(),
439 suppress_run_boundary: true,
440 };
441 let project_bus = bus.clone();
442 join_set.spawn(async move {
443 let summary = sub_runner.execute_with_summary(project_plan, project_bus).await;
444 (idx, Some(summary))
445 });
446 in_flight += 1;
447 }
448
449 if in_flight == 0 {
452 break;
453 }
454
455 if let Some(joined) = join_set.join_next().await {
457 in_flight -= 1;
458 match joined {
459 Ok((idx, Some(summary))) => {
460 terminal.insert(idx);
461 if summary.exit_code == 0 {
462 passed_projects.insert(idx);
463 } else {
464 exit_code = 1;
465 }
466 agg.passed += summary.passed;
467 agg.failed += summary.failed;
468 agg.skipped += summary.skipped;
469 agg.flaky += summary.flaky;
470 },
471 Ok((idx, None)) => {
472 terminal.insert(idx);
473 exit_code = 1;
474 },
475 Err(e) => {
476 tracing::error!(target: "ferridriver::runner", "project task panicked: {e}");
477 exit_code = 1;
478 },
479 }
480 }
481 }
482
483 if reporting_enabled {
485 bus.emit(ReporterEvent::RunFinished {
486 total: total_tests,
487 passed: agg.passed,
488 failed: agg.failed,
489 skipped: agg.skipped,
490 flaky: agg.flaky,
491 duration: run_start.elapsed(),
492 });
493 }
494 bus.close();
495 if let Some(driver_handle) = driver_handle {
496 if let Ok(reporters) = driver_handle.await {
497 self.reporters = reporters;
498 }
499 }
500
501 if let Some(mgr) = web_server_manager {
502 mgr.stop().await;
503 }
504
505 exit_code
506 }
507
508 pub async fn execute(&self, plan: TestPlan, event_bus: EventBus) -> i32 {
516 self.execute_with_summary(plan, event_bus).await.exit_code
517 }
518
519 #[tracing::instrument(skip_all, fields(workers = self.config.workers, tests = plan.total_tests))]
523 pub async fn execute_with_summary(&self, mut plan: TestPlan, event_bus: EventBus) -> ExecuteSummary {
524 if let Some(shard_arg) = &self.overrides.shard {
526 shard::filter_by_shard(
527 &mut plan,
528 &crate::model::ShardInfo {
529 current: shard_arg.current,
530 total: shard_arg.total,
531 },
532 );
533 }
534 let grep = self.overrides.grep.as_ref().or(self.config.config_grep.as_ref());
536 let grep_inv = self
537 .overrides
538 .grep_invert
539 .as_ref()
540 .or(self.config.config_grep_invert.as_ref());
541 if let Some(grep) = grep {
542 crate::discovery::filter_by_grep(&mut plan, grep, false);
543 }
544 if let Some(grep_inv) = grep_inv {
545 crate::discovery::filter_by_grep(&mut plan, grep_inv, true);
546 }
547 if let Some(tag) = &self.overrides.tag {
548 crate::discovery::filter_by_tag(&mut plan, tag);
549 }
550
551 if self.config.forbid_only || self.overrides.forbid_only {
553 if let Err(e) = crate::discovery::check_forbid_only(&plan) {
554 eprint!("{e}");
555 return ExecuteSummary {
556 exit_code: 1,
557 ..Default::default()
558 };
559 }
560 }
561
562 crate::discovery::filter_by_only(&mut plan);
564
565 if self.overrides.last_failed {
567 let rerun_path = self.config.output_dir.join("@rerun.txt");
568 crate::discovery::filter_by_rerun(&mut plan, &rerun_path);
569 }
570
571 if self.config.preserve_output == "never" {
573 let _ = std::fs::remove_dir_all(&self.config.output_dir);
574 }
575
576 let total_tests = plan.total_tests;
577 tracing::debug!(
578 target: "ferridriver::runner",
579 total_tests,
580 suites = plan.suites.len(),
581 "test plan after filtering",
582 );
583 if total_tests == 0 {
584 tracing::info!(target: "ferridriver::runner", "no tests found");
585 return ExecuteSummary::default();
586 }
587
588 if self.overrides.list_only {
589 for suite in &plan.suites {
590 for test in &suite.tests {
591 println!(" {}", test.id.full_name());
592 }
593 }
594 println!("\n {total_tests} test(s) found");
595 return ExecuteSummary {
596 total: total_tests,
597 ..Default::default()
598 };
599 }
600
601 let num_workers = (self.config.workers as usize).min(total_tests).max(1) as u32;
603
604 let custom_fixtures = crate::discovery::collect_rust_fixtures();
607
608 {
610 let mut fixture_defs = builtin_fixtures(&self.config.browser);
611 for (name, def) in &custom_fixtures {
612 fixture_defs.insert(name.clone(), def.clone());
613 }
614 if let Err(e) = validate_dag(&fixture_defs) {
615 tracing::error!(target: "ferridriver::fixture", "fixture DAG error: {e}");
616 return ExecuteSummary {
617 exit_code: 1,
618 total: total_tests,
619 failed: total_tests,
620 ..Default::default()
621 };
622 }
623 }
624
625 let web_server_manager = if !self.config.web_server.is_empty() {
628 match crate::server::WebServerManager::start(&self.config.web_server).await {
629 Ok(mgr) => {
630 if let Some(url) = mgr.first_url() {
631 if self.config.base_url.is_none() {
632 #[allow(unsafe_code)]
635 unsafe {
636 std::env::set_var("FERRIDRIVER_BASE_URL", &url)
637 };
638 tracing::info!(target: "ferridriver::runner", "webServer base_url={url}");
639 }
640 }
641 Some(mgr)
642 },
643 Err(e) => {
644 tracing::error!(target: "ferridriver::runner", "webServer start failed: {e}");
645 return ExecuteSummary {
646 exit_code: 1,
647 total: total_tests,
648 failed: total_tests,
649 ..Default::default()
650 };
651 },
652 }
653 } else {
654 None
655 };
656
657 let mut run_metadata = self.config.metadata.clone();
660 if self.config.capture_git_info {
661 let info = crate::git_info::GitInfo::capture();
662 let git_value = serde_json::to_value(&info).unwrap_or(serde_json::Value::Null);
663 match &mut run_metadata {
664 serde_json::Value::Object(map) => {
665 map.insert("git".into(), git_value);
666 },
667 other => {
668 *other = serde_json::json!({ "git": git_value });
669 },
670 }
671 }
672
673 let reporting_enabled = event_bus.has_subscribers();
674 let emit_boundary = reporting_enabled && !self.suppress_run_boundary;
679 if emit_boundary {
680 event_bus.emit(ReporterEvent::RunStarted {
681 total_tests,
682 num_workers,
683 metadata: run_metadata,
684 });
685 }
686
687 let start = Instant::now();
688
689 if !self.hooks.global_setup_fns.is_empty() {
691 let global_pool = FixturePool::new(FxHashMap::default(), FixtureScope::Global);
692 for setup_fn in &self.hooks.global_setup_fns {
693 if let Err(e) = setup_fn(global_pool.clone()).await {
694 tracing::error!(target: "ferridriver::runner", "global setup failed: {e}");
695 if emit_boundary {
696 event_bus.emit(ReporterEvent::RunFinished {
697 total: total_tests,
698 passed: 0,
699 failed: total_tests,
700 skipped: 0,
701 flaky: 0,
702 duration: start.elapsed(),
703 });
704 }
705 return ExecuteSummary {
706 exit_code: 1,
707 total: total_tests,
708 failed: total_tests,
709 ..Default::default()
710 };
711 }
712 }
713 }
714
715 let repeat_each = self.config.repeat_each.max(1);
717 let total_executions = total_tests * repeat_each as usize;
718
719 let dispatcher = Arc::new(Dispatcher::new());
721 for _rep in 0..repeat_each {
722 for suite in &plan.suites {
723 let suite_key = format!("{}::{}", suite.file, suite.name);
724 let hooks = Arc::new(Hooks {
725 before_all: suite.hooks.before_all.clone(),
726 after_all: suite.hooks.after_all.clone(),
727 before_each: suite.hooks.before_each.clone(),
728 after_each: suite.hooks.after_each.clone(),
729 });
730
731 match suite.mode {
732 crate::model::SuiteMode::Parallel => {
733 for test in &suite.tests {
734 let assignment = crate::dispatcher::TestAssignment {
735 test: crate::model::TestCase {
736 id: test.id.clone(),
737 test_fn: Arc::clone(&test.test_fn),
738 fixture_requests: test.fixture_requests.clone(),
739 annotations: test.annotations.clone(),
740 timeout: test.timeout,
741 retries: test.retries,
742 expected_status: test.expected_status.clone(),
743 use_options: test.use_options.clone(),
744 },
745 attempt: 1,
746 suite_key: suite_key.clone(),
747 hooks: Arc::clone(&hooks),
748 suite_mode: crate::model::SuiteMode::Parallel,
749 };
750 dispatcher.enqueue_single(assignment);
751 }
752 },
753 crate::model::SuiteMode::Serial => {
754 let assignments: Vec<_> = suite
755 .tests
756 .iter()
757 .map(|test| crate::dispatcher::TestAssignment {
758 test: crate::model::TestCase {
759 id: test.id.clone(),
760 test_fn: Arc::clone(&test.test_fn),
761 fixture_requests: test.fixture_requests.clone(),
762 annotations: test.annotations.clone(),
763 timeout: test.timeout,
764 retries: test.retries,
765 expected_status: test.expected_status.clone(),
766 use_options: test.use_options.clone(),
767 },
768 attempt: 1,
769 suite_key: suite_key.clone(),
770 hooks: Arc::clone(&hooks),
771 suite_mode: crate::model::SuiteMode::Serial,
772 })
773 .collect();
774 dispatcher.enqueue_serial(crate::dispatcher::SerialBatch {
775 suite_key: suite_key.clone(),
776 assignments,
777 hooks: Arc::clone(&hooks),
778 });
779 },
780 }
781 }
782 }
783
784 let (result_tx, mut result_rx) = mpsc::channel::<WorkerTestResult>(256);
790
791 let mut worker_handles = Vec::new();
792 let launch_plan = build_launch_plan(&self.config.browser);
793 let worker_event_bus = reporting_enabled.then(|| event_bus.clone());
794
795 for worker_id in 0..num_workers {
796 let worker = Worker::new(worker_id, Arc::clone(&self.config), worker_event_bus.clone());
797 let rx = dispatcher.receiver();
798 let tx = result_tx.clone();
799 let custom_pool = FixturePool::new(custom_fixtures.clone(), FixtureScope::Worker);
800 let shared = self.shared_browser.clone();
801 let plan = launch_plan.clone();
802 let stop_flag = dispatcher.stop_flag();
803
804 let handle = tokio::spawn(async move {
805 let browser_handle = if let Some(b) = shared {
806 Arc::new(BrowserHandle::from_shared(b))
807 } else {
808 Arc::new(BrowserHandle::new(plan))
809 };
810 Box::pin(worker.run(browser_handle, custom_pool, rx, tx, stop_flag)).await;
811 });
812 worker_handles.push(handle);
813 }
814 drop(result_tx);
815
816 let mut attempt_history: FxHashMap<String, Vec<TestStatus>> = FxHashMap::default();
818 let mut final_count = 0usize;
819 let mut failure_count = 0usize;
820 let max_failures = if self.config.fail_fast {
821 1 } else {
823 self.config.max_failures as usize };
825
826 while let Some(result) = result_rx.recv().await {
827 let test_key = result.outcome.test_id.full_name();
828 attempt_history
829 .entry(test_key)
830 .or_default()
831 .push(result.outcome.status.clone());
832
833 if result.should_retry {
834 tracing::debug!(
835 target: "ferridriver::runner",
836 test = result.test_id.full_name(),
837 attempt = result.outcome.attempt,
838 "retrying failed test",
839 );
840 dispatcher.retry_shared(
841 &result.test_fn,
842 &result.test_id,
843 result.fixture_requests.clone(),
844 result.outcome.attempt + 1,
845 result.suite_key.clone(),
846 Arc::clone(&result.hooks),
847 );
848 } else {
849 final_count += 1;
850 if matches!(result.outcome.status, TestStatus::Failed | TestStatus::TimedOut) {
852 failure_count += 1;
853 }
854 }
855
856 if max_failures > 0 && failure_count >= max_failures {
860 tracing::info!(
861 target: "ferridriver::runner",
862 failure_count,
863 max_failures,
864 "max failures reached, stopping",
865 );
866 dispatcher.stop();
867 }
868
869 if final_count >= total_executions {
870 dispatcher.close();
871 }
872 }
873
874 for handle in worker_handles {
875 let _ = handle.await;
876 }
877
878 if !self.hooks.global_teardown_fns.is_empty() {
880 let global_pool = FixturePool::new(FxHashMap::default(), FixtureScope::Global);
881 for teardown_fn in &self.hooks.global_teardown_fns {
882 if let Err(e) = teardown_fn(global_pool.clone()).await {
883 tracing::error!(target: "ferridriver::runner", "global teardown error: {e}");
884 }
885 }
886 }
887
888 let duration = start.elapsed();
889
890 let mut passed = 0usize;
892 let mut failed = 0usize;
893 let mut skipped = 0usize;
894 let mut flaky = 0usize;
895
896 for attempts in attempt_history.values() {
897 match crate::retry::RetryPolicy::final_status(attempts) {
898 TestStatus::Passed => passed += 1,
899 TestStatus::Flaky => {
900 flaky += 1;
901 passed += 1;
902 },
903 TestStatus::Skipped => skipped += 1,
904 _ => failed += 1,
905 }
906 }
907
908 if self.config.preserve_output == "failures-only" {
910 for (test_key, attempts) in &attempt_history {
911 let status = crate::retry::RetryPolicy::final_status(attempts);
912 if matches!(status, TestStatus::Passed | TestStatus::Skipped | TestStatus::Flaky) {
913 let test_output_dir = self.config.output_dir.join(test_key);
914 if test_output_dir.exists() {
915 let _ = std::fs::remove_dir_all(&test_output_dir);
916 }
917 }
918 }
919 }
920
921 if let Some(mgr) = web_server_manager {
923 mgr.stop().await;
924 }
925
926 if emit_boundary {
927 event_bus.emit(ReporterEvent::RunFinished {
928 total: total_tests,
929 passed,
930 failed,
931 skipped,
932 flaky,
933 duration,
934 });
935 }
936
937 let exit_code = if failed > 0 || (self.config.fail_on_flaky_tests && flaky > 0) {
938 1
939 } else {
940 0
941 };
942 if exit_code != 0 && failed == 0 && flaky > 0 && self.config.fail_on_flaky_tests {
943 tracing::warn!(
944 target: "ferridriver::runner",
945 flaky,
946 "fail_on_flaky_tests: flagging exit 1 for {flaky} flaky test(s)",
947 );
948 }
949 ExecuteSummary {
950 exit_code,
951 total: total_tests,
952 passed,
953 failed,
954 skipped,
955 flaky,
956 }
957 }
958
959 pub async fn run_watch<F>(&mut self, plan_factory: F, watch_root: std::path::PathBuf) -> i32
971 where
972 F: Fn(Option<&[std::path::PathBuf]>) -> TestPlan,
973 {
974 use crate::watch::FileWatcher;
975
976 let launch_plan = build_launch_plan(&self.config.browser);
978 let browser = match launch_with_plan(launch_plan).await {
979 Ok(b) => Arc::new(b),
980 Err(e) => {
981 eprintln!("Failed to launch browser: {e}");
982 return 1;
983 },
984 };
985 self.shared_browser = Some(Arc::clone(&browser));
986
987 let watcher = match FileWatcher::new(&watch_root, &self.config.test_match, &self.config.test_ignore) {
989 Ok(w) => w,
990 Err(e) => {
991 eprintln!("Failed to start file watcher: {e}");
992 return 1;
993 },
994 };
995
996 let tui_result = crate::tui::WatchTui::new();
998
999 match tui_result {
1000 Ok((mut tui, tui_tx)) => {
1001 self
1002 .run_watch_tui(&mut tui, tui_tx, &watcher, &plan_factory, &browser)
1003 .await;
1004 tui.shutdown();
1005 },
1006 Err(e) => {
1007 tracing::debug!(target: "ferridriver::watch", "TUI unavailable ({e}), running non-interactive");
1009 Box::pin(self.run_watch_headless(&watcher, &plan_factory)).await;
1010 },
1011 }
1012
1013 self.shared_browser = None;
1015 let _ = browser.close(None).await;
1016
1017 0
1018 }
1019
1020 async fn run_with_tui_drain(&mut self, plan: TestPlan, tui: &mut crate::tui::WatchTui) -> bool {
1028 let mut builder = EventBusBuilder::new();
1029 let reporter_sub = builder.subscribe();
1030 let bus = builder.build();
1031
1032 let reporters = std::mem::take(&mut self.reporters);
1033 let driver = ReporterDriver::new(reporters, reporter_sub);
1034 let driver_handle = tokio::spawn(driver.run());
1035
1036 let cancelled = tokio::select! {
1040 _ = self.execute(plan, bus.clone()) => {
1041 tui.flush();
1042 false
1043 }
1044 result = tui.drain_while_running() => {
1045 matches!(result, crate::tui::DrainResult::Cancelled)
1046 }
1047 };
1048
1049 bus.close();
1050 if let Ok(reporters) = driver_handle.await {
1051 self.reporters = reporters;
1052 }
1053
1054 cancelled
1055 }
1056
1057 async fn run_watch_tui<F>(
1059 &mut self,
1060 tui: &mut crate::tui::WatchTui,
1061 tui_tx: tokio::sync::mpsc::UnboundedSender<crate::tui::TuiMessage>,
1062 watcher: &crate::watch::FileWatcher,
1063 plan_factory: &F,
1064 _browser: &Arc<Browser>,
1065 ) where
1066 F: Fn(Option<&[std::path::PathBuf]>) -> TestPlan,
1067 {
1068 use crate::interactive::WatchCommand;
1069
1070 let mut grep_filter: Option<String> = None;
1071
1072 self.reporters.replace(vec![
1075 Box::new(crate::tui_reporter::TuiReporter::new(
1076 tui_tx.clone(),
1077 self.config.has_bdd,
1078 )),
1079 Box::new(crate::reporter::rerun::RerunReporter::new(
1080 self.config.output_dir.join("@rerun.txt"),
1081 )),
1082 ]);
1083
1084 let plan = plan_factory(None);
1086 if self.run_with_tui_drain(plan, tui).await {
1087 return; }
1089 tui.set_status(crate::tui::WatchStatus::Idle);
1090
1091 loop {
1093 tokio::select! {
1094 change = watcher.recv() => {
1095 let Some(change) = change else { break };
1096 let mut all_changes = vec![change];
1097 all_changes.extend(watcher.drain_deduped());
1098
1099 let (run_all, changed_paths) = classify_changes(&all_changes);
1100 if !run_all && changed_paths.is_empty() { continue; }
1101
1102 let mut plan = build_plan_for_changes(plan_factory, run_all, &changed_paths);
1103 if let Some(ref pattern) = grep_filter {
1105 crate::discovery::filter_by_grep(&mut plan, pattern, false);
1106 }
1107 if plan.total_tests == 0 { continue; }
1108
1109 if self.run_with_tui_drain(plan, tui).await { break; }
1110 tui.set_status(crate::tui::WatchStatus::Idle);
1111 }
1112
1113 cmd = tui.next_command() => {
1114 let Some(cmd) = cmd else { break };
1115 match cmd {
1116 WatchCommand::Quit => break,
1117 WatchCommand::RunAll => {
1118 grep_filter = None;
1119 tui.active_filter = None;
1120 if self.run_with_tui_drain(plan_factory(None), tui).await { break; }
1121 tui.set_status(crate::tui::WatchStatus::Idle);
1122 }
1123 WatchCommand::RunFailed => {
1124 let mut plan = plan_factory(None);
1125 let rerun_path = self.config.output_dir.join("@rerun.txt");
1126 if rerun_path.exists() {
1127 crate::discovery::filter_by_rerun(&mut plan, &rerun_path);
1128 }
1129 if let Some(ref pattern) = grep_filter {
1131 crate::discovery::filter_by_grep(&mut plan, pattern, false);
1132 }
1133 if plan.total_tests > 0
1134 && self.run_with_tui_drain(plan, tui).await { break; }
1135 tui.set_status(crate::tui::WatchStatus::Idle);
1136 }
1137 WatchCommand::Rerun => {
1138 let mut plan = plan_factory(None);
1139 if let Some(ref pattern) = grep_filter {
1140 crate::discovery::filter_by_grep(&mut plan, pattern, false);
1141 }
1142 if self.run_with_tui_drain(plan, tui).await { break; }
1143 tui.set_status(crate::tui::WatchStatus::Idle);
1144 }
1145 WatchCommand::FilterByName(pattern) => {
1146 if !pattern.is_empty() {
1147 grep_filter = Some(pattern.clone());
1148 let mut plan = plan_factory(None);
1149 crate::discovery::filter_by_grep(&mut plan, &pattern, false);
1150 if self.run_with_tui_drain(plan, tui).await { break; }
1151 }
1152 tui.set_status(crate::tui::WatchStatus::Idle);
1153 }
1154 }
1155 }
1156 }
1157 }
1158 }
1159
1160 async fn run_watch_headless<F>(&mut self, watcher: &crate::watch::FileWatcher, plan_factory: &F)
1162 where
1163 F: Fn(Option<&[std::path::PathBuf]>) -> TestPlan,
1164 {
1165 let plan = plan_factory(None);
1167 let _ = Box::pin(self.run(plan)).await;
1168 eprintln!("\n\x1b[2mWatching for changes (non-interactive)...\x1b[0m\n");
1169
1170 loop {
1171 let Some(change) = watcher.recv().await else { break };
1172 let mut all_changes = vec![change];
1173 all_changes.extend(watcher.drain_deduped());
1174
1175 let (run_all, changed_paths) = classify_changes(&all_changes);
1176 if !run_all && changed_paths.is_empty() {
1177 continue;
1178 }
1179
1180 eprintln!("\n\x1b[2mChange detected, re-running...\x1b[0m\n");
1181
1182 let plan = build_plan_for_changes(plan_factory, run_all, &changed_paths);
1183 if plan.total_tests == 0 {
1184 eprintln!("No tests matched changed files.");
1185 continue;
1186 }
1187
1188 let _ = Box::pin(self.run(plan)).await;
1189 eprintln!("\n\x1b[2mWatching for changes (non-interactive)...\x1b[0m\n");
1190 }
1191 }
1192}
1193
1194fn classify_changes(changes: &[crate::watch::ChangeKind]) -> (bool, Vec<std::path::PathBuf>) {
1196 use crate::watch::ChangeKind;
1197 let mut run_all = false;
1198 let mut changed_paths = Vec::new();
1199 for change in changes {
1200 match change {
1201 ChangeKind::SourceFile(_) | ChangeKind::StepFile(_) | ChangeKind::Config => {
1202 run_all = true;
1203 },
1204 ChangeKind::TestFile(p) | ChangeKind::FeatureFile(p) => {
1205 changed_paths.push(p.clone());
1206 },
1207 }
1208 }
1209 (run_all, changed_paths)
1210}
1211
1212fn build_plan_for_changes(
1214 plan_factory: &dyn Fn(Option<&[std::path::PathBuf]>) -> TestPlan,
1215 run_all: bool,
1216 changed_paths: &[std::path::PathBuf],
1217) -> TestPlan {
1218 let changed = if run_all { None } else { Some(changed_paths) };
1219 let mut plan = plan_factory(changed);
1220
1221 if !run_all && !changed_paths.is_empty() {
1223 let changed_names: rustc_hash::FxHashSet<&str> = changed_paths
1224 .iter()
1225 .filter_map(|p| p.file_name().and_then(|n| n.to_str()))
1226 .collect();
1227 for suite in &mut plan.suites {
1228 suite
1229 .tests
1230 .retain(|t| changed_names.iter().any(|name| t.id.file.contains(name)));
1231 }
1232 plan.suites.retain(|s| !s.tests.is_empty());
1233 plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
1234 }
1235
1236 plan
1237}
1238
1239fn topo_sort_projects(projects: &[ProjectConfig]) -> Result<Vec<usize>, ferridriver::FerriError> {
1243 let name_to_idx: FxHashMap<&str, usize> = projects.iter().enumerate().map(|(i, p)| (p.name.as_str(), i)).collect();
1244
1245 let n = projects.len();
1247 let mut in_degree = vec![0usize; n];
1248 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
1249
1250 for (i, project) in projects.iter().enumerate() {
1251 for dep_name in &project.dependencies {
1252 let &dep_idx = name_to_idx.get(dep_name.as_str()).ok_or_else(|| {
1253 ferridriver::FerriError::invalid_argument(
1254 "dependencies",
1255 format!("project '{}' depends on unknown project '{dep_name}'", project.name),
1256 )
1257 })?;
1258 adj[dep_idx].push(i);
1259 in_degree[i] += 1;
1260 }
1261 }
1262
1263 let mut queue: std::collections::VecDeque<usize> = in_degree
1265 .iter()
1266 .enumerate()
1267 .filter(|(_, d)| **d == 0)
1268 .map(|(i, _)| i)
1269 .collect();
1270
1271 let mut order = Vec::with_capacity(n);
1272 while let Some(node) = queue.pop_front() {
1273 order.push(node);
1274 for next in &adj[node] {
1275 in_degree[*next] -= 1;
1276 if in_degree[*next] == 0 {
1277 queue.push_back(*next);
1278 }
1279 }
1280 }
1281
1282 if order.len() != n {
1283 return Err(ferridriver::FerriError::invalid_argument(
1284 "dependencies",
1285 "circular dependency detected among projects",
1286 ));
1287 }
1288
1289 Ok(order)
1290}
1291
1292fn filter_plan_for_project(plan: &mut TestPlan, config: &TestConfig, project: &ProjectConfig) {
1296 if let Some(ref test_dir) = config.test_dir {
1298 plan.suites.retain(|s| s.file.starts_with(test_dir.as_str()));
1299 }
1300
1301 if let Some(ref grep) = config.config_grep {
1303 crate::discovery::filter_by_grep(plan, grep, false);
1304 }
1305 if let Some(ref grep_inv) = config.config_grep_invert {
1306 crate::discovery::filter_by_grep(plan, grep_inv, true);
1307 }
1308
1309 if let Some(ref tags) = project.tag {
1311 for tag in tags {
1312 crate::discovery::filter_by_tag(plan, tag);
1313 }
1314 }
1315
1316 plan.suites.retain(|s| !s.tests.is_empty());
1318 plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
1319}
1320
1321fn build_launch_plan(browser_config: &crate::config::BrowserConfig) -> LaunchPlan {
1322 let backend = match browser_config.backend.as_str() {
1324 "cdp-raw" => BackendKind::CdpRaw,
1325 "webkit" => BackendKind::WebKit,
1326 "bidi" => BackendKind::Bidi,
1327 _ => BackendKind::CdpPipe,
1328 };
1329
1330 let kind = match browser_config.browser.as_str() {
1331 "firefox" => BrowserKind::Firefox,
1332 "webkit" => BrowserKind::WebKit,
1333 _ => BrowserKind::Chromium,
1334 };
1335
1336 let mut args = browser_config.args.clone();
1337 if let Some(ref proxy) = browser_config.use_options.proxy {
1339 args.push(format!("--proxy-server={}", proxy.server));
1340 if let Some(ref bypass) = proxy.bypass {
1341 args.push(format!("--proxy-bypass-list={bypass}"));
1342 }
1343 }
1344 if browser_config.use_options.ignore_https_errors {
1346 args.push("--ignore-certificate-errors".to_string());
1347 }
1348
1349 let headless = browser_config.headless || std::env::var("CI").is_ok();
1356
1357 LaunchPlan {
1358 backend,
1359 kind,
1360 headless,
1361 executable_path: browser_config.executable_path.clone(),
1362 args,
1363 default_viewport: browser_config
1364 .viewport
1365 .as_ref()
1366 .map(|v| ferridriver::options::ViewportConfig {
1367 width: v.width,
1368 height: v.height,
1369 ..Default::default()
1370 }),
1371 ..Default::default()
1372 }
1373}
1374
1375pub(crate) async fn launch_with_plan(plan: LaunchPlan) -> ferridriver::error::Result<Browser> {
1379 let mut state = BrowserState::with_plan(ConnectMode::Launch, plan);
1380 Box::pin(state.ensure_browser()).await?;
1381 Ok(Browser::from_state(state))
1382}
1383
1384pub struct BrowserHandle {
1390 plan: LaunchPlan,
1391 cell: tokio::sync::OnceCell<Arc<Browser>>,
1392 shared: bool,
1393}
1394
1395impl BrowserHandle {
1396 pub fn new(plan: LaunchPlan) -> Self {
1397 Self {
1398 plan,
1399 cell: tokio::sync::OnceCell::new(),
1400 shared: false,
1401 }
1402 }
1403
1404 pub fn from_shared(browser: Arc<Browser>) -> Self {
1407 let cell = tokio::sync::OnceCell::new();
1408 let _ = cell.set(browser);
1409 Self {
1410 plan: LaunchPlan::default(),
1411 cell,
1412 shared: true,
1413 }
1414 }
1415
1416 #[tracing::instrument(skip_all, name = "browser_launch")]
1417 pub async fn get(&self) -> ferridriver::error::Result<Arc<Browser>> {
1418 let plan = self.plan.clone();
1419 self
1420 .cell
1421 .get_or_try_init(|| async move { launch_with_plan(plan).await.map(Arc::new) })
1422 .await
1423 .cloned()
1424 }
1425
1426 pub fn try_get(&self) -> Option<Arc<Browser>> {
1427 self.cell.get().cloned()
1428 }
1429
1430 pub async fn close(&self) {
1431 if self.shared {
1432 return;
1433 }
1434 if let Some(b) = self.cell.get() {
1435 let _ = b.close(None).await;
1436 }
1437 }
1438}