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#[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 #[must_use]
34 pub fn disabled() -> Self {
35 Self::enabled(false)
36 }
37
38 #[must_use]
42 pub fn from_env() -> Self {
43 Self::from_env_var(DEFAULT_PAUSE_ENV)
44 }
45
46 #[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 #[must_use]
59 pub fn enabled(enabled: bool) -> Self {
60 Self {
61 enabled,
62 ..Self::default()
63 }
64 }
65
66 #[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 #[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 #[must_use]
82 pub const fn is_enabled(&self) -> bool {
83 self.enabled
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub(crate) enum PauseDecision {
90 Continue,
92
93 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}