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/// Aggregate outcome of one `execute()` pass. The multi-project orchestrator
24/// sums these across concurrently-run projects to emit a single `RunFinished`.
25#[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
35/// Top-level test runner.
36pub struct TestRunner {
37  config: Arc<TestConfig>,
38  hooks: TestHooks,
39  reporters: ReporterSet,
40  overrides: CliOverrides,
41  /// Shared browser for watch mode (persists across runs).
42  shared_browser: Option<Arc<Browser>>,
43  /// When set, `execute()` does not emit `RunStarted` / `RunFinished`. The
44  /// multi-project orchestrator turns this on for every per-project run so a
45  /// single aggregate run boundary is emitted once around all projects,
46  /// rather than one pair per project (which would reset terminal counters
47  /// and finalize reporters mid-run).
48  suppress_run_boundary: bool,
49}
50
51impl TestRunner {
52  /// Build a runner with no programmatic suite hooks. For runners that need
53  /// `before_all` / `after_all` closures, use [`TestRunner::with_hooks`].
54  pub fn new(config: TestConfig, overrides: CliOverrides) -> Self {
55    Self::with_hooks(config, TestHooks::default(), overrides)
56  }
57
58  /// Build a runner with programmatic suite hooks supplied at construction.
59  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  /// Append an additional reporter after construction (e.g., NAPI ResultCollector).
78  pub fn add_reporter(&mut self, reporter: Box<dyn crate::reporter::Reporter>) {
79    self.reporters.add(reporter);
80  }
81
82  /// Run the full test plan. Returns exit code (0 = all passed).
83  ///
84  /// When `config.projects` is non-empty, topologically sorts projects by
85  /// dependencies and runs each with a merged config. Otherwise runs the
86  /// plan directly (single-project path).
87  ///
88  /// Convenience wrapper: creates an `EventBus`, subscribes a `ReporterDriver`,
89  /// and delegates to `execute()`. For real-time external observation (TUI, WebSocket),
90  /// use `execute()` directly with a custom bus.
91  pub async fn run(&mut self, plan: TestPlan) -> i32 {
92    let global_timeout = self.config.global_timeout;
93    let inner = async move {
94      // ── Multi-project path ──
95      if !self.config.projects.is_empty() {
96        return Box::pin(self.run_projects(plan)).await;
97      }
98
99      // ── Single-project path ──
100      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      // Explicitly close senders so the driver's recv() returns None.
114      // Cannot rely on Drop — tokio::spawn defers task deallocation,
115      // keeping Arc<EventBusInner> alive after JoinHandle::await returns.
116      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  /// Run multiple projects in dependency order.
145  ///
146  /// Each project creates a merged config and runs the full execute pipeline
147  /// with its own browser instance. Results are aggregated — if any project
148  /// fails, the overall exit code is non-zero.
149  ///
150  /// Follows Playwright's project semantics:
151  /// - Projects are topologically sorted by `dependencies`
152  /// - A project only runs after all its dependencies have passed
153  /// - `teardown` projects run after the project and all its dependents complete
154  /// - If a dependency fails, dependent projects are skipped
155  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    // Resolve `--project NAME` filter into the index set the runner
167    // will execute. When non-empty, also pull in transitive deps
168    // (unless `--no-deps`) and any teardown projects referenced by
169    // the kept set.
170    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      // Walk dependencies until fixpoint (unless --no-deps).
182      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      // Always pull in declared teardowns of kept projects.
195      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    // `--teardown NAME` overrides any project-declared teardown by
208    // forcing it onto the run regardless of explicit project filter.
209    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    // Append CLI-supplied teardown so the scheduler tracks it like any other
223    // project. It runs after every other selected project reaches a terminal
224    // state, regardless of pass/fail — modelled below as a teardown with all
225    // remaining projects as prerequisites.
226    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    // Pre-compute each scheduled project's prerequisites and whether it is a
234    // teardown. The ready-set scheduler spawns a project once all its
235    // prerequisites have reached a terminal state.
236    //
237    // - A normal project requires every `dependencies` entry to have PASSED.
238    //   If any dependency failed/was skipped, the project is itself skipped.
239    // - A teardown project (referenced by another project's `teardown` field)
240    //   requires only that its declaring parent reached a terminal state — it
241    //   runs even if the parent failed (Playwright teardown semantics).
242    // - The CLI-supplied teardown requires every other selected project to be
243    //   terminal.
244    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    // Prerequisites by index: (prereq_idx, must_pass).
256    let prereqs: FxHashMap<usize, Vec<(usize, bool)>> = scheduled
257      .iter()
258      .map(|&idx| {
259        let mut reqs: Vec<(usize, bool)> = Vec::new();
260        // Normal dependencies must pass.
261        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        // Teardown parent must merely be terminal.
269        if let Some(&parent_idx) = teardown_parent.get(&idx) {
270          if scheduled.contains(&parent_idx) {
271            reqs.push((parent_idx, false));
272          }
273        }
274        // CLI-supplied teardown waits on every other scheduled project.
275        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    // ── Hoist web servers out of per-project execute ──
287    // `merge_project` copies the top-level `web_server` list onto every
288    // project; starting/stopping the same servers per project would bind the
289    // same ports concurrently. Start them once here and clear the per-project
290    // copies so each project's `execute()` skips its web-server lifecycle.
291    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              // SAFETY: set once here before any worker threads spawn.
299              #[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    // Build each project's merged config + filtered plan up front so we can
316    // both report an accurate aggregate total and reuse them when spawning.
317    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    // ── Shared reporter driver + single aggregate run boundary ──
331    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    // `workers` is the global concurrency budget; never launch more workers
343    // than tests across all projects in flight.
344    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    // ── Ready-set scheduler ──
355    // `max_parallel_projects == 0` means unbounded (cap at the number of
356    // scheduled projects). Spawn every dependency-ready project up to the cap,
357    // drive completions via a JoinSet, and re-evaluate readiness on each
358    // completion. Dependency ordering, teardown ordering, and dep-failure
359    // skipping are all preserved by the prerequisite model above.
360    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      // Launch every ready project up to the parallelism cap. Skips (no tests
377      // or dependency failed) resolve immediately and may unblock others, so
378      // keep scanning until no further progress is possible this round.
379      while in_flight < cap {
380        // Find a not-yet-started project whose prerequisites are all terminal.
381        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        // Skip a normal project whose passing-required prerequisites did not
391        // pass (dependency failure). Teardowns are never skipped this way.
392        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      // Nothing running and nothing launchable — done (or a cycle the topo
450      // sort already rejected, so `remaining` is unreachable prereqs).
451      if in_flight == 0 {
452        break;
453      }
454
455      // Await the next completion, then loop to launch newly-ready projects.
456      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    // ── Single aggregate RunFinished + reporter teardown ──
484    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  /// Core execution engine. Emits events on the provided `EventBus`.
509  ///
510  /// Takes `&self` — no reporter ownership, no mutable state. The caller
511  /// controls who subscribes to the bus (reporters, TUI, external consumers).
512  ///
513  /// The bus is consumed by value and dropped when execution completes,
514  /// closing all subscriber channels and signaling consumers to finalize.
515  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  /// Core execution engine, returning the full per-run tally. `execute()` is
520  /// the thin `i32` wrapper; the multi-project orchestrator uses the summary
521  /// to aggregate counts across concurrently-run projects.
522  #[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    // ── Filtering ──
525    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    // Apply grep: CLI overrides take precedence, then config-level grep.
535    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    // ── Forbid-only check ──
552    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    // ── Only filtering: if any test/suite has Only, keep only those ──
563    crate::discovery::filter_by_only(&mut plan);
564
565    // ── Last-failed rerun filter ──
566    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    // ── preserve_output: "never" — wipe output_dir at run start ──
572    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    // Never launch more workers than tests — extra workers launch browsers for nothing.
602    let num_workers = (self.config.workers as usize).min(total_tests).max(1) as u32;
603
604    // Custom `#[fixture]` definitions, collected once and seeded into every
605    // worker's fixture pool so tests can resolve them via `ctx.get`.
606    let custom_fixtures = crate::discovery::collect_rust_fixtures();
607
608    // ── Validate fixture DAG ──
609    {
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    // ── Web server lifecycle ──
626    // Follows Playwright's pattern: start servers, set FERRIDRIVER_BASE_URL env var.
627    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              // SAFETY: set_var is called before worker threads are spawned,
633              // so no concurrent reads can race.
634              #[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    // Compose `metadata` with optional git info per `captureGitInfo`.
658    // Cloned once here so each downstream emit sees the same JSON.
659    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    // Boundary events (`RunStarted` / `RunFinished`) are emitted once per
675    // `execute()` for the single-project path, but suppressed when the
676    // multi-project orchestrator drives many `execute()` calls into one
677    // shared bus — it emits a single aggregate boundary itself.
678    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    // ── Global setup ──
690    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    // ── Collect tests, apply repeatEach ──
716    let repeat_each = self.config.repeat_each.max(1);
717    let total_executions = total_tests * repeat_each as usize;
718
719    // ── Dispatcher — enqueue suites with hooks + mode context ──
720    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    // ── Spawn workers with lazy browser launch ──
785    // Each worker holds a `BrowserHandle` that launches the browser on first
786    // fixture access. Tests that never resolve `browser`/`context`/`page`
787    // (config-only tests, request-only tests) skip the launch entirely —
788    // critical in CI where Chromium's first-launch can exceed 30s.
789    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    // ── Collect results with retry re-dispatch ──
817    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 // fail_fast = stop after first failure
822    } else {
823      self.config.max_failures as usize // 0 = unlimited
824    };
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        // Track failures for max_failures / fail_fast.
851        if matches!(result.outcome.status, TestStatus::Failed | TestStatus::TimedOut) {
852          failure_count += 1;
853        }
854      }
855
856      // Stop early if max_failures reached. Use `stop()` (hard cancel)
857      // rather than `close()` so workers drop the buffered queue instead
858      // of draining it.
859      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    // ── Global teardown (always runs, even if tests failed) ──
879    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    // ── Final stats with flaky detection ──
891    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    // ── preserve_output: "failures-only" — delete output dirs for passing tests ──
909    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    // ── Web server teardown ──
922    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  /// Run in watch mode: re-run tests on file changes with interactive keyboard controls.
960  ///
961  /// Launches a browser once and reuses it across all runs. Watches the project
962  /// directory for file changes and dispatches re-runs based on change type.
963  ///
964  /// # Arguments
965  ///
966  /// * `plan_factory` — Closure that generates a `TestPlan`. Receives an optional slice
967  ///   of changed file paths — when `Some`, the factory should only re-process those files
968  ///   (e.g., re-parse only changed `.feature` files). When `None`, generate the full plan.
969  /// * `watch_root` — Root directory to watch for file changes.
970  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    // Launch browser once — reuse across all watch cycles.
977    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    // Start file watcher — uses test_match globs for classification, test_ignore for filtering.
988    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    // Try TUI (requires TTY). Falls back to non-interactive for CI/pipes.
997    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        // Non-TTY fallback: file changes only, no keyboard, normal terminal output.
1008        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    // Cleanup.
1014    self.shared_browser = None;
1015    let _ = browser.close(None).await;
1016
1017    0
1018  }
1019
1020  /// Execute a plan while draining TUI messages in real-time.
1021  ///
1022  /// Creates a fresh `EventBus` + `ReporterDriver` per run cycle. The driver
1023  /// runs in a spawned task; `execute()` and `tui.drain_while_running()` run
1024  /// concurrently via `tokio::join!`, so the TUI renders events as they arrive.
1025  /// Execute a plan while draining TUI messages in real-time.
1026  /// Returns true if the user cancelled (q/Ctrl+C during run).
1027  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    // Execute tests and drain TUI concurrently via select!.
1037    // If the user presses q/Ctrl+C, drain returns Cancelled and
1038    // select! drops the execute future (cancelling it).
1039    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  /// TUI watch loop: ratatui inline viewport with status bar + key controls.
1058  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    // Replace ALL reporters with TUI reporter + rerun.
1073    // Persist across watch cycles via run_with_tui_drain's take/restore.
1074    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    // Initial run — TUI drains messages in real-time.
1085    let plan = plan_factory(None);
1086    if self.run_with_tui_drain(plan, tui).await {
1087      return; // User cancelled during initial run.
1088    }
1089    tui.set_status(crate::tui::WatchStatus::Idle);
1090
1091    // Watch loop — TUI handles both key input and message display.
1092    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          // Apply active filter to file-change re-runs.
1104          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              // Apply active filter on top of failed filter.
1130              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  /// Non-interactive watch: file changes only, no keyboard, normal terminal output.
1161  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    // Initial run.
1166    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
1194/// Classify file changes into run-all vs specific changed files.
1195fn 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
1212/// Build a test plan, optionally filtered to changed files.
1213fn 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  // Filter plan to changed files if applicable.
1222  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
1239/// Topologically sort projects by `dependencies`. Returns indices in execution order.
1240///
1241/// Uses Kahn's algorithm. Returns `Err` if there's a cycle or a missing dependency.
1242fn 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  // Build adjacency list + in-degree.
1246  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  // Kahn's algorithm.
1264  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
1292/// Filter a test plan for a specific project's scope.
1293///
1294/// Applies project-level test_match, test_dir, grep, grep_invert, and tag filters.
1295fn filter_plan_for_project(plan: &mut TestPlan, config: &TestConfig, project: &ProjectConfig) {
1296  // Filter by test_dir: only keep suites whose file starts with test_dir.
1297  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  // Apply project-level grep filter (already merged into config.config_grep).
1302  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  // Apply project-level tag filter.
1310  if let Some(ref tags) = project.tag {
1311    for tag in tags {
1312      crate::discovery::filter_by_tag(plan, tag);
1313    }
1314  }
1315
1316  // Recount after filtering.
1317  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  // BrowserConfig is already normalized (browser↔backend consistent).
1323  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  // Proxy launch args.
1338  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  // Ignore HTTPS errors launch arg.
1345  if browser_config.use_options.ignore_https_errors {
1346    args.push("--ignore-certificate-errors".to_string());
1347  }
1348
1349  // Force headless under CI even if the config left the default
1350  // (`false`) in place. Headed Chrome / Firefox on a runner with no
1351  // DISPLAY hangs the launch handshake past the per-command timeout.
1352  // Matches Playwright's `process.env.CI` handling in
1353  // `packages/playwright/src/index.ts` (the `headless` option fixture
1354  // defaults to `!process.env.PWDEBUG`).
1355  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
1375/// Launch a browser using the runner's internal `LaunchPlan`. Wraps
1376/// `BrowserState::with_plan` + `Browser::from_state` so callers don't
1377/// need to repeat the handshake-await dance.
1378pub(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
1384/// Lazy-launch handle for a worker's browser. The browser is launched
1385/// on first `get()` call and cached. Workers that never access the
1386/// browser (e.g. config-only tests) skip the launch entirely — under
1387/// CI conditions where Chromium first-launch can take >30s, this
1388/// keeps non-browser tests inside the per-test deadline.
1389pub 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  /// Wrap a pre-launched browser (watch-mode shared) — `close()` is a
1405  /// no-op so the shared browser survives across runs.
1406  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}