Skip to main content

glass/browser/session/
wait.rs

1//! Page wait conditions and lifecycle detection.
2//!
3//! Waits for conditions such as URL changes, element visibility, text
4//! appearance, navigation completion, or configurable timeouts.
5
6use super::*;
7use std::future::Future;
8use std::pin::Pin;
9
10type VerificationCheckFuture<'a> =
11    Pin<Box<dyn Future<Output = BrowserResult<(bool, String)>> + 'a>>;
12
13impl BrowserSession {
14    pub(crate) async fn evaluate_predicate_once(
15        &self,
16        predicate: &VerificationPredicate,
17    ) -> BrowserResult<(bool, String)> {
18        predicate.validate(0)?;
19        self.check_verification_predicate(predicate).await
20    }
21
22    /// Evaluate a bounded, composable postcondition until it becomes true.
23    pub async fn verify(
24        &self,
25        predicate: VerificationPredicate,
26        deadline: Duration,
27    ) -> BrowserResult<VerificationOutcome> {
28        validate_wait_deadline(deadline)?;
29        predicate.validate(0)?;
30        let started = tokio::time::Instant::now();
31        let expires = started + deadline;
32        loop {
33            let (matched, observed) = self.check_verification_predicate(&predicate).await?;
34            let state = bounded_diagnostic_text(&observed);
35            if matched {
36                return Ok(VerificationOutcome {
37                    status: "satisfied",
38                    predicate,
39                    elapsed_ms: started.elapsed().as_millis() as u64,
40                    state,
41                });
42            }
43            if tokio::time::Instant::now() >= expires {
44                return Err(ActionVerificationError {
45                    kind: ActionFailureKind::VerificationFailed,
46                    action: ActionKind::Click,
47                    phase: ActionFailurePhase::Verification,
48                    recovery_strategy: RecoveryStrategy::Report,
49                    execution_id: Some(self.next_execution_id()),
50                    target: None,
51                    revision: self.page_revision.load(Ordering::Relaxed),
52                    reason: format!("verification predicate not satisfied: {state}"),
53                }
54                .into());
55            }
56            tokio::time::sleep(WAIT_POLL_INTERVAL).await;
57        }
58    }
59
60    fn check_verification_predicate<'a>(
61        &'a self,
62        predicate: &'a VerificationPredicate,
63    ) -> VerificationCheckFuture<'a> {
64        Box::pin(async move {
65            match predicate {
66                VerificationPredicate::UrlEquals { value } => {
67                    let url = self.page_info().await?.url;
68                    Ok((url == *value, format!("url={url}")))
69                }
70                VerificationPredicate::TitleContains { value } => {
71                    let title = self.page_info().await?.title;
72                    Ok((title.contains(value), format!("title={title}")))
73                }
74                VerificationPredicate::Visible { visible } => {
75                    let (matched, state, _) = self
76                        .check_wait_condition(&WaitCondition::TargetVisible(visible.clone()), None)
77                        .await?;
78                    Ok((matched, state))
79                }
80                VerificationPredicate::TextContains { value } => {
81                    let (matched, state, _) = self
82                        .check_wait_condition(&WaitCondition::Text(value.clone()), None)
83                        .await?;
84                    Ok((matched, state))
85                }
86                VerificationPredicate::PopupOpened { value } => {
87                    let topology = self.topology.lock().await;
88                    let opened = topology.targets.len() > 1;
89                    Ok((opened == *value, format!("popupOpened={opened}")))
90                }
91                VerificationPredicate::DialogOpen { value } => {
92                    let topology = self.topology.lock().await;
93                    let open = topology.pending_dialog.is_some();
94                    Ok((open == *value, format!("dialogOpen={open}")))
95                }
96                VerificationPredicate::DownloadStarted { value } => {
97                    let started = self.download_sequence.load(Ordering::Relaxed) > 0;
98                    Ok((started == *value, format!("downloadStarted={started}")))
99                }
100                VerificationPredicate::RevisionEquals { value } => {
101                    let revision = self.page_revision.load(Ordering::Relaxed);
102                    Ok((revision == *value, format!("revision={revision}")))
103                }
104                VerificationPredicate::All { all } => {
105                    let mut states = Vec::with_capacity(all.len());
106                    let mut matched = true;
107                    for child in all {
108                        let (child_matched, state) =
109                            self.check_verification_predicate(child).await?;
110                        matched &= child_matched;
111                        states.push(state);
112                    }
113                    Ok((matched, format!("all=[{}]", states.join(","))))
114                }
115                VerificationPredicate::Any { any } => {
116                    let mut states = Vec::with_capacity(any.len());
117                    let mut matched = false;
118                    for child in any {
119                        let (child_matched, state) =
120                            self.check_verification_predicate(child).await?;
121                        matched |= child_matched;
122                        states.push(state);
123                    }
124                    Ok((matched, format!("any=[{}]", states.join(","))))
125                }
126                VerificationPredicate::Not { not } => {
127                    let (matched, state) = self.check_verification_predicate(not).await?;
128                    Ok((!matched, format!("not({state})")))
129                }
130            }
131        })
132    }
133
134    /// Wait for a condition to be satisfied on the page.
135    ///
136    /// Supported conditions include lifecycle events (`"complete"`, `"interactive"`),
137    /// URL matching, target visibility/enabled/stability, text presence,
138    /// JavaScript expressions, and network quiet. Returns a [`WaitOutcome`]
139    /// on success or a [`WaitTimeout`] error if the deadline expires.
140    pub async fn wait(
141        &self,
142        condition: WaitCondition,
143        deadline: Duration,
144    ) -> BrowserResult<WaitOutcome> {
145        self.cdp
146            .with_current_route(async {
147                validate_wait_deadline(deadline)?;
148                condition.validate()?;
149                if let WaitCondition::NetworkQuiet(quiet) = condition {
150                    return tokio::time::timeout(
151                        deadline,
152                        self.wait_for_network_quiet(quiet, deadline),
153                    )
154                    .await
155                    .map_err(|_| {
156                        wait_timeout("network_quiet", deadline, "network_check_pending")
157                    })?;
158                }
159                let mut events = self.cdp.subscribe_events();
160                self.wait_loop(condition, deadline, deadline, &mut events, false)
161                    .await
162            })
163            .await
164    }
165
166    pub(crate) async fn wait_loop(
167        &self,
168        condition: WaitCondition,
169        deadline: Duration,
170        reported_deadline: Duration,
171        events: &mut tokio::sync::broadcast::Receiver<crate::browser::cdp::CdpEvent>,
172        require_load_event: bool,
173    ) -> BrowserResult<WaitOutcome> {
174        let started = tokio::time::Instant::now();
175        let expires = started + deadline;
176        let mut previous_geometry = None;
177        let description = condition.description();
178        let mut load_event_seen = !require_load_event;
179        let mut last_state = "not_checked".to_string();
180        loop {
181            let now = tokio::time::Instant::now();
182            if now >= expires {
183                return Err(wait_timeout(&description, reported_deadline, &last_state).into());
184            }
185            let remaining = expires - now;
186            let (matched, state, geometry) = tokio::time::timeout(
187                remaining,
188                self.check_wait_condition(&condition, previous_geometry.as_deref()),
189            )
190            .await
191            .map_err(|_| wait_timeout(&description, reported_deadline, &last_state))??;
192            last_state = bounded_wait_state(&state);
193            previous_geometry = geometry;
194            if matched && load_event_seen {
195                let (target_id, frame_id) = self.ensured_route_identity().await?;
196                return Ok(WaitOutcome {
197                    condition: description,
198                    elapsed_ms: started.elapsed().as_millis() as u64,
199                    last_state,
200                    target_id,
201                    frame_id,
202                });
203            }
204            let now = tokio::time::Instant::now();
205            let remaining = expires - now;
206            tokio::select! {
207                _ = tokio::time::sleep(WAIT_POLL_INTERVAL.min(remaining)) => {}
208                event = events.recv() => match event {
209                    Ok(event) => { load_event_seen |= event.method == "Page.loadEventFired"; }
210                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
211                    Err(_) => return Err("CDP event stream closed during wait".into()),
212                }
213            }
214        }
215    }
216
217    async fn check_wait_condition(
218        &self,
219        condition: &WaitCondition,
220        previous_geometry: Option<&str>,
221    ) -> BrowserResult<(bool, String, Option<String>)> {
222        match condition {
223            WaitCondition::Lifecycle(expected) => {
224                let page = self.page_info().await?;
225                Ok((page.ready_state == *expected, page.ready_state, None))
226            }
227            WaitCondition::UrlExact(expected) => {
228                let page = self.page_info().await?;
229                Ok((page.url == *expected, page.url, None))
230            }
231            WaitCondition::UrlPrefix(prefix) => {
232                let page = self.page_info().await?;
233                Ok((page.url.starts_with(prefix), page.url, None))
234            }
235            WaitCondition::Text(expected) => {
236                let expression = visible_text_contains_expression(expected)?;
237                let value = self.evaluate_value(&expression).await?;
238                let matched = value.as_bool().unwrap_or(false);
239                Ok((matched, format!("present={matched}"), None))
240            }
241            WaitCondition::JavaScript(expression) => {
242                let value = self.evaluate_value(expression).await?;
243                let matched = value
244                    .as_bool()
245                    .ok_or("wait JavaScript predicate must return a boolean")?;
246                Ok((matched, matched.to_string(), None))
247            }
248            WaitCondition::TargetAttached(target)
249            | WaitCondition::TargetVisible(target)
250            | WaitCondition::TargetHidden(target)
251            | WaitCondition::TargetEnabled(target)
252            | WaitCondition::TargetStable(target) => {
253                self.check_target_wait(condition, target, previous_geometry)
254                    .await
255            }
256            WaitCondition::NetworkQuiet(_) => unreachable!("handled by wait"),
257        }
258    }
259
260    async fn check_target_wait(
261        &self,
262        condition: &WaitCondition,
263        target: &str,
264        previous_geometry: Option<&str>,
265    ) -> BrowserResult<(bool, String, Option<String>)> {
266        let element = match self.resolve_element(target).await {
267            Ok(element) => element,
268            Err(error)
269                if error
270                    .downcast_ref::<TargetError>()
271                    .is_some_and(|error| error.kind == TargetErrorKind::NotFound) =>
272            {
273                let matched = matches!(condition, WaitCondition::TargetHidden(_));
274                return Ok((matched, "detached".to_string(), None));
275            }
276            Err(error) => return Err(error),
277        };
278        if matches!(condition, WaitCondition::TargetAttached(_)) {
279            return Ok((true, "attached".to_string(), None));
280        }
281        let object_id = self
282            .cdp
283            .resolve_node_object(element.node_id, element.backend_dom_node_id)
284            .await?;
285        let remote = RemoteObjectGuard::new(self.cdp.clone(), object_id);
286        let raw = self
287            .cdp
288            .call_on_object(&remote.object_id, WAIT_TARGET_STATE_FUNCTION)
289            .await;
290        let value = runtime_value(&raw?)?;
291        let visible = value["visible"].as_bool().unwrap_or(false);
292        let enabled = value["enabled"].as_bool().unwrap_or(false);
293        let geometry = value["geometry"].as_str().map(str::to_string);
294        let matched = match condition {
295            WaitCondition::TargetVisible(_) => visible,
296            WaitCondition::TargetHidden(_) => !visible,
297            WaitCondition::TargetEnabled(_) => visible && enabled,
298            WaitCondition::TargetStable(_) => {
299                visible
300                    && geometry
301                        .as_deref()
302                        .is_some_and(|geometry| previous_geometry == Some(geometry))
303            }
304            _ => unreachable!(),
305        };
306        Ok((matched, value.to_string(), geometry))
307    }
308
309    async fn wait_for_network_quiet(
310        &self,
311        quiet: Duration,
312        deadline: Duration,
313    ) -> BrowserResult<WaitOutcome> {
314        if quiet.is_zero() {
315            return Err("network quiet duration must be positive".into());
316        }
317        let mut events = self.cdp.subscribe_events_with_params();
318        let mut guard =
319            NetworkDomainGuard::acquire(self.cdp.clone(), Arc::clone(&self.network_wait_leases))
320                .await?;
321        let started = tokio::time::Instant::now();
322        let expires = started + deadline;
323        let mut empty_since = started;
324        let mut in_flight = HashSet::new();
325        let mut overflowed = false;
326        loop {
327            let now = tokio::time::Instant::now();
328            if in_flight.is_empty() && !overflowed && now.duration_since(empty_since) >= quiet {
329                guard.disable().await?;
330                let (target_id, frame_id) = self.route_identity().await?;
331                return Ok(WaitOutcome {
332                    condition: "network_quiet".to_string(),
333                    elapsed_ms: started.elapsed().as_millis() as u64,
334                    last_state: "in_flight=0".to_string(),
335                    target_id,
336                    frame_id,
337                });
338            }
339            if now >= expires {
340                return Err(WaitTimeout {
341                    condition: "network_quiet".to_string(),
342                    deadline_ms: deadline.as_millis() as u64,
343                    last_state: if overflowed {
344                        "in_flight=overflow".to_string()
345                    } else {
346                        format!("in_flight={}", in_flight.len())
347                    },
348                    reason: "deadline_exceeded",
349                }
350                .into());
351            }
352            tokio::select! {
353                _ = tokio::time::sleep((expires - now).min(WAIT_POLL_INTERVAL)) => {}
354                event = events.recv() => match event {
355                    Ok(event) => {
356                      let request_id = event.params["requestId"].as_str();
357                      match event.method.as_str() {
358                        "Network.requestWillBeSent" => {
359                            if let Some(id) = request_id {
360                                if in_flight.len() < NETWORK_IN_FLIGHT_LIMIT {
361                                    in_flight.insert(id.to_string());
362                                } else {
363                                    overflowed = true;
364                                }
365                            }
366                        }
367                        "Network.loadingFinished" | "Network.loadingFailed" => {
368                            if let Some(id) = request_id { in_flight.remove(id); }
369                            if in_flight.is_empty() && !overflowed { empty_since = tokio::time::Instant::now(); }
370                        }
371                        _ => {}
372                      }
373                    }
374                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => return Err("network wait event stream lagged".into()),
375                    Err(tokio::sync::broadcast::error::RecvError::Closed) => return Err("network wait event stream closed".into()),
376                }
377            }
378        }
379    }
380}