Skip to main content

ferridriver_test/
runner.rs

1//! Test runner orchestrator: overlaps browser launch with test dispatch,
2//! handles retries with flaky detection.
3
4use 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/// Top-level test runner.
24pub struct TestRunner {
25  config: Arc<TestConfig>,
26  hooks: TestHooks,
27  reporters: ReporterSet,
28  overrides: CliOverrides,
29  /// Shared browser for watch mode (persists across runs).
30  shared_browser: Option<Arc<Browser>>,
31}
32
33impl TestRunner {
34  /// Build a runner with no programmatic suite hooks. For runners that need
35  /// `before_all` / `after_all` closures, use [`TestRunner::with_hooks`].
36  pub fn new(config: TestConfig, overrides: CliOverrides) -> Self {
37    Self::with_hooks(config, TestHooks::default(), overrides)
38  }
39
40  /// Build a runner with programmatic suite hooks supplied at construction.
41  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  /// Append an additional reporter after construction (e.g., NAPI ResultCollector).
59  pub fn add_reporter(&mut self, reporter: Box<dyn crate::reporter::Reporter>) {
60    self.reporters.add(reporter);
61  }
62
63  /// Run the full test plan. Returns exit code (0 = all passed).
64  ///
65  /// When `config.projects` is non-empty, topologically sorts projects by
66  /// dependencies and runs each with a merged config. Otherwise runs the
67  /// plan directly (single-project path).
68  ///
69  /// Convenience wrapper: creates an `EventBus`, subscribes a `ReporterDriver`,
70  /// and delegates to `execute()`. For real-time external observation (TUI, WebSocket),
71  /// use `execute()` directly with a custom bus.
72  pub async fn run(&mut self, plan: TestPlan) -> i32 {
73    let global_timeout = self.config.global_timeout;
74    let inner = async move {
75      // ── Multi-project path ──
76      if !self.config.projects.is_empty() {
77        return Box::pin(self.run_projects(plan)).await;
78      }
79
80      // ── Single-project path ──
81      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      // Explicitly close senders so the driver's recv() returns None.
92      // Cannot rely on Drop — tokio::spawn defers task deallocation,
93      // keeping Arc<EventBusInner> alive after JoinHandle::await returns.
94      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  /// Run multiple projects in dependency order.
121  ///
122  /// Each project creates a merged config and runs the full execute pipeline
123  /// with its own browser instance. Results are aggregated — if any project
124  /// fails, the overall exit code is non-zero.
125  ///
126  /// Follows Playwright's project semantics:
127  /// - Projects are topologically sorted by `dependencies`
128  /// - A project only runs after all its dependencies have passed
129  /// - `teardown` projects run after the project and all its dependents complete
130  /// - If a dependency fails, dependent projects are skipped
131  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    // Resolve `--project NAME` filter into the index set the runner
143    // will execute. When non-empty, also pull in transitive deps
144    // (unless `--no-deps`) and any teardown projects referenced by
145    // the kept set.
146    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      // Walk dependencies until fixpoint (unless --no-deps).
158      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      // Always pull in declared teardowns of kept projects.
171      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    // `--teardown NAME` overrides any project-declared teardown by
184    // forcing it onto the run regardless of explicit project filter.
185    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    // Track completed projects for teardown scheduling.
201    let mut completed_projects: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
202    // Collect teardown projects to run after all dependents finish.
203    let mut pending_teardowns: Vec<usize> = Vec::new();
204
205    for &idx in &sorted {
206      let project = &projects[idx];
207
208      // Skip if any dependency failed.
209      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      // Check if this is a teardown-only project (referenced by another project's `teardown` field).
221      // Teardown projects are deferred until after their parent and all dependents complete.
222      let is_teardown = projects.iter().any(|p| p.teardown.as_deref() == Some(&project.name));
223      if is_teardown && !completed_projects.contains(&project.name) {
224        // Haven't been explicitly scheduled yet — defer.
225        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      // Run any teardown projects whose parent just completed.
239      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          // Remove from pending if it was deferred.
251          pending_teardowns.retain(|&i| i != td_idx);
252          if td_exit != 0 {
253            exit_code = 1;
254          }
255        }
256      }
257    }
258
259    // Run any remaining deferred teardown projects.
260    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    // Honour `--teardown NAME` — runs once after every selected
277    // project has finished (success or failure), even if no project
278    // declared it as their teardown.
279    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  /// Run a single project with merged config.
298  async fn run_single_project(&mut self, project: &ProjectConfig, base_plan: &TestPlan) -> i32 {
299    let merged_config = self.config.merge_project(project);
300
301    // Clone and filter the plan for this project.
302    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    // Create a sub-runner with merged config. Reuse our reporters + overrides.
322    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    // Build a temporary runner with the merged config.
332    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  /// Core execution engine. Emits events on the provided `EventBus`.
351  ///
352  /// Takes `&self` — no reporter ownership, no mutable state. The caller
353  /// controls who subscribes to the bus (reporters, TUI, external consumers).
354  ///
355  /// The bus is consumed by value and dropped when execution completes,
356  /// closing all subscriber channels and signaling consumers to finalize.
357  #[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    // ── Filtering ──
360    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    // Apply grep: CLI overrides take precedence, then config-level grep.
370    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    // ── Forbid-only check ──
387    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    // ── Only filtering: if any test/suite has Only, keep only those ──
395    crate::discovery::filter_by_only(&mut plan);
396
397    // ── Last-failed rerun filter ──
398    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    // ── preserve_output: "never" — wipe output_dir at run start ──
404    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    // Never launch more workers than tests — extra workers launch browsers for nothing.
431    let num_workers = (self.config.workers as usize).min(total_tests).max(1) as u32;
432
433    // ── Validate fixture DAG ──
434    {
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    // ── Web server lifecycle ──
443    // Follows Playwright's pattern: start servers, set FERRIDRIVER_BASE_URL env var.
444    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              // SAFETY: set_var is called before worker threads are spawned,
450              // so no concurrent reads can race.
451              #[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    // Compose `metadata` with optional git info per `captureGitInfo`.
470    // Cloned once here so each downstream emit sees the same JSON.
471    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    // ── Global setup ──
496    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    // ── Collect tests, apply repeatEach ──
517    let repeat_each = self.config.repeat_each.max(1);
518    let total_executions = total_tests * repeat_each as usize;
519
520    // ── Dispatcher — enqueue suites with hooks + mode context ──
521    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    // ── Spawn workers with lazy browser launch ──
586    // Each worker holds a `BrowserHandle` that launches the browser on first
587    // fixture access. Tests that never resolve `browser`/`context`/`page`
588    // (config-only tests, request-only tests) skip the launch entirely —
589    // critical in CI where Chromium's first-launch can exceed 30s.
590    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    // ── Collect results with retry re-dispatch ──
617    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 // fail_fast = stop after first failure
622    } else {
623      self.config.max_failures as usize // 0 = unlimited
624    };
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        // Track failures for max_failures / fail_fast.
651        if matches!(result.outcome.status, TestStatus::Failed | TestStatus::TimedOut) {
652          failure_count += 1;
653        }
654      }
655
656      // Stop early if max_failures reached. Use `stop()` (hard cancel)
657      // rather than `close()` so workers drop the buffered queue instead
658      // of draining it.
659      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    // ── Global teardown (always runs, even if tests failed) ──
679    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    // ── Final stats with flaky detection ──
691    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    // ── preserve_output: "failures-only" — delete output dirs for passing tests ──
709    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    // ── Web server teardown ──
722    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  /// Run in watch mode: re-run tests on file changes with interactive keyboard controls.
753  ///
754  /// Launches a browser once and reuses it across all runs. Watches the project
755  /// directory for file changes and dispatches re-runs based on change type.
756  ///
757  /// # Arguments
758  ///
759  /// * `plan_factory` — Closure that generates a `TestPlan`. Receives an optional slice
760  ///   of changed file paths — when `Some`, the factory should only re-process those files
761  ///   (e.g., re-parse only changed `.feature` files). When `None`, generate the full plan.
762  /// * `watch_root` — Root directory to watch for file changes.
763  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    // Launch browser once — reuse across all watch cycles.
770    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    // Start file watcher — uses test_match globs for classification, test_ignore for filtering.
781    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    // Try TUI (requires TTY). Falls back to non-interactive for CI/pipes.
790    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        // Non-TTY fallback: file changes only, no keyboard, normal terminal output.
801        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    // Cleanup.
807    self.shared_browser = None;
808    let _ = browser.close(None).await;
809
810    0
811  }
812
813  /// Execute a plan while draining TUI messages in real-time.
814  ///
815  /// Creates a fresh `EventBus` + `ReporterDriver` per run cycle. The driver
816  /// runs in a spawned task; `execute()` and `tui.drain_while_running()` run
817  /// concurrently via `tokio::join!`, so the TUI renders events as they arrive.
818  /// Execute a plan while draining TUI messages in real-time.
819  /// Returns true if the user cancelled (q/Ctrl+C during run).
820  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    // Execute tests and drain TUI concurrently via select!.
830    // If the user presses q/Ctrl+C, drain returns Cancelled and
831    // select! drops the execute future (cancelling it).
832    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  /// TUI watch loop: ratatui inline viewport with status bar + key controls.
851  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    // Replace ALL reporters with TUI reporter + rerun.
866    // Persist across watch cycles via run_with_tui_drain's take/restore.
867    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    // Initial run — TUI drains messages in real-time.
878    let plan = plan_factory(None);
879    if self.run_with_tui_drain(plan, tui).await {
880      return; // User cancelled during initial run.
881    }
882    tui.set_status(crate::tui::WatchStatus::Idle);
883
884    // Watch loop — TUI handles both key input and message display.
885    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          // Apply active filter to file-change re-runs.
897          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              // Apply active filter on top of failed filter.
923              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  /// Non-interactive watch: file changes only, no keyboard, normal terminal output.
954  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    // Initial run.
959    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
987/// Classify file changes into run-all vs specific changed files.
988fn 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
1005/// Build a test plan, optionally filtered to changed files.
1006fn 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  // Filter plan to changed files if applicable.
1015  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
1032/// Topologically sort projects by `dependencies`. Returns indices in execution order.
1033///
1034/// Uses Kahn's algorithm. Returns `Err` if there's a cycle or a missing dependency.
1035fn 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  // Build adjacency list + in-degree.
1039  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  // Kahn's algorithm.
1057  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
1085/// Filter a test plan for a specific project's scope.
1086///
1087/// Applies project-level test_match, test_dir, grep, grep_invert, and tag filters.
1088fn filter_plan_for_project(plan: &mut TestPlan, config: &TestConfig, project: &ProjectConfig) {
1089  // Filter by test_dir: only keep suites whose file starts with test_dir.
1090  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  // Apply project-level grep filter (already merged into config.config_grep).
1095  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  // Apply project-level tag filter.
1103  if let Some(ref tags) = project.tag {
1104    for tag in tags {
1105      crate::discovery::filter_by_tag(plan, tag);
1106    }
1107  }
1108
1109  // Recount after filtering.
1110  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  // BrowserConfig is already normalized (browser↔backend consistent).
1116  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  // Proxy launch args.
1131  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  // Ignore HTTPS errors launch arg.
1138  if browser_config.use_options.ignore_https_errors {
1139    args.push("--ignore-certificate-errors".to_string());
1140  }
1141
1142  // Force headless under CI even if the config left the default
1143  // (`false`) in place. Headed Chrome / Firefox on a runner with no
1144  // DISPLAY hangs the launch handshake past the per-command timeout.
1145  // Matches Playwright's `process.env.CI` handling in
1146  // `packages/playwright/src/index.ts` (the `headless` option fixture
1147  // defaults to `!process.env.PWDEBUG`).
1148  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
1168/// Launch a browser using the runner's internal `LaunchPlan`. Wraps
1169/// `BrowserState::with_plan` + `Browser::from_state` so callers don't
1170/// need to repeat the handshake-await dance.
1171pub(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
1177/// Lazy-launch handle for a worker's browser. The browser is launched
1178/// on first `get()` call and cached. Workers that never access the
1179/// browser (e.g. config-only tests) skip the launch entirely — under
1180/// CI conditions where Chromium first-launch can take >30s, this
1181/// keeps non-browser tests inside the per-test deadline.
1182pub 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  /// Wrap a pre-launched browser (watch-mode shared) — `close()` is a
1198  /// no-op so the shared browser survives across runs.
1199  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}