Skip to main content

cosh_tools/computer/
wait.rs

1//! `computer_wait` — block until an element reaches a state, or time out
2//! with a diagnosis.
3//!
4//! This is a standalone OBSERVATION tool (like [`super::apps`] and
5//! [`super::snapshot`]): it sends no input and moves nothing, so it is
6//! allowed in every mode including Ask. It replaces poll-loops (snapshot →
7//! check → snapshot …, each a full round trip) with one blocking call:
8//! wait until a selector reaches a state, or fail with the condition AND
9//! the last observed state so the model can decide the next move without
10//! another probe.
11//!
12//! Every xa11y call is blocking (platform accessibility APIs are
13//! synchronous), so it runs on tokio's blocking pool like every other
14//! tool in this module.
15use std::time::{Duration, Instant};
16
17use xa11y::{App, AppExt, ElementState, Locator};
18
19use super::types::{ComputerWait, WaitObservation, WaitOutput, WaitState};
20
21/// Default wait before giving up: long enough for dialogs to open and
22/// spinners to finish, short enough that a stuck call wastes one round
23/// trip, not a minute.
24pub const DEFAULT_WAIT_MS: u64 = 10_000;
25
26/// Hard cap on a single wait: a stuck condition must not pin the agent
27/// loop (or the blocking pool) for minutes. Chain calls for longer waits.
28pub const MAX_WAIT_MS: u64 = 60_000;
29
30/// Input validation for `computer_wait`: an element target (`selector` +
31/// exactly one app scope), a 1-based `nth`, and a timeout within the cap.
32///
33/// # Errors
34///
35/// Returns `Err` for a missing/blank `selector`, `name` and `pid`
36/// together, no app scope at all, a 0-based `nth`, or a `timeout_ms`
37/// above [`MAX_WAIT_MS`].
38pub fn validate(input: &ComputerWait) -> Result<(), String> {
39    if input
40        .selector
41        .as_deref()
42        .map(str::trim)
43        .filter(|s| !s.is_empty())
44        .is_none()
45    {
46        return Err(
47            "computer_wait: `selector` is required — the element to watch, \
48             e.g. progress_bar[name='Exporting…']"
49                .to_string(),
50        );
51    }
52    let has_name = input
53        .name
54        .as_deref()
55        .map(str::trim)
56        .filter(|s| !s.is_empty())
57        .is_some();
58    if has_name && input.pid.is_some() {
59        return Err("computer_wait: provide `name` or `pid`, not both".to_string());
60    }
61    if !has_name && input.pid.is_none() {
62        return Err("computer_wait: provide `name` or `pid`".to_string());
63    }
64    if input.nth == Some(0) {
65        return Err("computer_wait: `nth` is 1-based; use 1 for the first match".to_string());
66    }
67    if input.timeout_ms.is_some_and(|ms| ms > MAX_WAIT_MS) {
68        return Err(format!(
69            "computer_wait: `timeout_ms` is capped at {MAX_WAIT_MS} ms per call — \
70             chain calls for longer waits"
71        ));
72    }
73    Ok(())
74}
75
76/// Run `computer_wait` on tokio's blocking pool.
77///
78/// # Errors
79///
80/// Returns `Err` for invalid input, an app that never surfaces, a selector
81/// that never matches, or — the point of the tool — a condition not met
82/// within the timeout. The timeout error embeds xa11y's `Diagnosis` (what
83/// was waited for + the last observed state), so the failure message alone
84/// answers "why didn't it happen?".
85pub async fn wait(input: &ComputerWait) -> Result<WaitOutput, String> {
86    let input = input.clone();
87    tokio::task::spawn_blocking(move || wait_blocking(&input))
88        .await
89        .map_err(|e| format!("computer_wait: blocking task failed: {e}"))?
90}
91
92fn wait_blocking(input: &ComputerWait) -> Result<WaitOutput, String> {
93    validate(input)?;
94    let timeout = Duration::from_millis(input.timeout_ms.unwrap_or(DEFAULT_WAIT_MS));
95    // The wall clock starts HERE, not at the poll loop: the caller asked
96    // "answer within timeout_ms", so the answer (or the timeout error)
97    // must reflect the whole call, app resolution included.
98    let started = Instant::now();
99
100    let name = input
101        .name
102        .as_deref()
103        .map(str::trim)
104        .filter(|s| !s.is_empty());
105    // The app lookup SHARES the call's budget instead of getting a fresh
106    // one (review finding): bounded to the same 3 s slice snapshot uses
107    // for "app surfaces", it can never eat the budget the condition
108    // needs — but a slow DEE (cold app launch) still gets a fair share
109    // when the caller asks for a long wait.
110    let app_timeout = timeout.min(Duration::from_millis(super::snapshot::DEFAULT_TIMEOUT_MS));
111    let app = match (name, input.pid) {
112        (Some(name), None) => App::by_name(name, app_timeout),
113        (None, Some(pid)) => App::by_pid(pid, app_timeout),
114        _ => unreachable!("validated: computer_wait carries exactly one app scope"),
115    }
116    .map_err(|e| super::errors::render_app_miss("computer_wait", &e))?;
117
118    let selector = input
119        .selector
120        .as_deref()
121        .expect("validated: computer_wait carries a selector");
122    let locator = app.locator(selector.trim()).nth(input.nth.unwrap_or(1));
123    let state = wait_state(input.state.unwrap_or_default());
124    // The element wait gets whatever budget the app lookup left — the
125    // TOTAL stays within the caller's timeout_ms (xa11y's poll loop
126    // evaluates the predicate at least once even at Duration::ZERO, and
127    // overshoots by at most one ~100 ms poll).
128    let remaining = timeout.saturating_sub(started.elapsed());
129    wait_on_locator(&locator, state, remaining, started)
130}
131
132/// Map the wire enum onto xa11y's state vocabulary.
133pub(crate) fn wait_state(state: WaitState) -> ElementState {
134    match state {
135        WaitState::Attached => ElementState::Attached,
136        WaitState::Detached => ElementState::Detached,
137        WaitState::Visible => ElementState::Visible,
138        WaitState::Hidden => ElementState::Hidden,
139        WaitState::Enabled => ElementState::Enabled,
140        WaitState::Disabled => ElementState::Disabled,
141        WaitState::Focused => ElementState::Focused,
142        WaitState::Unfocused => ElementState::Unfocused,
143    }
144}
145
146/// Core wait over an already-built locator — the seam the mock-provider
147/// tests use (they build a `Locator` directly instead of resolving an
148/// app by name against the live desktop).
149///
150/// `started` is the wall clock of the WHOLE call (app resolution
151/// included), so `elapsed_ms` answers "how long did this call take" —
152/// the contract the model reasons about when budgeting its next steps.
153///
154/// On timeout the xa11y error's `Display` already embeds the `Diagnosis`
155/// (condition + last observed state); it is passed through verbatim so
156/// the model sees exactly what the poll loop last saw.
157pub(crate) fn wait_on_locator(
158    locator: &Locator,
159    state: ElementState,
160    timeout: Duration,
161    started: Instant,
162) -> Result<WaitOutput, String> {
163    let elapsed_ms = || u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
164    match locator.wait_for_state(state, timeout) {
165        Ok(Some(element)) => Ok(WaitOutput {
166            met: true,
167            elapsed_ms: elapsed_ms(),
168            observed: observation(&element.data().states, true),
169        }),
170        // An absence condition (detached/hidden) met with nothing in the
171        // tree: nothing to observe beyond the fact.
172        Ok(None) => Ok(WaitOutput {
173            met: true,
174            elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
175            observed: WaitObservation {
176                attached: false,
177                visible: None,
178                enabled: None,
179                focused: None,
180            },
181        }),
182        Err(e) => Err(super::errors::render(
183            "computer_wait",
184            "wait for element state",
185            &e,
186        )),
187    }
188}
189
190fn observation(states: &xa11y::StateSet, attached: bool) -> WaitObservation {
191    if attached {
192        WaitObservation {
193            attached: true,
194            visible: Some(states.visible),
195            enabled: Some(states.enabled),
196            focused: Some(states.focused),
197        }
198    } else {
199        WaitObservation {
200            attached: false,
201            visible: None,
202            enabled: None,
203            focused: None,
204        }
205    }
206}