Skip to main content

browser_test/
scheduler.rs

1use std::{future::Future, num::NonZeroUsize, pin::Pin};
2
3use futures_util::stream::{FuturesUnordered, StreamExt as _};
4use rootcause::Report;
5use rootcause::report_collection::ReportCollection;
6
7use crate::BrowserTestError;
8
9/// How [`crate::BrowserTestRunner`] schedules browser tests.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum BrowserTestParallelism {
12    /// Run one test at a time.
13    #[default]
14    Sequential,
15
16    /// Run up to the given number of tests at the same time.
17    ///
18    /// Each test still receives a fresh `WebDriver` session.
19    ///
20    /// Using `1` here leads to the same behavior as using `Sequential`.
21    Parallel(NonZeroUsize),
22}
23
24impl BrowserTestParallelism {
25    pub(crate) const fn max_parallel_tests(self) -> NonZeroUsize {
26        match self {
27            Self::Sequential => NonZeroUsize::MIN,
28            Self::Parallel(max_parallel_tests) => max_parallel_tests,
29        }
30    }
31}
32
33/// How [`crate::BrowserTestRunner`] handles failed browser tests.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum BrowserTestFailurePolicy {
36    /// Stop after the first failed test.
37    ///
38    /// When tests are running in parallel, the runner stops starting additional tests and waits for
39    /// already-started sessions to finish before reporting failures from those sessions.
40    #[default]
41    FailFast,
42
43    /// Run every test and return all failures as child reports on one aggregate report.
44    RunAll,
45}
46
47pub(crate) struct BrowserTestExecution {
48    pub(crate) test_index: usize,
49    pub(crate) result: Result<(), Report<BrowserTestError>>,
50}
51
52pub(crate) type BrowserTestExecutionFuture<'a> =
53    Pin<Box<dyn Future<Output = BrowserTestExecution> + Send + 'a>>;
54
55pub(crate) async fn run_test_executions_sequential<'a>(
56    failure_policy: BrowserTestFailurePolicy,
57    executions: impl IntoIterator<Item = BrowserTestExecutionFuture<'a>>,
58) -> Result<(), Report<BrowserTestError>> {
59    let mut failures = BrowserTestFailures::default();
60
61    for execution in executions {
62        let execution = execution.await;
63        match execution.result {
64            Ok(()) => {}
65            Err(err) => {
66                if failure_policy == BrowserTestFailurePolicy::FailFast {
67                    return Err(err);
68                }
69                failures.push(execution.test_index, err);
70            }
71        }
72    }
73
74    failures.into_result()
75}
76
77pub(crate) async fn run_test_executions_parallel<'a>(
78    failure_policy: BrowserTestFailurePolicy,
79    executions: impl IntoIterator<Item = BrowserTestExecutionFuture<'a>>,
80    max_parallel_tests: NonZeroUsize,
81) -> Result<(), Report<BrowserTestError>> {
82    let mut tests = executions.into_iter();
83    let mut running = FuturesUnordered::new();
84    let mut keep_starting = true;
85    let mut collected_failures = BrowserTestFailures::default();
86
87    // Start up to `max_parallel_tests` initially.
88    while running.len() < max_parallel_tests.get() {
89        match tests.next() {
90            None => break,
91            Some(test) => running.push(test),
92        }
93    }
94
95    // Keep starting an additional test once any previous test completes, keeping us topped up at
96    // `max_parallel_tests` until all tests are executing and completing.
97    while let Some(execution) = running.next().await {
98        if let Err(err) = execution.result {
99            if failure_policy == BrowserTestFailurePolicy::FailFast {
100                keep_starting = false;
101            }
102            collected_failures.push(execution.test_index, err);
103        }
104
105        if keep_starting && let Some(test) = tests.next() {
106            running.push(test);
107        }
108    }
109
110    collected_failures.into_result()
111}
112
113#[derive(Default)]
114struct BrowserTestFailures {
115    failures: Vec<(usize, Report<BrowserTestError>)>,
116}
117
118impl BrowserTestFailures {
119    fn push(&mut self, test_index: usize, failure: Report<BrowserTestError>) {
120        self.failures.push((test_index, failure));
121    }
122
123    fn into_result(mut self) -> Result<(), Report<BrowserTestError>> {
124        if self.failures.is_empty() {
125            return Ok(());
126        }
127
128        let failed_tests = self.failures.len();
129        self.failures
130            .sort_by_key(|(test_index, _failure)| *test_index);
131
132        let mut failure_collection = ReportCollection::with_capacity(failed_tests);
133        for (_test_index, failure) in self.failures {
134            failure_collection.push(failure.into_cloneable());
135        }
136
137        Err(failure_collection.context(BrowserTestError::RunTests { failed_tests }))
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use std::sync::{
144        Arc,
145        atomic::{AtomicUsize, Ordering},
146    };
147
148    use assertr::prelude::*;
149
150    use super::*;
151
152    #[test]
153    fn parallelism_max_parallel_tests_treats_sequential_as_one() {
154        assert_that!(
155            BrowserTestParallelism::Sequential
156                .max_parallel_tests()
157                .get()
158        )
159        .is_equal_to(1);
160
161        let max_parallel_tests =
162            NonZeroUsize::new(3).expect("literal parallelism should be non-zero");
163        assert_that!(
164            BrowserTestParallelism::Parallel(max_parallel_tests)
165                .max_parallel_tests()
166                .get()
167        )
168        .is_equal_to(3);
169    }
170
171    #[test]
172    fn browser_test_failures_returns_ok_when_empty() {
173        let failures = BrowserTestFailures::default();
174
175        assert_that!(failures.into_result()).is_ok();
176    }
177
178    #[test]
179    fn browser_test_failures_returns_aggregate_report_with_children() {
180        let mut failures = BrowserTestFailures::default();
181        failures.push(
182            0,
183            Report::new(BrowserTestError::RunTest {
184                test_name: "login".to_owned(),
185            }),
186        );
187        failures.push(
188            1,
189            Report::new(BrowserTestError::RunTest {
190                test_name: "checkout".to_owned(),
191            }),
192        );
193
194        let err = failures
195            .into_result()
196            .expect_err("non-empty failure collection should fail");
197
198        assert_that!(err.to_string())
199            .contains(BrowserTestError::RunTests { failed_tests: 2 }.to_string());
200        assert_that!(err.children().len()).is_equal_to(2);
201    }
202
203    #[test]
204    fn sequential_fail_fast_stops_after_first_failure() {
205        let runtime = current_thread_runtime();
206        let first = Arc::new(AtomicUsize::new(0));
207        let second = Arc::new(AtomicUsize::new(0));
208
209        let result = runtime.block_on(run_test_executions_sequential(
210            BrowserTestFailurePolicy::FailFast,
211            [
212                tracked_execution(first.clone(), failing_execution(0, "first")),
213                tracked_execution(second.clone(), passing_execution(1)),
214            ],
215        ));
216
217        assert_that!(result).is_err();
218        assert_that!(first.load(Ordering::SeqCst)).is_equal_to(1);
219        assert_that!(second.load(Ordering::SeqCst)).is_equal_to(0);
220    }
221
222    #[test]
223    fn sequential_run_all_continues_after_failure_and_panic() {
224        let runtime = current_thread_runtime();
225        let first = Arc::new(AtomicUsize::new(0));
226        let second = Arc::new(AtomicUsize::new(0));
227        let third = Arc::new(AtomicUsize::new(0));
228
229        let err = runtime
230            .block_on(run_test_executions_sequential(
231                BrowserTestFailurePolicy::RunAll,
232                [
233                    tracked_execution(first.clone(), failing_execution(0, "first")),
234                    tracked_execution(second.clone(), panicked_execution(1, "second")),
235                    tracked_execution(third.clone(), passing_execution(2)),
236                ],
237            ))
238            .expect_err("run-all should report collected failures");
239
240        assert_that!(first.load(Ordering::SeqCst)).is_equal_to(1);
241        assert_that!(second.load(Ordering::SeqCst)).is_equal_to(1);
242        assert_that!(third.load(Ordering::SeqCst)).is_equal_to(1);
243        assert_that!(err.children().len()).is_equal_to(2);
244        assert_that!(err.to_string())
245            .contains(BrowserTestError::RunTests { failed_tests: 2 }.to_string());
246    }
247
248    #[test]
249    fn parallel_fail_fast_stops_starting_new_tests_but_waits_for_running_tests() {
250        let runtime = current_thread_runtime();
251        let first = Arc::new(AtomicUsize::new(0));
252        let second = Arc::new(AtomicUsize::new(0));
253        let third = Arc::new(AtomicUsize::new(0));
254
255        let err = runtime
256            .block_on(run_test_executions_parallel(
257                BrowserTestFailurePolicy::FailFast,
258                [
259                    tracked_execution(first.clone(), failing_execution(0, "first")),
260                    tracked_execution(second.clone(), failing_execution(1, "second")),
261                    tracked_execution(third.clone(), passing_execution(2)),
262                ],
263                NonZeroUsize::new(2).expect("literal parallelism should be non-zero"),
264            ))
265            .expect_err("fail-fast should report failures from already-running tests");
266
267        assert_that!(err.children().len()).is_equal_to(2);
268        assert_that!(first.load(Ordering::SeqCst)).is_equal_to(1);
269        assert_that!(second.load(Ordering::SeqCst)).is_equal_to(1);
270        assert_that!(third.load(Ordering::SeqCst)).is_equal_to(0);
271    }
272
273    #[test]
274    fn parallel_run_all_starts_every_test() {
275        let runtime = current_thread_runtime();
276        let first = Arc::new(AtomicUsize::new(0));
277        let second = Arc::new(AtomicUsize::new(0));
278        let third = Arc::new(AtomicUsize::new(0));
279
280        runtime
281            .block_on(run_test_executions_parallel(
282                BrowserTestFailurePolicy::RunAll,
283                [
284                    tracked_execution(first.clone(), passing_execution(0)),
285                    tracked_execution(second.clone(), passing_execution(1)),
286                    tracked_execution(third.clone(), passing_execution(2)),
287                ],
288                NonZeroUsize::new(2).expect("literal parallelism should be non-zero"),
289            ))
290            .expect("all passing executions should succeed");
291
292        assert_that!(first.load(Ordering::SeqCst)).is_equal_to(1);
293        assert_that!(second.load(Ordering::SeqCst)).is_equal_to(1);
294        assert_that!(third.load(Ordering::SeqCst)).is_equal_to(1);
295    }
296
297    fn current_thread_runtime() -> tokio::runtime::Runtime {
298        tokio::runtime::Builder::new_current_thread()
299            .build()
300            .expect("current-thread runtime should build")
301    }
302
303    fn tracked_execution(
304        counter: Arc<AtomicUsize>,
305        execution: BrowserTestExecution,
306    ) -> BrowserTestExecutionFuture<'static> {
307        Box::pin(async move {
308            counter.fetch_add(1, Ordering::SeqCst);
309            execution
310        })
311    }
312
313    fn passing_execution(test_index: usize) -> BrowserTestExecution {
314        BrowserTestExecution {
315            test_index,
316            result: Ok(()),
317        }
318    }
319
320    fn failing_execution(test_index: usize, test_name: &str) -> BrowserTestExecution {
321        BrowserTestExecution {
322            test_index,
323            result: Err(Report::new(BrowserTestError::RunTest {
324                test_name: test_name.to_owned(),
325            })),
326        }
327    }
328
329    fn panicked_execution(test_index: usize, test_name: &str) -> BrowserTestExecution {
330        BrowserTestExecution {
331            test_index,
332            result: Err(Report::new(BrowserTestError::Panic {
333                test_name: test_name.to_owned(),
334                message: "boom".to_owned(),
335            })),
336        }
337    }
338}