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
23pub struct TestRunner {
25 config: Arc<TestConfig>,
26 hooks: TestHooks,
27 reporters: ReporterSet,
28 overrides: CliOverrides,
29 shared_browser: Option<Arc<Browser>>,
31}
32
33impl TestRunner {
34 pub fn new(config: TestConfig, overrides: CliOverrides) -> Self {
37 Self::with_hooks(config, TestHooks::default(), overrides)
38 }
39
40 pub fn with_hooks(config: TestConfig, hooks: TestHooks, overrides: CliOverrides) -> Self {
42 let reporters = crate::reporter::create_reporters(
43 &config.reporter,
44 &config.output_dir,
45 config.has_bdd,
46 config.quiet,
47 config.report_slow_tests.clone(),
48 );
49 Self {
50 config: Arc::new(config),
51 hooks,
52 reporters,
53 overrides,
54 shared_browser: None,
55 }
56 }
57
58 pub fn add_reporter(&mut self, reporter: Box<dyn crate::reporter::Reporter>) {
60 self.reporters.add(reporter);
61 }
62
63 pub async fn run(&mut self, plan: TestPlan) -> i32 {
73 let global_timeout = self.config.global_timeout;
74 let inner = async move {
75 if !self.config.projects.is_empty() {
77 return Box::pin(self.run_projects(plan)).await;
78 }
79
80 let mut builder = EventBusBuilder::new();
82 let reporter_sub = builder.subscribe();
83 let bus = builder.build();
84
85 let reporters = std::mem::take(&mut self.reporters);
86 let driver = ReporterDriver::new(reporters, reporter_sub);
87 let driver_handle = tokio::spawn(driver.run());
88
89 let exit_code = self.execute(plan, bus.clone()).await;
90
91 bus.close();
95
96 if let Ok(reporters) = driver_handle.await {
97 self.reporters = reporters;
98 }
99
100 exit_code
101 };
102
103 if global_timeout > 0 {
104 if let Ok(code) = tokio::time::timeout(std::time::Duration::from_millis(global_timeout), inner).await {
105 code
106 } else {
107 tracing::error!(
108 target: "ferridriver::runner",
109 global_timeout_ms = global_timeout,
110 "global timeout exceeded — aborting run",
111 );
112 eprintln!("Error: global timeout of {global_timeout}ms exceeded");
113 1
114 }
115 } else {
116 inner.await
117 }
118 }
119
120 async fn run_projects(&mut self, plan: TestPlan) -> i32 {
132 let projects = self.config.projects.clone();
133
134 let sorted = match topo_sort_projects(&projects) {
135 Ok(order) => order,
136 Err(e) => {
137 tracing::error!(target: "ferridriver::runner", "project dependency error: {e}");
138 return 1;
139 },
140 };
141
142 let allowed_indices: rustc_hash::FxHashSet<usize> = if self.overrides.project_filter.is_empty() {
147 (0..projects.len()).collect()
148 } else {
149 let mut wanted: rustc_hash::FxHashSet<usize> = rustc_hash::FxHashSet::default();
150 for name in &self.overrides.project_filter {
151 if let Some(idx) = projects.iter().position(|p| &p.name == name) {
152 wanted.insert(idx);
153 } else {
154 tracing::warn!(target: "ferridriver::runner", "--project {name}: no matching project");
155 }
156 }
157 if !self.overrides.no_deps {
159 let mut frontier: Vec<usize> = wanted.iter().copied().collect();
160 while let Some(idx) = frontier.pop() {
161 for dep_name in &projects[idx].dependencies {
162 if let Some(dep_idx) = projects.iter().position(|p| &p.name == dep_name) {
163 if wanted.insert(dep_idx) {
164 frontier.push(dep_idx);
165 }
166 }
167 }
168 }
169 }
170 let kept: Vec<usize> = wanted.iter().copied().collect();
172 for idx in kept {
173 if let Some(t) = &projects[idx].teardown {
174 if let Some(t_idx) = projects.iter().position(|p| &p.name == t) {
175 wanted.insert(t_idx);
176 }
177 }
178 }
179 wanted
180 };
181 let sorted: Vec<usize> = sorted.into_iter().filter(|idx| allowed_indices.contains(idx)).collect();
182
183 let cli_teardown_idx: Option<usize> = self
186 .overrides
187 .teardown
188 .as_deref()
189 .and_then(|name| projects.iter().position(|p| p.name == name));
190
191 tracing::info!(
192 target: "ferridriver::runner",
193 projects = sorted.len(),
194 order = ?sorted.iter().map(|i| &projects[*i].name).collect::<Vec<_>>(),
195 "running projects in dependency order",
196 );
197
198 let mut exit_code = 0i32;
199 let mut failed_projects: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
200 let mut completed_projects: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
202 let mut pending_teardowns: Vec<usize> = Vec::new();
204
205 for &idx in &sorted {
206 let project = &projects[idx];
207
208 let dep_failed = project.dependencies.iter().any(|dep| failed_projects.contains(dep));
210 if dep_failed {
211 tracing::warn!(
212 target: "ferridriver::runner",
213 project = project.name,
214 "skipping — dependency failed",
215 );
216 failed_projects.insert(project.name.clone());
217 continue;
218 }
219
220 let is_teardown = projects.iter().any(|p| p.teardown.as_deref() == Some(&project.name));
223 if is_teardown && !completed_projects.contains(&project.name) {
224 pending_teardowns.push(idx);
226 continue;
227 }
228
229 let project_exit = Box::pin(self.run_single_project(project, &plan)).await;
230
231 completed_projects.insert(project.name.clone());
232
233 if project_exit != 0 {
234 exit_code = 1;
235 failed_projects.insert(project.name.clone());
236 }
237
238 if let Some(ref teardown_name) = project.teardown {
240 if let Some(td_idx) = projects.iter().position(|p| p.name == *teardown_name) {
241 let td_project = &projects[td_idx];
242 tracing::info!(
243 target: "ferridriver::runner",
244 project = td_project.name,
245 parent = project.name,
246 "running teardown project",
247 );
248 let td_exit = Box::pin(self.run_single_project(td_project, &plan)).await;
249 completed_projects.insert(td_project.name.clone());
250 pending_teardowns.retain(|&i| i != td_idx);
252 if td_exit != 0 {
253 exit_code = 1;
254 }
255 }
256 }
257 }
258
259 for td_idx in pending_teardowns {
261 let td_project = &projects[td_idx];
262 if completed_projects.contains(&td_project.name) {
263 continue;
264 }
265 tracing::info!(
266 target: "ferridriver::runner",
267 project = td_project.name,
268 "running deferred teardown project",
269 );
270 let td_exit = Box::pin(self.run_single_project(td_project, &plan)).await;
271 if td_exit != 0 {
272 exit_code = 1;
273 }
274 }
275
276 if let Some(td_idx) = cli_teardown_idx {
280 let td_project = &projects[td_idx];
281 if !completed_projects.contains(&td_project.name) {
282 tracing::info!(
283 target: "ferridriver::runner",
284 project = td_project.name,
285 "running CLI-supplied teardown project",
286 );
287 let td_exit = Box::pin(self.run_single_project(td_project, &plan)).await;
288 if td_exit != 0 {
289 exit_code = 1;
290 }
291 }
292 }
293
294 exit_code
295 }
296
297 async fn run_single_project(&mut self, project: &ProjectConfig, base_plan: &TestPlan) -> i32 {
299 let merged_config = self.config.merge_project(project);
300
301 let mut plan = base_plan.clone();
303 filter_plan_for_project(&mut plan, &merged_config, project);
304
305 if plan.total_tests == 0 {
306 tracing::debug!(
307 target: "ferridriver::runner",
308 project = project.name,
309 "no tests matched, skipping",
310 );
311 return 0;
312 }
313
314 tracing::info!(
315 target: "ferridriver::runner",
316 project = project.name,
317 tests = plan.total_tests,
318 "running project",
319 );
320
321 let reporters = std::mem::take(&mut self.reporters);
323
324 let mut builder = EventBusBuilder::new();
325 let reporter_sub = builder.subscribe();
326 let bus = builder.build();
327
328 let driver = ReporterDriver::new(reporters, reporter_sub);
329 let driver_handle = tokio::spawn(driver.run());
330
331 let sub_runner = TestRunner {
333 config: Arc::new(merged_config),
334 hooks: self.hooks.clone(),
335 reporters: ReporterSet::default(),
336 overrides: self.overrides.clone(),
337 shared_browser: self.shared_browser.clone(),
338 };
339
340 let exit_code = sub_runner.execute(plan, bus.clone()).await;
341 bus.close();
342
343 if let Ok(reporters) = driver_handle.await {
344 self.reporters = reporters;
345 }
346
347 exit_code
348 }
349
350 #[tracing::instrument(skip_all, fields(workers = self.config.workers, tests = plan.total_tests))]
358 pub async fn execute(&self, mut plan: TestPlan, event_bus: EventBus) -> i32 {
359 if let Some(shard_arg) = &self.overrides.shard {
361 shard::filter_by_shard(
362 &mut plan,
363 &crate::model::ShardInfo {
364 current: shard_arg.current,
365 total: shard_arg.total,
366 },
367 );
368 }
369 let grep = self.overrides.grep.as_ref().or(self.config.config_grep.as_ref());
371 let grep_inv = self
372 .overrides
373 .grep_invert
374 .as_ref()
375 .or(self.config.config_grep_invert.as_ref());
376 if let Some(grep) = grep {
377 crate::discovery::filter_by_grep(&mut plan, grep, false);
378 }
379 if let Some(grep_inv) = grep_inv {
380 crate::discovery::filter_by_grep(&mut plan, grep_inv, true);
381 }
382 if let Some(tag) = &self.overrides.tag {
383 crate::discovery::filter_by_tag(&mut plan, tag);
384 }
385
386 if self.config.forbid_only || self.overrides.forbid_only {
388 if let Err(e) = crate::discovery::check_forbid_only(&plan) {
389 eprint!("{e}");
390 return 1;
391 }
392 }
393
394 crate::discovery::filter_by_only(&mut plan);
396
397 if self.overrides.last_failed {
399 let rerun_path = self.config.output_dir.join("@rerun.txt");
400 crate::discovery::filter_by_rerun(&mut plan, &rerun_path);
401 }
402
403 if self.config.preserve_output == "never" {
405 let _ = std::fs::remove_dir_all(&self.config.output_dir);
406 }
407
408 let total_tests = plan.total_tests;
409 tracing::debug!(
410 target: "ferridriver::runner",
411 total_tests,
412 suites = plan.suites.len(),
413 "test plan after filtering",
414 );
415 if total_tests == 0 {
416 tracing::info!(target: "ferridriver::runner", "no tests found");
417 return 0;
418 }
419
420 if self.overrides.list_only {
421 for suite in &plan.suites {
422 for test in &suite.tests {
423 println!(" {}", test.id.full_name());
424 }
425 }
426 println!("\n {total_tests} test(s) found");
427 return 0;
428 }
429
430 let num_workers = (self.config.workers as usize).min(total_tests).max(1) as u32;
432
433 {
435 let fixture_defs = builtin_fixtures(&self.config.browser);
436 if let Err(e) = validate_dag(&fixture_defs) {
437 tracing::error!(target: "ferridriver::fixture", "fixture DAG error: {e}");
438 return 1;
439 }
440 }
441
442 let web_server_manager = if !self.config.web_server.is_empty() {
445 match crate::server::WebServerManager::start(&self.config.web_server).await {
446 Ok(mgr) => {
447 if let Some(url) = mgr.first_url() {
448 if self.config.base_url.is_none() {
449 #[allow(unsafe_code)]
452 unsafe {
453 std::env::set_var("FERRIDRIVER_BASE_URL", &url)
454 };
455 tracing::info!(target: "ferridriver::runner", "webServer base_url={url}");
456 }
457 }
458 Some(mgr)
459 },
460 Err(e) => {
461 tracing::error!(target: "ferridriver::runner", "webServer start failed: {e}");
462 return 1;
463 },
464 }
465 } else {
466 None
467 };
468
469 let mut run_metadata = self.config.metadata.clone();
472 if self.config.capture_git_info {
473 let info = crate::git_info::GitInfo::capture();
474 let git_value = serde_json::to_value(&info).unwrap_or(serde_json::Value::Null);
475 match &mut run_metadata {
476 serde_json::Value::Object(map) => {
477 map.insert("git".into(), git_value);
478 },
479 other => {
480 *other = serde_json::json!({ "git": git_value });
481 },
482 }
483 }
484
485 event_bus
486 .emit(ReporterEvent::RunStarted {
487 total_tests,
488 num_workers,
489 metadata: run_metadata,
490 })
491 .await;
492
493 let start = Instant::now();
494
495 if !self.hooks.global_setup_fns.is_empty() {
497 let global_pool = FixturePool::new(FxHashMap::default(), FixtureScope::Global);
498 for setup_fn in &self.hooks.global_setup_fns {
499 if let Err(e) = setup_fn(global_pool.clone()).await {
500 tracing::error!(target: "ferridriver::runner", "global setup failed: {e}");
501 event_bus
502 .emit(ReporterEvent::RunFinished {
503 total: total_tests,
504 passed: 0,
505 failed: total_tests,
506 skipped: 0,
507 flaky: 0,
508 duration: start.elapsed(),
509 })
510 .await;
511 return 1;
512 }
513 }
514 }
515
516 let repeat_each = self.config.repeat_each.max(1);
518 let total_executions = total_tests * repeat_each as usize;
519
520 let dispatcher = Arc::new(Dispatcher::new());
522 for _rep in 0..repeat_each {
523 for suite in &plan.suites {
524 let suite_key = format!("{}::{}", suite.file, suite.name);
525 let hooks = Arc::new(Hooks {
526 before_all: suite.hooks.before_all.clone(),
527 after_all: suite.hooks.after_all.clone(),
528 before_each: suite.hooks.before_each.clone(),
529 after_each: suite.hooks.after_each.clone(),
530 });
531
532 match suite.mode {
533 crate::model::SuiteMode::Parallel => {
534 for test in &suite.tests {
535 let assignment = crate::dispatcher::TestAssignment {
536 test: crate::model::TestCase {
537 id: test.id.clone(),
538 test_fn: Arc::clone(&test.test_fn),
539 fixture_requests: test.fixture_requests.clone(),
540 annotations: test.annotations.clone(),
541 timeout: test.timeout,
542 retries: test.retries,
543 expected_status: test.expected_status.clone(),
544 use_options: test.use_options.clone(),
545 },
546 attempt: 1,
547 suite_key: suite_key.clone(),
548 hooks: Arc::clone(&hooks),
549 suite_mode: crate::model::SuiteMode::Parallel,
550 };
551 dispatcher.enqueue_single(assignment);
552 }
553 },
554 crate::model::SuiteMode::Serial => {
555 let assignments: Vec<_> = suite
556 .tests
557 .iter()
558 .map(|test| crate::dispatcher::TestAssignment {
559 test: crate::model::TestCase {
560 id: test.id.clone(),
561 test_fn: Arc::clone(&test.test_fn),
562 fixture_requests: test.fixture_requests.clone(),
563 annotations: test.annotations.clone(),
564 timeout: test.timeout,
565 retries: test.retries,
566 expected_status: test.expected_status.clone(),
567 use_options: test.use_options.clone(),
568 },
569 attempt: 1,
570 suite_key: suite_key.clone(),
571 hooks: Arc::clone(&hooks),
572 suite_mode: crate::model::SuiteMode::Serial,
573 })
574 .collect();
575 dispatcher.enqueue_serial(crate::dispatcher::SerialBatch {
576 suite_key: suite_key.clone(),
577 assignments,
578 hooks: Arc::clone(&hooks),
579 });
580 },
581 }
582 }
583 }
584
585 let (result_tx, mut result_rx) = mpsc::channel::<WorkerTestResult>(256);
591
592 let mut worker_handles = Vec::new();
593 let launch_plan = build_launch_plan(&self.config.browser);
594
595 for worker_id in 0..num_workers {
596 let worker = Worker::new(worker_id, Arc::clone(&self.config), event_bus.clone());
597 let rx = dispatcher.receiver();
598 let tx = result_tx.clone();
599 let custom_pool = FixturePool::new(FxHashMap::default(), FixtureScope::Worker);
600 let shared = self.shared_browser.clone();
601 let plan = launch_plan.clone();
602 let stop_flag = dispatcher.stop_flag();
603
604 let handle = tokio::spawn(async move {
605 let browser_handle = if let Some(b) = shared {
606 Arc::new(BrowserHandle::from_shared(b))
607 } else {
608 Arc::new(BrowserHandle::new(plan))
609 };
610 Box::pin(worker.run(browser_handle, custom_pool, rx, tx, stop_flag)).await;
611 });
612 worker_handles.push(handle);
613 }
614 drop(result_tx);
615
616 let mut attempt_history: FxHashMap<String, Vec<TestStatus>> = FxHashMap::default();
618 let mut final_count = 0usize;
619 let mut failure_count = 0usize;
620 let max_failures = if self.config.fail_fast {
621 1 } else {
623 self.config.max_failures as usize };
625
626 while let Some(result) = result_rx.recv().await {
627 let test_key = result.outcome.test_id.full_name();
628 attempt_history
629 .entry(test_key)
630 .or_default()
631 .push(result.outcome.status.clone());
632
633 if result.should_retry {
634 tracing::debug!(
635 target: "ferridriver::runner",
636 test = result.test_id.full_name(),
637 attempt = result.outcome.attempt,
638 "retrying failed test",
639 );
640 dispatcher.retry_shared(
641 &result.test_fn,
642 &result.test_id,
643 result.fixture_requests.clone(),
644 result.outcome.attempt + 1,
645 result.suite_key.clone(),
646 Arc::clone(&result.hooks),
647 );
648 } else {
649 final_count += 1;
650 if matches!(result.outcome.status, TestStatus::Failed | TestStatus::TimedOut) {
652 failure_count += 1;
653 }
654 }
655
656 if max_failures > 0 && failure_count >= max_failures {
660 tracing::info!(
661 target: "ferridriver::runner",
662 failure_count,
663 max_failures,
664 "max failures reached, stopping",
665 );
666 dispatcher.stop();
667 }
668
669 if final_count >= total_executions {
670 dispatcher.close();
671 }
672 }
673
674 for handle in worker_handles {
675 let _ = handle.await;
676 }
677
678 if !self.hooks.global_teardown_fns.is_empty() {
680 let global_pool = FixturePool::new(FxHashMap::default(), FixtureScope::Global);
681 for teardown_fn in &self.hooks.global_teardown_fns {
682 if let Err(e) = teardown_fn(global_pool.clone()).await {
683 tracing::error!(target: "ferridriver::runner", "global teardown error: {e}");
684 }
685 }
686 }
687
688 let duration = start.elapsed();
689
690 let mut passed = 0usize;
692 let mut failed = 0usize;
693 let mut skipped = 0usize;
694 let mut flaky = 0usize;
695
696 for attempts in attempt_history.values() {
697 match crate::retry::RetryPolicy::final_status(attempts) {
698 TestStatus::Passed => passed += 1,
699 TestStatus::Flaky => {
700 flaky += 1;
701 passed += 1;
702 },
703 TestStatus::Skipped => skipped += 1,
704 _ => failed += 1,
705 }
706 }
707
708 if self.config.preserve_output == "failures-only" {
710 for (test_key, attempts) in &attempt_history {
711 let status = crate::retry::RetryPolicy::final_status(attempts);
712 if matches!(status, TestStatus::Passed | TestStatus::Skipped | TestStatus::Flaky) {
713 let test_output_dir = self.config.output_dir.join(test_key);
714 if test_output_dir.exists() {
715 let _ = std::fs::remove_dir_all(&test_output_dir);
716 }
717 }
718 }
719 }
720
721 if let Some(mgr) = web_server_manager {
723 mgr.stop().await;
724 }
725
726 event_bus
727 .emit(ReporterEvent::RunFinished {
728 total: total_tests,
729 passed,
730 failed,
731 skipped,
732 flaky,
733 duration,
734 })
735 .await;
736
737 let exit_code = if failed > 0 || (self.config.fail_on_flaky_tests && flaky > 0) {
738 1
739 } else {
740 0
741 };
742 if exit_code != 0 && failed == 0 && flaky > 0 && self.config.fail_on_flaky_tests {
743 tracing::warn!(
744 target: "ferridriver::runner",
745 flaky,
746 "fail_on_flaky_tests: flagging exit 1 for {flaky} flaky test(s)",
747 );
748 }
749 exit_code
750 }
751
752 pub async fn run_watch<F>(&mut self, plan_factory: F, watch_root: std::path::PathBuf) -> i32
764 where
765 F: Fn(Option<&[std::path::PathBuf]>) -> TestPlan,
766 {
767 use crate::watch::FileWatcher;
768
769 let launch_plan = build_launch_plan(&self.config.browser);
771 let browser = match launch_with_plan(launch_plan).await {
772 Ok(b) => Arc::new(b),
773 Err(e) => {
774 eprintln!("Failed to launch browser: {e}");
775 return 1;
776 },
777 };
778 self.shared_browser = Some(Arc::clone(&browser));
779
780 let watcher = match FileWatcher::new(&watch_root, &self.config.test_match, &self.config.test_ignore) {
782 Ok(w) => w,
783 Err(e) => {
784 eprintln!("Failed to start file watcher: {e}");
785 return 1;
786 },
787 };
788
789 let tui_result = crate::tui::WatchTui::new();
791
792 match tui_result {
793 Ok((mut tui, tui_tx)) => {
794 self
795 .run_watch_tui(&mut tui, tui_tx, &watcher, &plan_factory, &browser)
796 .await;
797 tui.shutdown();
798 },
799 Err(e) => {
800 tracing::debug!(target: "ferridriver::watch", "TUI unavailable ({e}), running non-interactive");
802 Box::pin(self.run_watch_headless(&watcher, &plan_factory)).await;
803 },
804 }
805
806 self.shared_browser = None;
808 let _ = browser.close(None).await;
809
810 0
811 }
812
813 async fn run_with_tui_drain(&mut self, plan: TestPlan, tui: &mut crate::tui::WatchTui) -> bool {
821 let mut builder = EventBusBuilder::new();
822 let reporter_sub = builder.subscribe();
823 let bus = builder.build();
824
825 let reporters = std::mem::take(&mut self.reporters);
826 let driver = ReporterDriver::new(reporters, reporter_sub);
827 let driver_handle = tokio::spawn(driver.run());
828
829 let cancelled = tokio::select! {
833 _ = self.execute(plan, bus.clone()) => {
834 tui.flush();
835 false
836 }
837 result = tui.drain_while_running() => {
838 matches!(result, crate::tui::DrainResult::Cancelled)
839 }
840 };
841
842 bus.close();
843 if let Ok(reporters) = driver_handle.await {
844 self.reporters = reporters;
845 }
846
847 cancelled
848 }
849
850 async fn run_watch_tui<F>(
852 &mut self,
853 tui: &mut crate::tui::WatchTui,
854 tui_tx: tokio::sync::mpsc::UnboundedSender<crate::tui::TuiMessage>,
855 watcher: &crate::watch::FileWatcher,
856 plan_factory: &F,
857 _browser: &Arc<Browser>,
858 ) where
859 F: Fn(Option<&[std::path::PathBuf]>) -> TestPlan,
860 {
861 use crate::interactive::WatchCommand;
862
863 let mut grep_filter: Option<String> = None;
864
865 self.reporters.replace(vec![
868 Box::new(crate::tui_reporter::TuiReporter::new(
869 tui_tx.clone(),
870 self.config.has_bdd,
871 )),
872 Box::new(crate::reporter::rerun::RerunReporter::new(
873 self.config.output_dir.join("@rerun.txt"),
874 )),
875 ]);
876
877 let plan = plan_factory(None);
879 if self.run_with_tui_drain(plan, tui).await {
880 return; }
882 tui.set_status(crate::tui::WatchStatus::Idle);
883
884 loop {
886 tokio::select! {
887 change = watcher.recv() => {
888 let Some(change) = change else { break };
889 let mut all_changes = vec![change];
890 all_changes.extend(watcher.drain_deduped());
891
892 let (run_all, changed_paths) = classify_changes(&all_changes);
893 if !run_all && changed_paths.is_empty() { continue; }
894
895 let mut plan = build_plan_for_changes(plan_factory, run_all, &changed_paths);
896 if let Some(ref pattern) = grep_filter {
898 crate::discovery::filter_by_grep(&mut plan, pattern, false);
899 }
900 if plan.total_tests == 0 { continue; }
901
902 if self.run_with_tui_drain(plan, tui).await { break; }
903 tui.set_status(crate::tui::WatchStatus::Idle);
904 }
905
906 cmd = tui.next_command() => {
907 let Some(cmd) = cmd else { break };
908 match cmd {
909 WatchCommand::Quit => break,
910 WatchCommand::RunAll => {
911 grep_filter = None;
912 tui.active_filter = None;
913 if self.run_with_tui_drain(plan_factory(None), tui).await { break; }
914 tui.set_status(crate::tui::WatchStatus::Idle);
915 }
916 WatchCommand::RunFailed => {
917 let mut plan = plan_factory(None);
918 let rerun_path = self.config.output_dir.join("@rerun.txt");
919 if rerun_path.exists() {
920 crate::discovery::filter_by_rerun(&mut plan, &rerun_path);
921 }
922 if let Some(ref pattern) = grep_filter {
924 crate::discovery::filter_by_grep(&mut plan, pattern, false);
925 }
926 if plan.total_tests > 0
927 && self.run_with_tui_drain(plan, tui).await { break; }
928 tui.set_status(crate::tui::WatchStatus::Idle);
929 }
930 WatchCommand::Rerun => {
931 let mut plan = plan_factory(None);
932 if let Some(ref pattern) = grep_filter {
933 crate::discovery::filter_by_grep(&mut plan, pattern, false);
934 }
935 if self.run_with_tui_drain(plan, tui).await { break; }
936 tui.set_status(crate::tui::WatchStatus::Idle);
937 }
938 WatchCommand::FilterByName(pattern) => {
939 if !pattern.is_empty() {
940 grep_filter = Some(pattern.clone());
941 let mut plan = plan_factory(None);
942 crate::discovery::filter_by_grep(&mut plan, &pattern, false);
943 if self.run_with_tui_drain(plan, tui).await { break; }
944 }
945 tui.set_status(crate::tui::WatchStatus::Idle);
946 }
947 }
948 }
949 }
950 }
951 }
952
953 async fn run_watch_headless<F>(&mut self, watcher: &crate::watch::FileWatcher, plan_factory: &F)
955 where
956 F: Fn(Option<&[std::path::PathBuf]>) -> TestPlan,
957 {
958 let plan = plan_factory(None);
960 let _ = Box::pin(self.run(plan)).await;
961 eprintln!("\n\x1b[2mWatching for changes (non-interactive)...\x1b[0m\n");
962
963 loop {
964 let Some(change) = watcher.recv().await else { break };
965 let mut all_changes = vec![change];
966 all_changes.extend(watcher.drain_deduped());
967
968 let (run_all, changed_paths) = classify_changes(&all_changes);
969 if !run_all && changed_paths.is_empty() {
970 continue;
971 }
972
973 eprintln!("\n\x1b[2mChange detected, re-running...\x1b[0m\n");
974
975 let plan = build_plan_for_changes(plan_factory, run_all, &changed_paths);
976 if plan.total_tests == 0 {
977 eprintln!("No tests matched changed files.");
978 continue;
979 }
980
981 let _ = Box::pin(self.run(plan)).await;
982 eprintln!("\n\x1b[2mWatching for changes (non-interactive)...\x1b[0m\n");
983 }
984 }
985}
986
987fn classify_changes(changes: &[crate::watch::ChangeKind]) -> (bool, Vec<std::path::PathBuf>) {
989 use crate::watch::ChangeKind;
990 let mut run_all = false;
991 let mut changed_paths = Vec::new();
992 for change in changes {
993 match change {
994 ChangeKind::SourceFile(_) | ChangeKind::StepFile(_) | ChangeKind::Config => {
995 run_all = true;
996 },
997 ChangeKind::TestFile(p) | ChangeKind::FeatureFile(p) => {
998 changed_paths.push(p.clone());
999 },
1000 }
1001 }
1002 (run_all, changed_paths)
1003}
1004
1005fn build_plan_for_changes(
1007 plan_factory: &dyn Fn(Option<&[std::path::PathBuf]>) -> TestPlan,
1008 run_all: bool,
1009 changed_paths: &[std::path::PathBuf],
1010) -> TestPlan {
1011 let changed = if run_all { None } else { Some(changed_paths) };
1012 let mut plan = plan_factory(changed);
1013
1014 if !run_all && !changed_paths.is_empty() {
1016 let changed_names: rustc_hash::FxHashSet<&str> = changed_paths
1017 .iter()
1018 .filter_map(|p| p.file_name().and_then(|n| n.to_str()))
1019 .collect();
1020 for suite in &mut plan.suites {
1021 suite
1022 .tests
1023 .retain(|t| changed_names.iter().any(|name| t.id.file.contains(name)));
1024 }
1025 plan.suites.retain(|s| !s.tests.is_empty());
1026 plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
1027 }
1028
1029 plan
1030}
1031
1032fn topo_sort_projects(projects: &[ProjectConfig]) -> Result<Vec<usize>, ferridriver::FerriError> {
1036 let name_to_idx: FxHashMap<&str, usize> = projects.iter().enumerate().map(|(i, p)| (p.name.as_str(), i)).collect();
1037
1038 let n = projects.len();
1040 let mut in_degree = vec![0usize; n];
1041 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
1042
1043 for (i, project) in projects.iter().enumerate() {
1044 for dep_name in &project.dependencies {
1045 let &dep_idx = name_to_idx.get(dep_name.as_str()).ok_or_else(|| {
1046 ferridriver::FerriError::invalid_argument(
1047 "dependencies",
1048 format!("project '{}' depends on unknown project '{dep_name}'", project.name),
1049 )
1050 })?;
1051 adj[dep_idx].push(i);
1052 in_degree[i] += 1;
1053 }
1054 }
1055
1056 let mut queue: std::collections::VecDeque<usize> = in_degree
1058 .iter()
1059 .enumerate()
1060 .filter(|(_, d)| **d == 0)
1061 .map(|(i, _)| i)
1062 .collect();
1063
1064 let mut order = Vec::with_capacity(n);
1065 while let Some(node) = queue.pop_front() {
1066 order.push(node);
1067 for next in &adj[node] {
1068 in_degree[*next] -= 1;
1069 if in_degree[*next] == 0 {
1070 queue.push_back(*next);
1071 }
1072 }
1073 }
1074
1075 if order.len() != n {
1076 return Err(ferridriver::FerriError::invalid_argument(
1077 "dependencies",
1078 "circular dependency detected among projects",
1079 ));
1080 }
1081
1082 Ok(order)
1083}
1084
1085fn filter_plan_for_project(plan: &mut TestPlan, config: &TestConfig, project: &ProjectConfig) {
1089 if let Some(ref test_dir) = config.test_dir {
1091 plan.suites.retain(|s| s.file.starts_with(test_dir.as_str()));
1092 }
1093
1094 if let Some(ref grep) = config.config_grep {
1096 crate::discovery::filter_by_grep(plan, grep, false);
1097 }
1098 if let Some(ref grep_inv) = config.config_grep_invert {
1099 crate::discovery::filter_by_grep(plan, grep_inv, true);
1100 }
1101
1102 if let Some(ref tags) = project.tag {
1104 for tag in tags {
1105 crate::discovery::filter_by_tag(plan, tag);
1106 }
1107 }
1108
1109 plan.suites.retain(|s| !s.tests.is_empty());
1111 plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
1112}
1113
1114fn build_launch_plan(browser_config: &crate::config::BrowserConfig) -> LaunchPlan {
1115 let backend = match browser_config.backend.as_str() {
1117 "cdp-raw" => BackendKind::CdpRaw,
1118 "webkit" => BackendKind::WebKit,
1119 "bidi" => BackendKind::Bidi,
1120 _ => BackendKind::CdpPipe,
1121 };
1122
1123 let kind = match browser_config.browser.as_str() {
1124 "firefox" => BrowserKind::Firefox,
1125 "webkit" => BrowserKind::WebKit,
1126 _ => BrowserKind::Chromium,
1127 };
1128
1129 let mut args = browser_config.args.clone();
1130 if let Some(ref proxy) = browser_config.use_options.proxy {
1132 args.push(format!("--proxy-server={}", proxy.server));
1133 if let Some(ref bypass) = proxy.bypass {
1134 args.push(format!("--proxy-bypass-list={bypass}"));
1135 }
1136 }
1137 if browser_config.use_options.ignore_https_errors {
1139 args.push("--ignore-certificate-errors".to_string());
1140 }
1141
1142 let headless = browser_config.headless || std::env::var("CI").is_ok();
1149
1150 LaunchPlan {
1151 backend,
1152 kind,
1153 headless,
1154 executable_path: browser_config.executable_path.clone(),
1155 args,
1156 default_viewport: browser_config
1157 .viewport
1158 .as_ref()
1159 .map(|v| ferridriver::options::ViewportConfig {
1160 width: v.width,
1161 height: v.height,
1162 ..Default::default()
1163 }),
1164 ..Default::default()
1165 }
1166}
1167
1168pub(crate) async fn launch_with_plan(plan: LaunchPlan) -> ferridriver::error::Result<Browser> {
1172 let mut state = BrowserState::with_plan(ConnectMode::Launch, plan);
1173 Box::pin(state.ensure_browser()).await?;
1174 Ok(Browser::from_state(state))
1175}
1176
1177pub struct BrowserHandle {
1183 plan: LaunchPlan,
1184 cell: tokio::sync::OnceCell<Arc<Browser>>,
1185 shared: bool,
1186}
1187
1188impl BrowserHandle {
1189 pub fn new(plan: LaunchPlan) -> Self {
1190 Self {
1191 plan,
1192 cell: tokio::sync::OnceCell::new(),
1193 shared: false,
1194 }
1195 }
1196
1197 pub fn from_shared(browser: Arc<Browser>) -> Self {
1200 let cell = tokio::sync::OnceCell::new();
1201 let _ = cell.set(browser);
1202 Self {
1203 plan: LaunchPlan::default(),
1204 cell,
1205 shared: true,
1206 }
1207 }
1208
1209 #[tracing::instrument(skip_all, name = "browser_launch")]
1210 pub async fn get(&self) -> ferridriver::error::Result<Arc<Browser>> {
1211 let plan = self.plan.clone();
1212 self
1213 .cell
1214 .get_or_try_init(|| async move { launch_with_plan(plan).await.map(Arc::new) })
1215 .await
1216 .cloned()
1217 }
1218
1219 pub fn try_get(&self) -> Option<Arc<Browser>> {
1220 self.cell.get().cloned()
1221 }
1222
1223 pub async fn close(&self) {
1224 if self.shared {
1225 return;
1226 }
1227 if let Some(b) = self.cell.get() {
1228 let _ = b.close(None).await;
1229 }
1230 }
1231}