Skip to main content

browser_test/
runner.rs

1use std::{
2    fmt::{self, Display},
3    num::NonZeroUsize,
4    sync::Arc,
5};
6
7use chrome_for_testing_manager::{
8    Channel, Chromedriver, ChromedriverRunConfig, DriverOutputListener, PortRequest, VersionRequest,
9};
10use rootcause::Report;
11use rootcause::prelude::ResultExt;
12use thirtyfour::{ChromeCapabilities, error::WebDriverResult};
13
14use crate::driver_output::{
15    DriverOutputCapture, DriverOutputConfig, attach_browser_driver_output,
16    attach_browser_driver_output_to_result, browser_driver_output_config_from_env,
17};
18use crate::env::env_flag_enabled;
19use crate::execution::{ChromeCapabilitiesSetup, browser_test_executions};
20use crate::pause::{self, PauseConfig, PauseDecision};
21use crate::scheduler::{
22    BrowserTestFailurePolicy, BrowserTestParallelism, run_test_executions_parallel,
23    run_test_executions_sequential,
24};
25use crate::{BrowserTestError, BrowserTests, BrowserTimeouts, ElementQueryWaitConfig};
26
27pub(crate) const DEFAULT_VISIBLE_ENV: &str = "BROWSER_TEST_VISIBLE";
28
29/// Chrome visibility mode for [`BrowserTestRunner`].
30#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
31pub enum BrowserTestVisibility {
32    /// Run Chrome headlessly.
33    #[default]
34    Headless,
35
36    /// Run Chrome visibly.
37    Visible,
38
39    /// Read visibility from the given environment variable.
40    ///
41    /// The variable is considered enabled unless it is unset, empty, `0`, `false`, `no`, or `off`.
42    FromEnvVar(String),
43
44    /// Read visibility from `BROWSER_TEST_VISIBLE`.
45    FromEnv,
46}
47
48impl BrowserTestVisibility {
49    /// Build a headless visibility config.
50    #[must_use]
51    pub const fn headless() -> Self {
52        Self::Headless
53    }
54
55    /// Build a visible visibility config.
56    #[must_use]
57    pub const fn visible() -> Self {
58        Self::Visible
59    }
60
61    /// Build a visibility config from `BROWSER_TEST_VISIBLE`.
62    #[must_use]
63    pub const fn from_env() -> Self {
64        Self::FromEnv
65    }
66
67    /// Build a visibility config from an environment variable.
68    #[must_use]
69    pub fn from_env_var(env_var: impl Into<String>) -> Self {
70        Self::FromEnvVar(env_var.into())
71    }
72
73    fn is_visible(&self) -> bool {
74        match self {
75            Self::Headless => false,
76            Self::Visible => true,
77            Self::FromEnvVar(env_var) => env_flag_enabled(env_var),
78            Self::FromEnv => env_flag_enabled(DEFAULT_VISIBLE_ENV),
79        }
80    }
81}
82
83/// Runs [`crate::BrowserTest`] implementations through Chrome for Testing.
84#[derive(Clone)]
85pub struct BrowserTestRunner {
86    channel: Channel,
87    visible: bool,
88    pause: Option<PauseConfig>,
89    hint: Option<String>,
90    parallelism: BrowserTestParallelism,
91    failure_policy: BrowserTestFailurePolicy,
92    webdriver_timeouts: Option<BrowserTimeouts>,
93    element_query_wait: Option<ElementQueryWaitConfig>,
94    chrome_capabilities_setups: Vec<Arc<ChromeCapabilitiesSetup>>,
95    browser_driver_output: BrowserDriverOutputSetting,
96}
97
98#[derive(Debug, Clone)]
99enum BrowserDriverOutputSetting {
100    Disabled,
101    TailLines(NonZeroUsize),
102}
103
104impl Default for BrowserTestRunner {
105    fn default() -> Self {
106        Self {
107            channel: Channel::Stable,
108            visible: false,
109            pause: None,
110            hint: None,
111            parallelism: BrowserTestParallelism::Sequential,
112            failure_policy: BrowserTestFailurePolicy::FailFast,
113            webdriver_timeouts: None,
114            element_query_wait: None,
115            chrome_capabilities_setups: Vec::new(),
116            browser_driver_output: BrowserDriverOutputSetting::Disabled,
117        }
118    }
119}
120
121impl fmt::Debug for BrowserTestRunner {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        f.debug_struct("BrowserTestRunner")
124            .field("channel", &self.channel)
125            .field("visible", &self.visible)
126            .field("pause", &self.pause)
127            .field("hint", &self.hint)
128            .field("parallelism", &self.parallelism)
129            .field("failure_policy", &self.failure_policy)
130            .field("webdriver_timeouts", &self.webdriver_timeouts)
131            .field("element_query_wait", &self.element_query_wait)
132            .field(
133                "chrome_capabilities_setup_count",
134                &self.chrome_capabilities_setups.len(),
135            )
136            .field("browser_driver_output", &self.browser_driver_output)
137            .finish()
138    }
139}
140
141impl BrowserTestRunner {
142    /// Create a runner using stable Chrome in headless mode.
143    #[must_use]
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    /// Select the Chrome release channel.
149    #[must_use]
150    pub fn with_channel(mut self, channel: Channel) -> Self {
151        self.channel = channel;
152        self
153    }
154
155    /// Configure Chrome visibility.
156    #[must_use]
157    pub fn with_visibility(mut self, visibility: impl Into<BrowserTestVisibility>) -> Self {
158        self.visible = visibility.into().is_visible();
159        self
160    }
161
162    /// Pause before starting webdriver when the config is enabled.
163    ///
164    /// If the pause is aborted, [`Self::run`] returns successfully without starting webdriver or
165    /// running any tests. If stdin reaches EOF while waiting for a pause response, [`Self::run`]
166    /// returns an error instead.
167    #[must_use]
168    pub fn with_pause(mut self, pause: impl Into<PauseConfig>) -> Self {
169        self.pause = Some(pause.into());
170        self
171    }
172
173    /// Set extra context shown when a manual pause prompt is enabled.
174    #[must_use]
175    pub fn with_hint(mut self, hint: impl Display) -> Self {
176        self.hint = Some(hint.to_string());
177        self
178    }
179
180    /// Add custom Chrome capability setup applied to every `WebDriver` session.
181    ///
182    /// The runner applies its own visible/headless configuration first, then applies custom setup
183    /// functions in the order they were added. The setup function must be thread-safe because
184    /// parallel browser tests can create multiple sessions at the same time.
185    #[must_use]
186    pub fn with_chrome_capabilities(
187        mut self,
188        setup: impl Fn(&mut ChromeCapabilities) -> WebDriverResult<()> + Send + Sync + 'static,
189    ) -> Self {
190        self.chrome_capabilities_setups.push(Arc::new(setup));
191        self
192    }
193
194    /// Set timeouts applied to every session before running tests.
195    ///
196    /// Individual [`crate::BrowserTest`] implementations can override this by returning `Some` from
197    /// [`crate::BrowserTest::timeouts`].
198    #[must_use]
199    pub fn with_timeouts(mut self, timeouts: BrowserTimeouts) -> Self {
200        self.webdriver_timeouts = Some(timeouts);
201        self
202    }
203
204    /// Deprecated name for [`Self::with_timeouts`].
205    ///
206    /// Use [`Self::with_timeouts`] in new code.
207    #[deprecated(since = "0.1.0", note = "use with_timeouts instead")]
208    #[must_use]
209    pub fn with_webdriver_timeouts(self, timeouts: BrowserTimeouts) -> Self {
210        self.with_timeouts(timeouts)
211    }
212
213    /// Set the element query wait applied to every session before running tests.
214    ///
215    /// Individual [`crate::BrowserTest`] implementations can override this by returning `Some` from
216    /// [`crate::BrowserTest::element_query_wait`].
217    #[must_use]
218    pub const fn with_element_query_wait(mut self, wait: ElementQueryWaitConfig) -> Self {
219        self.element_query_wait = Some(wait);
220        self
221    }
222
223    /// Configure how browser tests are scheduled.
224    #[must_use]
225    pub fn with_test_parallelism(mut self, parallelism: impl Into<BrowserTestParallelism>) -> Self {
226        self.parallelism = parallelism.into();
227        self
228    }
229
230    /// Configure how browser test failures affect the rest of the run.
231    #[must_use]
232    pub fn with_failure_policy(
233        mut self,
234        failure_policy: impl Into<BrowserTestFailurePolicy>,
235    ) -> Self {
236        self.failure_policy = failure_policy.into();
237        self
238    }
239
240    /// Capture recent browser-driver output for failure diagnostics.
241    ///
242    /// This stores capture configuration and creates a fresh capture buffer for each
243    /// [`Self::run`] call.
244    #[must_use]
245    pub fn with_driver_output(mut self, config: impl Into<DriverOutputConfig>) -> Self {
246        self.browser_driver_output = browser_driver_output_setting(config.into());
247        self
248    }
249
250    /// Deprecated name for [`Self::with_driver_output`].
251    ///
252    /// Use [`Self::with_driver_output`] in new code.
253    #[deprecated(since = "0.1.0", note = "use with_driver_output instead")]
254    #[must_use]
255    pub fn with_browser_driver_output(self, config: impl Into<DriverOutputConfig>) -> Self {
256        self.with_driver_output(config)
257    }
258
259    /// Run every test with a fresh `WebDriver` session.
260    ///
261    /// The shared chromedriver process is always terminated, even when a test returns an error or
262    /// panics. Test panics are converted into [`BrowserTestError::Panic`] reports instead of being
263    /// resumed.
264    ///
265    /// Tests run sequentially and stop on the first failure by default. Use
266    /// [`Self::with_test_parallelism`] to run multiple fresh `WebDriver` sessions at once. Use
267    /// [`Self::with_failure_policy`] to execute every test and return all failures as child reports
268    /// on one aggregate report.
269    ///
270    /// Non-empty runs require a multi-threaded Tokio runtime because
271    /// [`Chromedriver::run`] requires one. Use `#[tokio::test(flavor = "multi_thread")]` for
272    /// browser tests.
273    ///
274    /// # Parameters
275    ///
276    /// `context`: Given to each test.
277    ///
278    /// # Errors
279    ///
280    /// Returns an error if chromedriver cannot be started or terminated, if a session cannot be
281    /// created, or if any test fails.
282    pub async fn run<Context, TestError>(
283        &self,
284        context: &Context,
285        tests: BrowserTests<Context, TestError>,
286    ) -> Result<(), Report<BrowserTestError>>
287    where
288        Context: Sync + ?Sized,
289        TestError: ?Sized + 'static,
290    {
291        if tests.is_empty() {
292            tracing::info!("Skipping browser test run because no tests were provided.");
293            return Ok(());
294        }
295
296        if let Some(pause) = self.pause.clone()
297            && pause::pause_if_requested(pause, self.hint.as_deref()).await? == PauseDecision::Abort
298        {
299            tracing::info!("Browser test run aborted at manual pause.");
300            return Ok(());
301        }
302
303        tracing::info!("Starting webdriver...");
304        let browser_driver_output = self.browser_driver_output_capture_for_run();
305        let output_listener: Option<DriverOutputListener> = browser_driver_output
306            .as_ref()
307            .map(DriverOutputCapture::listener);
308        let chromedriver = match Chromedriver::run(
309            ChromedriverRunConfig::builder()
310                .version(VersionRequest::LatestIn(self.channel.clone()))
311                .port(PortRequest::Any)
312                .output_listener_opt(output_listener)
313                .build(),
314        )
315        .await
316        .context(BrowserTestError::StartWebdriver)
317        {
318            Ok(chromedriver) => chromedriver,
319            Err(mut err) => {
320                attach_browser_driver_output(&mut err, browser_driver_output.as_ref());
321                return Err(err);
322            }
323        };
324
325        let test_result = self.run_tests(&chromedriver, context, tests).await;
326
327        let termination_result = chromedriver
328            .terminate()
329            .await
330            .context(BrowserTestError::TerminateWebdriver);
331
332        if let Err(err) = termination_result {
333            return attach_browser_driver_output_to_result(
334                merge_termination_result(test_result, err),
335                browser_driver_output.as_ref(),
336            );
337        }
338
339        attach_browser_driver_output_to_result(test_result, browser_driver_output.as_ref())
340    }
341
342    /// Runs `tests` while respecting this runner's `parallelism` configuration.
343    async fn run_tests<Context, TestError>(
344        &self,
345        chromedriver: &Chromedriver,
346        context: &Context,
347        tests: BrowserTests<Context, TestError>,
348    ) -> Result<(), Report<BrowserTestError>>
349    where
350        Context: Sync + ?Sized,
351        TestError: ?Sized + 'static,
352    {
353        let max_parallel_tests = self.parallelism.max_parallel_tests();
354        let executions = browser_test_executions(
355            chromedriver,
356            self.visible,
357            self.webdriver_timeouts.as_ref(),
358            self.element_query_wait.as_ref(),
359            &self.chrome_capabilities_setups,
360            context,
361            tests,
362        );
363
364        if max_parallel_tests.get() == 1 {
365            run_test_executions_sequential(self.failure_policy, executions).await
366        } else {
367            run_test_executions_parallel(self.failure_policy, executions, max_parallel_tests).await
368        }
369    }
370
371    fn browser_driver_output_capture_for_run(&self) -> Option<DriverOutputCapture> {
372        match &self.browser_driver_output {
373            BrowserDriverOutputSetting::Disabled => None,
374            BrowserDriverOutputSetting::TailLines(tail_lines) => {
375                Some(DriverOutputCapture::new(*tail_lines))
376            }
377        }
378    }
379}
380
381fn browser_driver_output_setting(config: DriverOutputConfig) -> BrowserDriverOutputSetting {
382    match config {
383        DriverOutputConfig::Disabled => BrowserDriverOutputSetting::Disabled,
384        DriverOutputConfig::TailLines(tail_lines) => NonZeroUsize::new(tail_lines).map_or(
385            BrowserDriverOutputSetting::Disabled,
386            BrowserDriverOutputSetting::TailLines,
387        ),
388        DriverOutputConfig::FromEnv => {
389            browser_driver_output_setting(browser_driver_output_config_from_env())
390        }
391    }
392}
393
394fn merge_termination_result(
395    test_result: Result<(), Report<BrowserTestError>>,
396    termination_error: Report<BrowserTestError>,
397) -> Result<(), Report<BrowserTestError>> {
398    let Err(mut test_error) = test_result else {
399        return Err(termination_error);
400    };
401
402    tracing::error!(
403        "Failed to terminate chromedriver after browser test failure: {termination_error:?}"
404    );
405
406    test_error
407        .children_mut()
408        .push(termination_error.into_dynamic().into_cloneable());
409    Err(test_error)
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::driver_output::DEFAULT_BROWSER_DRIVER_OUTPUT_ENV;
416    use crate::driver_output::DEFAULT_BROWSER_DRIVER_OUTPUT_TAIL_LINES;
417    use crate::driver_output::DEFAULT_BROWSER_DRIVER_OUTPUT_TAIL_LINES_ENV;
418    use crate::test_support::EnvVarGuard;
419    use assertr::prelude::*;
420    use chrome_for_testing_manager::{DriverOutputLine, DriverOutputSource};
421    use std::env;
422    use std::time::Duration;
423    use thirtyfour::ChromiumLikeCapabilities;
424
425    #[test]
426    fn runner_defaults_to_sequential_fail_fast_execution() {
427        let runner = BrowserTestRunner::new();
428
429        assert_that!(runner.parallelism).is_equal_to(BrowserTestParallelism::Sequential);
430        assert_that!(runner.failure_policy).is_equal_to(BrowserTestFailurePolicy::FailFast);
431    }
432
433    #[test]
434    fn runner_parallelism_builders_set_scheduling_mode() {
435        let max_parallel_tests =
436            NonZeroUsize::new(3).expect("literal parallelism should be non-zero");
437
438        let runner = BrowserTestRunner::new()
439            .with_test_parallelism(BrowserTestParallelism::Parallel(max_parallel_tests));
440        assert_that!(runner.parallelism)
441            .is_equal_to(BrowserTestParallelism::Parallel(max_parallel_tests));
442
443        let runner = runner.with_test_parallelism(BrowserTestParallelism::Sequential);
444        assert_that!(runner.parallelism).is_equal_to(BrowserTestParallelism::Sequential);
445    }
446
447    #[test]
448    fn runner_failure_policy_builders_set_failure_mode() {
449        let runner = BrowserTestRunner::new().with_failure_policy(BrowserTestFailurePolicy::RunAll);
450        assert_that!(runner.failure_policy).is_equal_to(BrowserTestFailurePolicy::RunAll);
451
452        let runner = runner.with_failure_policy(BrowserTestFailurePolicy::FailFast);
453        assert_that!(runner.failure_policy).is_equal_to(BrowserTestFailurePolicy::FailFast);
454    }
455
456    #[test]
457    fn runner_visibility_builder_sets_visible_mode() {
458        let runner = BrowserTestRunner::new().with_visibility(BrowserTestVisibility::Visible);
459        assert_that!(runner.visible).is_true();
460
461        let runner = runner.with_visibility(BrowserTestVisibility::Headless);
462        assert_that!(runner.visible).is_false();
463    }
464
465    #[test]
466    fn runner_visibility_builder_reads_default_env() {
467        let env = EnvVarGuard::new(DEFAULT_VISIBLE_ENV);
468        env.set("yes");
469
470        let runner = BrowserTestRunner::new().with_visibility(BrowserTestVisibility::from_env());
471
472        assert_that!(runner.visible).is_true();
473    }
474
475    #[test]
476    fn runner_browser_driver_output_builder_sets_capture() {
477        let runner =
478            BrowserTestRunner::new().with_driver_output(DriverOutputConfig::tail_lines(12));
479
480        let BrowserDriverOutputSetting::TailLines(tail_lines) = runner.browser_driver_output else {
481            panic!("browser driver output tail-line capture should be configured");
482        };
483        assert_that!(tail_lines.get()).is_equal_to(12);
484    }
485
486    #[allow(deprecated)]
487    #[test]
488    fn deprecated_browser_driver_output_builder_sets_capture() {
489        let runner = BrowserTestRunner::new()
490            .with_browser_driver_output(crate::BrowserDriverOutputConfig::new(12));
491
492        let BrowserDriverOutputSetting::TailLines(tail_lines) = runner.browser_driver_output else {
493            panic!("browser driver output tail-line capture should be configured");
494        };
495        assert_that!(tail_lines.get()).is_equal_to(12);
496    }
497
498    #[test]
499    fn runner_browser_driver_output_zero_tail_disables_capture() {
500        let runner = BrowserTestRunner::new().with_driver_output(DriverOutputConfig::tail_lines(0));
501
502        assert_that!(matches!(
503            runner.browser_driver_output,
504            BrowserDriverOutputSetting::Disabled
505        ))
506        .is_true();
507    }
508
509    #[test]
510    fn browser_driver_output_from_env_uses_default_tail_lines() {
511        let env = EnvVarGuard::new(DEFAULT_BROWSER_DRIVER_OUTPUT_ENV);
512        let original_tail = env::var_os(DEFAULT_BROWSER_DRIVER_OUTPUT_TAIL_LINES_ENV);
513        env.set("1");
514        // SAFETY: `env` holds the crate's environment lock for this test.
515        unsafe {
516            env::remove_var(DEFAULT_BROWSER_DRIVER_OUTPUT_TAIL_LINES_ENV);
517        }
518
519        let runner = BrowserTestRunner::new().with_driver_output(DriverOutputConfig::from_env());
520
521        let BrowserDriverOutputSetting::TailLines(tail_lines) = runner.browser_driver_output else {
522            panic!("env browser driver output tail-line capture should be configured");
523        };
524        assert_that!(tail_lines.get()).is_equal_to(DEFAULT_BROWSER_DRIVER_OUTPUT_TAIL_LINES);
525
526        // SAFETY: `env` holds the crate's environment lock for this test.
527        unsafe {
528            match original_tail {
529                Some(value) => env::set_var(DEFAULT_BROWSER_DRIVER_OUTPUT_TAIL_LINES_ENV, value),
530                None => env::remove_var(DEFAULT_BROWSER_DRIVER_OUTPUT_TAIL_LINES_ENV),
531            }
532        }
533    }
534
535    #[test]
536    fn browser_driver_output_tail_lines_creates_fresh_capture_per_run() {
537        let runner = BrowserTestRunner::new().with_driver_output(DriverOutputConfig::tail_lines(1));
538
539        let first = runner
540            .browser_driver_output_capture_for_run()
541            .expect("tail-line capture should be enabled");
542        let second = runner
543            .browser_driver_output_capture_for_run()
544            .expect("tail-line capture should be enabled");
545
546        first.push(DriverOutputLine {
547            source: DriverOutputSource::Stdout,
548            sequence: 0,
549            line: "first run".to_owned(),
550        });
551
552        assert_that!(first.snapshot().total_lines).is_equal_to(1);
553        assert_that!(second.snapshot().total_lines).is_equal_to(0);
554    }
555
556    #[test]
557    fn browser_driver_output_disabled_creates_no_capture_for_run() {
558        let runner = BrowserTestRunner::new().with_driver_output(DriverOutputConfig::disabled());
559
560        assert_that!(runner.browser_driver_output_capture_for_run().is_none()).is_true();
561    }
562
563    #[test]
564    fn runner_chrome_capabilities_builder_adds_setup() {
565        let runner =
566            BrowserTestRunner::new().with_chrome_capabilities(|caps| caps.add_arg("--no-sandbox"));
567
568        assert_that!(runner.chrome_capabilities_setups.len()).is_equal_to(1);
569    }
570
571    #[test]
572    fn runner_webdriver_timeouts_builder_sets_default_timeouts() {
573        let timeouts = BrowserTimeouts::builder()
574            .script_timeout(Duration::from_secs(10))
575            .page_load_timeout(Duration::from_secs(10))
576            .implicit_wait_timeout(Duration::from_secs(0))
577            .build();
578
579        let runner = BrowserTestRunner::new().with_timeouts(timeouts);
580
581        assert_that!(runner.webdriver_timeouts).is_equal_to(Some(timeouts));
582    }
583
584    #[allow(deprecated)]
585    #[test]
586    fn deprecated_webdriver_timeouts_builder_sets_default_timeouts() {
587        let timeouts = BrowserTimeouts::builder()
588            .script_timeout(Duration::from_secs(10))
589            .page_load_timeout(Duration::from_secs(10))
590            .implicit_wait_timeout(Duration::from_secs(0))
591            .build();
592
593        let runner = BrowserTestRunner::new().with_webdriver_timeouts(timeouts);
594
595        assert_that!(runner.webdriver_timeouts).is_equal_to(Some(timeouts));
596    }
597
598    #[test]
599    fn runner_element_query_wait_builder_sets_default_wait() {
600        let wait = ElementQueryWaitConfig::builder()
601            .timeout(Duration::from_secs(10))
602            .interval(Duration::from_millis(500))
603            .build();
604
605        let runner = BrowserTestRunner::new().with_element_query_wait(wait);
606
607        assert_that!(runner.element_query_wait).is_equal_to(Some(wait));
608    }
609
610    #[test]
611    fn runner_with_no_tests_returns_without_starting_webdriver_or_pausing() {
612        let runtime = tokio::runtime::Builder::new_current_thread()
613            .build()
614            .expect("current-thread runtime should build");
615
616        runtime.block_on(async {
617            BrowserTestRunner::new()
618                .with_pause(PauseConfig::enabled(true))
619                .run(&(), BrowserTests::<()>::new())
620                .await
621                .expect("empty test runs should be a no-op");
622        });
623    }
624
625    #[test]
626    fn termination_failure_is_attached_to_existing_test_failure() {
627        let test_result = Err(Report::new(BrowserTestError::RunTest {
628            test_name: "login".to_owned(),
629        }));
630        let termination_error = Report::new(BrowserTestError::TerminateWebdriver);
631
632        let err = merge_termination_result(test_result, termination_error)
633            .expect_err("test and termination failure should fail");
634
635        assert_that!(err.to_string()).contains(
636            BrowserTestError::RunTest {
637                test_name: "login".to_owned(),
638            }
639            .to_string(),
640        );
641        assert_that!(err.children().len()).is_equal_to(1);
642    }
643}