Skip to main content

rmux_sdk/wait/
visible.rs

1//! Playwright-style visible text waits built on pane snapshots.
2
3use std::error::Error;
4use std::fmt;
5use std::future::{Future, IntoFuture};
6use std::pin::Pin;
7use std::time::Duration;
8
9use rmux_proto::RmuxError as ProtoError;
10use tokio::time::Instant;
11
12use crate::{Pane, PaneSnapshot, Result, RmuxError};
13
14use super::{resolved_wait_timeout, TEXT_POLL_INTERVAL};
15
16#[cfg(feature = "regex")]
17const REGEX_SIZE_LIMIT: usize = 1_000_000;
18
19/// Entry point for visible text assertions on one pane.
20#[derive(Debug, Clone, Copy)]
21pub struct VisibleTextExpectation<'a> {
22    pane: &'a Pane,
23}
24
25impl<'a> VisibleTextExpectation<'a> {
26    pub(crate) const fn new(pane: &'a Pane) -> Self {
27        Self { pane }
28    }
29
30    /// Waits until the visible screen text contains `needle`.
31    pub fn to_contain(self, needle: impl Into<String>) -> VisibleTextWait<'a> {
32        VisibleTextWait::new(self.pane, VisibleTextMatcherSpec::Contains(needle.into()))
33    }
34
35    /// Waits until the visible screen text does not contain `needle`.
36    ///
37    /// Negative waits can pass before a process has printed anything. Prefer
38    /// anchoring the workflow with a positive wait first when testing a TUI
39    /// transition.
40    pub fn not_to_contain(self, needle: impl Into<String>) -> VisibleTextWait<'a> {
41        VisibleTextWait::new(
42            self.pane,
43            VisibleTextMatcherSpec::NotContains(needle.into()),
44        )
45    }
46
47    /// Waits until any supplied literal is present in the visible screen text.
48    pub fn to_match_any<I, S>(self, needles: I) -> VisibleTextWait<'a>
49    where
50        I: IntoIterator<Item = S>,
51        S: Into<String>,
52    {
53        VisibleTextWait::new(
54            self.pane,
55            VisibleTextMatcherSpec::Any(needles.into_iter().map(Into::into).collect()),
56        )
57    }
58
59    /// Waits until all supplied literals are present in the visible screen
60    /// text.
61    pub fn to_match_all<I, S>(self, needles: I) -> VisibleTextWait<'a>
62    where
63        I: IntoIterator<Item = S>,
64        S: Into<String>,
65    {
66        VisibleTextWait::new(
67            self.pane,
68            VisibleTextMatcherSpec::All(needles.into_iter().map(Into::into).collect()),
69        )
70    }
71
72    /// Waits until the visible screen text matches a regular expression.
73    ///
74    /// Available with `rmux-sdk` feature `regex`.
75    #[cfg(feature = "regex")]
76    pub fn to_match(self, pattern: impl Into<String>) -> VisibleTextWait<'a> {
77        self.to_match_regex(pattern)
78    }
79
80    /// Waits until the visible screen text matches a regular expression.
81    ///
82    /// Available with `rmux-sdk` feature `regex`.
83    #[cfg(feature = "regex")]
84    pub fn to_match_regex(self, pattern: impl Into<String>) -> VisibleTextWait<'a> {
85        VisibleTextWait::new(self.pane, VisibleTextMatcherSpec::Regex(pattern.into()))
86    }
87
88    /// Waits until the visible screen text does not match a regular
89    /// expression.
90    ///
91    /// Available with `rmux-sdk` feature `regex`. Like other negative waits,
92    /// this can pass before the process has printed anything; use a preceding
93    /// positive wait when absence must be checked after a known state change.
94    #[cfg(feature = "regex")]
95    pub fn not_to_match_regex(self, pattern: impl Into<String>) -> VisibleTextWait<'a> {
96        VisibleTextWait::new(self.pane, VisibleTextMatcherSpec::NotRegex(pattern.into()))
97    }
98
99    /// Waits until any supplied regular expression matches the visible screen.
100    ///
101    /// Available with `rmux-sdk` feature `regex`.
102    #[cfg(feature = "regex")]
103    pub fn to_match_any_regex<I, S>(self, patterns: I) -> VisibleTextWait<'a>
104    where
105        I: IntoIterator<Item = S>,
106        S: Into<String>,
107    {
108        VisibleTextWait::new(
109            self.pane,
110            VisibleTextMatcherSpec::RegexAny(patterns.into_iter().map(Into::into).collect()),
111        )
112    }
113
114    /// Waits until all supplied regular expressions match the visible screen.
115    ///
116    /// Available with `rmux-sdk` feature `regex`.
117    #[cfg(feature = "regex")]
118    pub fn to_match_all_regex<I, S>(self, patterns: I) -> VisibleTextWait<'a>
119    where
120        I: IntoIterator<Item = S>,
121        S: Into<String>,
122    {
123        VisibleTextWait::new(
124            self.pane,
125            VisibleTextMatcherSpec::RegexAll(patterns.into_iter().map(Into::into).collect()),
126        )
127    }
128}
129
130/// Awaitable visible text wait builder.
131#[derive(Debug)]
132#[must_use = "visible text waits do nothing unless awaited"]
133pub struct VisibleTextWait<'a> {
134    pane: &'a Pane,
135    matcher: VisibleTextMatcherSpec,
136    timeout: Option<Duration>,
137    poll_interval: Duration,
138}
139
140impl<'a> VisibleTextWait<'a> {
141    fn new(pane: &'a Pane, matcher: VisibleTextMatcherSpec) -> Self {
142        Self {
143            pane,
144            matcher,
145            timeout: None,
146            poll_interval: TEXT_POLL_INTERVAL,
147        }
148    }
149
150    /// Overrides the timeout for this wait.
151    pub const fn timeout(mut self, timeout: Duration) -> Self {
152        self.timeout = Some(timeout);
153        self
154    }
155
156    /// Overrides the snapshot polling interval for this wait.
157    pub const fn poll_interval(mut self, interval: Duration) -> Self {
158        self.poll_interval = interval;
159        self
160    }
161
162    async fn run(self) -> Result<PaneSnapshot> {
163        let matcher = self.matcher.compile()?;
164        let timeout = self
165            .timeout
166            .or_else(|| resolved_wait_timeout(self.pane.configured_default_timeout()));
167        let deadline = timeout.map(|timeout| Instant::now() + timeout);
168        loop {
169            let snapshot = self.pane.snapshot().await?;
170            if matcher.matches(&snapshot.visible_text()) {
171                return Ok(snapshot);
172            }
173
174            if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
175                return Err(RmuxError::wait_timeout(WaitTimeoutError::new(
176                    matcher.describe(),
177                    timeout.expect("deadline implies timeout"),
178                    snapshot,
179                )));
180            }
181
182            sleep_until_next_poll(deadline, self.poll_interval).await;
183
184            if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
185                return Err(RmuxError::wait_timeout(WaitTimeoutError::new(
186                    matcher.describe(),
187                    timeout.expect("deadline implies timeout"),
188                    snapshot,
189                )));
190            }
191        }
192    }
193}
194
195impl<'a> IntoFuture for VisibleTextWait<'a> {
196    type Output = Result<PaneSnapshot>;
197    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send + 'a>>;
198
199    fn into_future(self) -> Self::IntoFuture {
200        Box::pin(self.run())
201    }
202}
203
204#[derive(Debug)]
205enum VisibleTextMatcherSpec {
206    Contains(String),
207    NotContains(String),
208    Any(Vec<String>),
209    All(Vec<String>),
210    #[cfg(feature = "regex")]
211    Regex(String),
212    #[cfg(feature = "regex")]
213    NotRegex(String),
214    #[cfg(feature = "regex")]
215    RegexAny(Vec<String>),
216    #[cfg(feature = "regex")]
217    RegexAll(Vec<String>),
218}
219
220impl VisibleTextMatcherSpec {
221    fn compile(self) -> Result<VisibleTextMatcher> {
222        let invalid = match self {
223            Self::Contains(ref value) | Self::NotContains(ref value) => value.is_empty(),
224            Self::Any(ref values) | Self::All(ref values) => {
225                values.is_empty() || values.iter().any(String::is_empty)
226            }
227            #[cfg(feature = "regex")]
228            Self::Regex(ref value) | Self::NotRegex(ref value) => value.is_empty(),
229            #[cfg(feature = "regex")]
230            Self::RegexAny(ref values) | Self::RegexAll(ref values) => {
231                values.is_empty() || values.iter().any(String::is_empty)
232            }
233        };
234        if invalid {
235            return Err(RmuxError::protocol(ProtoError::Server(
236                "visible text wait patterns must not be empty".to_owned(),
237            )));
238        }
239
240        match self {
241            Self::Contains(value) => Ok(VisibleTextMatcher::Contains(value)),
242            Self::NotContains(value) => Ok(VisibleTextMatcher::NotContains(value)),
243            Self::Any(values) => Ok(VisibleTextMatcher::Any(values)),
244            Self::All(values) => Ok(VisibleTextMatcher::All(values)),
245            #[cfg(feature = "regex")]
246            Self::Regex(pattern) => Ok(VisibleTextMatcher::Regex(compile_regex(pattern)?)),
247            #[cfg(feature = "regex")]
248            Self::NotRegex(pattern) => Ok(VisibleTextMatcher::NotRegex(compile_regex(pattern)?)),
249            #[cfg(feature = "regex")]
250            Self::RegexAny(patterns) => compile_regexes(patterns).map(VisibleTextMatcher::RegexAny),
251            #[cfg(feature = "regex")]
252            Self::RegexAll(patterns) => compile_regexes(patterns).map(VisibleTextMatcher::RegexAll),
253        }
254    }
255}
256
257#[derive(Debug)]
258enum VisibleTextMatcher {
259    Contains(String),
260    NotContains(String),
261    Any(Vec<String>),
262    All(Vec<String>),
263    #[cfg(feature = "regex")]
264    Regex(regex::Regex),
265    #[cfg(feature = "regex")]
266    NotRegex(regex::Regex),
267    #[cfg(feature = "regex")]
268    RegexAny(Vec<regex::Regex>),
269    #[cfg(feature = "regex")]
270    RegexAll(Vec<regex::Regex>),
271}
272
273impl VisibleTextMatcher {
274    fn matches(&self, visible_text: &str) -> bool {
275        match self {
276            Self::Contains(value) => visible_text.contains(value),
277            Self::NotContains(value) => !visible_text.contains(value),
278            Self::Any(values) => values.iter().any(|value| visible_text.contains(value)),
279            Self::All(values) => values.iter().all(|value| visible_text.contains(value)),
280            #[cfg(feature = "regex")]
281            Self::Regex(pattern) => pattern.is_match(visible_text),
282            #[cfg(feature = "regex")]
283            Self::NotRegex(pattern) => !pattern.is_match(visible_text),
284            #[cfg(feature = "regex")]
285            Self::RegexAny(patterns) => patterns
286                .iter()
287                .any(|pattern| pattern.is_match(visible_text)),
288            #[cfg(feature = "regex")]
289            Self::RegexAll(patterns) => patterns
290                .iter()
291                .all(|pattern| pattern.is_match(visible_text)),
292        }
293    }
294
295    fn describe(&self) -> String {
296        match self {
297            Self::Contains(value) => format!("contain `{value}`"),
298            Self::NotContains(value) => format!("not contain `{value}`"),
299            Self::Any(values) => format!("match any of {}", render_patterns(values)),
300            Self::All(values) => format!("match all of {}", render_patterns(values)),
301            #[cfg(feature = "regex")]
302            Self::Regex(pattern) => format!("match regex `{}`", pattern.as_str()),
303            #[cfg(feature = "regex")]
304            Self::NotRegex(pattern) => format!("not match regex `{}`", pattern.as_str()),
305            #[cfg(feature = "regex")]
306            Self::RegexAny(patterns) => {
307                format!("match any regex of {}", render_regex_patterns(patterns))
308            }
309            #[cfg(feature = "regex")]
310            Self::RegexAll(patterns) => {
311                format!("match all regex of {}", render_regex_patterns(patterns))
312            }
313        }
314    }
315}
316
317/// Timeout details for a visible text wait.
318#[derive(Debug)]
319pub struct WaitTimeoutError {
320    matcher: String,
321    timeout: Duration,
322    last_snapshot: PaneSnapshot,
323}
324
325impl WaitTimeoutError {
326    pub(crate) fn new(matcher: String, timeout: Duration, last_snapshot: PaneSnapshot) -> Self {
327        Self {
328            matcher,
329            timeout,
330            last_snapshot,
331        }
332    }
333
334    /// Returns the matcher description that did not become true.
335    #[must_use]
336    pub fn matcher(&self) -> &str {
337        &self.matcher
338    }
339
340    /// Returns the timeout duration that elapsed.
341    #[must_use]
342    pub const fn timeout(&self) -> Duration {
343        self.timeout
344    }
345
346    /// Returns the last visible snapshot captured before timeout.
347    #[must_use]
348    pub const fn last_snapshot(&self) -> &PaneSnapshot {
349        &self.last_snapshot
350    }
351
352    /// Returns the last visible screen text captured before timeout.
353    #[must_use]
354    pub fn last_visible_text(&self) -> String {
355        self.last_snapshot.visible_text()
356    }
357}
358
359impl fmt::Display for WaitTimeoutError {
360    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
361        write!(
362            formatter,
363            "timed out after {}s waiting for visible text to {}; last visible screen:\n{}",
364            self.timeout.as_secs_f32(),
365            self.matcher,
366            self.last_snapshot.visible_text()
367        )
368    }
369}
370
371impl Error for WaitTimeoutError {}
372
373async fn sleep_until_next_poll(deadline: Option<Instant>, poll_interval: Duration) {
374    let Some(deadline) = deadline else {
375        tokio::time::sleep(poll_interval).await;
376        return;
377    };
378
379    let now = Instant::now();
380    if now >= deadline {
381        return;
382    }
383    tokio::time::sleep(poll_interval.min(deadline - now)).await;
384}
385
386fn render_patterns(patterns: &[String]) -> String {
387    patterns
388        .iter()
389        .map(|pattern| format!("`{pattern}`"))
390        .collect::<Vec<_>>()
391        .join(", ")
392}
393
394#[cfg(feature = "regex")]
395fn compile_regex(pattern: String) -> Result<regex::Regex> {
396    regex::RegexBuilder::new(&pattern)
397        .size_limit(REGEX_SIZE_LIMIT)
398        .dfa_size_limit(REGEX_SIZE_LIMIT)
399        .build()
400        .map_err(|error| RmuxError::invalid_regex(pattern, error.to_string()))
401}
402
403#[cfg(feature = "regex")]
404fn compile_regexes(patterns: Vec<String>) -> Result<Vec<regex::Regex>> {
405    patterns.into_iter().map(compile_regex).collect()
406}
407
408#[cfg(feature = "regex")]
409fn render_regex_patterns(patterns: &[regex::Regex]) -> String {
410    patterns
411        .iter()
412        .map(|pattern| format!("`{}`", pattern.as_str()))
413        .collect::<Vec<_>>()
414        .join(", ")
415}