Skip to main content

browser_test/
pause.rs

1use std::borrow::Cow;
2use std::io::ErrorKind;
3
4use rootcause::Report;
5use rootcause::prelude::ResultExt;
6use tokio::io::{self, AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt};
7
8use crate::BrowserTestError;
9use crate::env::env_flag_enabled;
10
11pub(crate) const DEFAULT_PAUSE_ENV: &str = "BROWSER_TEST_PAUSE";
12
13/// Configuration for the manual pause before browser tests execute.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct PauseConfig {
16    enabled: bool,
17    message: Cow<'static, str>,
18    prompt: Cow<'static, str>,
19}
20
21impl Default for PauseConfig {
22    fn default() -> Self {
23        Self {
24            enabled: false,
25            message: "Browser test execution is paused.".into(),
26            prompt: "Continue with tests? [y/N] ".into(),
27        }
28    }
29}
30
31impl PauseConfig {
32    /// Build a disabled pause config.
33    #[must_use]
34    pub fn disabled() -> Self {
35        Self::enabled(false)
36    }
37
38    /// Build a pause config from `BROWSER_TEST_PAUSE`.
39    ///
40    /// The variable is considered enabled unless it is unset, empty, `0`, `false`, `no`, or `off`.
41    #[must_use]
42    pub fn from_env() -> Self {
43        Self::from_env_var(DEFAULT_PAUSE_ENV)
44    }
45
46    /// Build a pause config from an environment variable.
47    ///
48    /// The variable is considered enabled unless it is unset, empty, `0`, `false`, `no`, or `off`.
49    #[must_use]
50    pub fn from_env_var(env_var: impl AsRef<str>) -> Self {
51        Self {
52            enabled: env_flag_enabled(env_var),
53            ..Self::default()
54        }
55    }
56
57    /// Build an enabled or disabled pause config directly.
58    #[must_use]
59    pub fn enabled(enabled: bool) -> Self {
60        Self {
61            enabled,
62            ..Self::default()
63        }
64    }
65
66    /// Set the message printed before the prompt.
67    #[must_use]
68    pub fn with_message(mut self, message: impl Into<Cow<'static, str>>) -> Self {
69        self.message = message.into();
70        self
71    }
72
73    /// Set the interactive prompt.
74    #[must_use]
75    pub fn with_prompt(mut self, prompt: impl Into<Cow<'static, str>>) -> Self {
76        self.prompt = prompt.into();
77        self
78    }
79
80    /// Whether the pause is enabled.
81    #[must_use]
82    pub const fn is_enabled(&self) -> bool {
83        self.enabled
84    }
85}
86
87/// The user's choice after a pause.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub(crate) enum PauseDecision {
90    /// Continue with the browser tests.
91    Continue,
92
93    /// Abort before running browser tests.
94    Abort,
95}
96
97pub(crate) async fn pause_if_requested(
98    config: PauseConfig,
99    hint: Option<&str>,
100) -> Result<PauseDecision, Report<BrowserTestError>> {
101    if !config.enabled {
102        return Ok(PauseDecision::Continue);
103    }
104    pause(config, hint).await
105}
106
107async fn pause(
108    config: PauseConfig,
109    hint: Option<&str>,
110) -> Result<PauseDecision, Report<BrowserTestError>> {
111    let mut stdin = io::BufReader::new(io::stdin());
112    let mut stdout = io::stdout();
113
114    pause_with_io(config, hint, &mut stdin, &mut stdout).await
115}
116
117async fn pause_with_io<R, W>(
118    config: PauseConfig,
119    hint: Option<&str>,
120    stdin: &mut R,
121    stdout: &mut W,
122) -> Result<PauseDecision, Report<BrowserTestError>>
123where
124    R: AsyncBufRead + Unpin,
125    W: AsyncWrite + Unpin,
126{
127    stdout
128        .write_all(config.message.as_bytes())
129        .await
130        .context(BrowserTestError::FlushPausePrompt)?;
131    stdout
132        .write_all(b"\n")
133        .await
134        .context(BrowserTestError::FlushPausePrompt)?;
135    tracing::info!("{}", config.message);
136
137    if let Some(hint) = hint.filter(|hint| !hint.is_empty()) {
138        stdout
139            .write_all(hint.as_bytes())
140            .await
141            .context(BrowserTestError::FlushPausePrompt)?;
142        stdout
143            .write_all(b"\n")
144            .await
145            .context(BrowserTestError::FlushPausePrompt)?;
146        tracing::info!("{hint}");
147    }
148
149    let mut buf = String::new();
150    loop {
151        stdout
152            .write_all(config.prompt.as_bytes())
153            .await
154            .context(BrowserTestError::FlushPausePrompt)?;
155        stdout
156            .flush()
157            .await
158            .context(BrowserTestError::FlushPausePrompt)?;
159
160        buf.clear();
161        let bytes_read = stdin
162            .read_line(&mut buf)
163            .await
164            .context(BrowserTestError::ReadPauseResponse)?;
165        if bytes_read == 0 {
166            return Err(Err::<(), _>(io::Error::new(
167                ErrorKind::UnexpectedEof,
168                "stdin reached EOF while waiting for pause response",
169            ))
170            .context(BrowserTestError::ReadPauseResponse)
171            .expect_err("synthetic EOF error should always be an error"));
172        }
173
174        match buf.trim().to_ascii_lowercase().as_str() {
175            "y" | "yes" | "c" | "continue" => return Ok(PauseDecision::Continue),
176            "n" | "no" | "q" | "quit" | "" => return Ok(PauseDecision::Abort),
177            _ => {
178                stdout
179                    .write_all(b"Enter 'y' to continue or 'n' to abort.\n")
180                    .await
181                    .context(BrowserTestError::FlushPausePrompt)?;
182            }
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::test_support::EnvVarGuard;
191    use assertr::prelude::*;
192    use tokio::io::BufReader;
193
194    mod pause_config {
195        use super::*;
196
197        #[test]
198        fn default_is_disabled() {
199            assert_that!(PauseConfig::default().is_enabled()).is_false();
200        }
201
202        mod from_env {
203            use super::*;
204
205            #[test]
206            fn from_env_treats_unset_as_disabled() {
207                let env = EnvVarGuard::new("BROWSER_TEST_PAUSE_CONFIG_TEST");
208                env.remove();
209
210                assert_that!(
211                    PauseConfig::from_env_var("BROWSER_TEST_PAUSE_CONFIG_TEST").is_enabled()
212                )
213                .is_false();
214            }
215
216            #[test]
217            fn from_env_reads_default_pause_var() {
218                let env = EnvVarGuard::new(DEFAULT_PAUSE_ENV);
219                env.set("yes");
220                assert_that!(PauseConfig::from_env().is_enabled()).is_true();
221                env.set("no");
222                assert_that!(PauseConfig::from_env().is_enabled()).is_false();
223            }
224        }
225    }
226
227    mod pause {
228        use super::*;
229
230        #[tokio::test]
231        async fn treats_stdin_eof_as_read_error() {
232            let mut stdin = BufReader::new(&b""[..]);
233            let mut stdout = Vec::new();
234
235            let err = pause_with_io(PauseConfig::enabled(true), None, &mut stdin, &mut stdout)
236                .await
237                .expect_err("stdin EOF should fail instead of aborting");
238
239            assert_that!(err.to_string()).contains(BrowserTestError::ReadPauseResponse.to_string());
240            assert_that!(format!("{err:?}"))
241                .contains("stdin reached EOF while waiting for pause response");
242        }
243
244        #[tokio::test]
245        async fn treats_empty_line_as_abort() {
246            let mut stdin = BufReader::new(&b"\n"[..]);
247            let mut stdout = Vec::new();
248
249            let decision = pause_with_io(PauseConfig::enabled(true), None, &mut stdin, &mut stdout)
250                .await
251                .expect("empty line should remain an explicit abort response");
252
253            assert_that!(decision).is_equal_to(PauseDecision::Abort);
254        }
255
256        #[tokio::test]
257        async fn treats_y_as_continue() {
258            let mut stdin = BufReader::new(&b"y\n"[..]);
259            let mut stdout = Vec::new();
260
261            let decision = pause_with_io(PauseConfig::enabled(true), None, &mut stdin, &mut stdout)
262                .await
263                .expect("positive response should continue");
264
265            assert_that!(decision).is_equal_to(PauseDecision::Continue);
266        }
267    }
268}