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::*;
7
8impl BrowserSession {
9    /// Wait for a condition to be satisfied on the page.
10    ///
11    /// Supported conditions include lifecycle events (`"complete"`, `"interactive"`),
12    /// URL matching, target visibility/enabled/stability, text presence,
13    /// JavaScript expressions, and network quiet. Returns a [`WaitOutcome`]
14    /// on success or a [`WaitTimeout`] error if the deadline expires.
15    pub async fn wait(
16        &self,
17        condition: WaitCondition,
18        deadline: Duration,
19    ) -> BrowserResult<WaitOutcome> {
20        self.cdp
21            .with_current_route(async {
22                validate_wait_deadline(deadline)?;
23                condition.validate()?;
24                if let WaitCondition::NetworkQuiet(quiet) = condition {
25                    return tokio::time::timeout(
26                        deadline,
27                        self.wait_for_network_quiet(quiet, deadline),
28                    )
29                    .await
30                    .map_err(|_| {
31                        wait_timeout("network_quiet", deadline, "network_check_pending")
32                    })?;
33                }
34                let mut events = self.cdp.subscribe_events();
35                self.wait_loop(condition, deadline, deadline, &mut events, false)
36                    .await
37            })
38            .await
39    }
40
41    pub(crate) async fn wait_loop(
42        &self,
43        condition: WaitCondition,
44        deadline: Duration,
45        reported_deadline: Duration,
46        events: &mut tokio::sync::broadcast::Receiver<crate::browser::cdp::CdpEvent>,
47        require_load_event: bool,
48    ) -> BrowserResult<WaitOutcome> {
49        let started = tokio::time::Instant::now();
50        let expires = started + deadline;
51        let mut previous_geometry = None;
52        let description = condition.description();
53        let mut load_event_seen = !require_load_event;
54        let mut last_state = "not_checked".to_string();
55        loop {
56            let now = tokio::time::Instant::now();
57            if now >= expires {
58                return Err(wait_timeout(&description, reported_deadline, &last_state).into());
59            }
60            let remaining = expires - now;
61            let (matched, state, geometry) = tokio::time::timeout(
62                remaining,
63                self.check_wait_condition(&condition, previous_geometry.as_deref()),
64            )
65            .await
66            .map_err(|_| wait_timeout(&description, reported_deadline, &last_state))??;
67            last_state = bounded_wait_state(&state);
68            previous_geometry = geometry;
69            if matched && load_event_seen {
70                let (target_id, frame_id) = self.ensured_route_identity().await?;
71                return Ok(WaitOutcome {
72                    condition: description,
73                    elapsed_ms: started.elapsed().as_millis() as u64,
74                    last_state,
75                    target_id,
76                    frame_id,
77                });
78            }
79            let now = tokio::time::Instant::now();
80            let remaining = expires - now;
81            tokio::select! {
82                _ = tokio::time::sleep(WAIT_POLL_INTERVAL.min(remaining)) => {}
83                event = events.recv() => match event {
84                    Ok(event) => { load_event_seen |= event.method == "Page.loadEventFired"; }
85                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
86                    Err(_) => return Err("CDP event stream closed during wait".into()),
87                }
88            }
89        }
90    }
91
92    async fn check_wait_condition(
93        &self,
94        condition: &WaitCondition,
95        previous_geometry: Option<&str>,
96    ) -> BrowserResult<(bool, String, Option<String>)> {
97        match condition {
98            WaitCondition::Lifecycle(expected) => {
99                let page = self.page_info().await?;
100                Ok((page.ready_state == *expected, page.ready_state, None))
101            }
102            WaitCondition::UrlExact(expected) => {
103                let page = self.page_info().await?;
104                Ok((page.url == *expected, page.url, None))
105            }
106            WaitCondition::UrlPrefix(prefix) => {
107                let page = self.page_info().await?;
108                Ok((page.url.starts_with(prefix), page.url, None))
109            }
110            WaitCondition::Text(expected) => {
111                let expression = visible_text_contains_expression(expected)?;
112                let value = self.evaluate_value(&expression).await?;
113                let matched = value.as_bool().unwrap_or(false);
114                Ok((matched, format!("present={matched}"), None))
115            }
116            WaitCondition::JavaScript(expression) => {
117                let value = self.evaluate_value(expression).await?;
118                let matched = value
119                    .as_bool()
120                    .ok_or("wait JavaScript predicate must return a boolean")?;
121                Ok((matched, matched.to_string(), None))
122            }
123            WaitCondition::TargetAttached(target)
124            | WaitCondition::TargetVisible(target)
125            | WaitCondition::TargetHidden(target)
126            | WaitCondition::TargetEnabled(target)
127            | WaitCondition::TargetStable(target) => {
128                self.check_target_wait(condition, target, previous_geometry)
129                    .await
130            }
131            WaitCondition::NetworkQuiet(_) => unreachable!("handled by wait"),
132        }
133    }
134
135    async fn check_target_wait(
136        &self,
137        condition: &WaitCondition,
138        target: &str,
139        previous_geometry: Option<&str>,
140    ) -> BrowserResult<(bool, String, Option<String>)> {
141        let element = match self.resolve_element(target).await {
142            Ok(element) => element,
143            Err(error)
144                if error
145                    .downcast_ref::<TargetError>()
146                    .is_some_and(|error| error.kind == TargetErrorKind::NotFound) =>
147            {
148                let matched = matches!(condition, WaitCondition::TargetHidden(_));
149                return Ok((matched, "detached".to_string(), None));
150            }
151            Err(error) => return Err(error),
152        };
153        if matches!(condition, WaitCondition::TargetAttached(_)) {
154            return Ok((true, "attached".to_string(), None));
155        }
156        let object_id = self
157            .cdp
158            .resolve_node_object(element.node_id, element.backend_dom_node_id)
159            .await?;
160        let remote = RemoteObjectGuard::new(self.cdp.clone(), object_id);
161        let raw = self
162            .cdp
163            .call_on_object(&remote.object_id, WAIT_TARGET_STATE_FUNCTION)
164            .await;
165        let value = runtime_value(&raw?)?;
166        let visible = value["visible"].as_bool().unwrap_or(false);
167        let enabled = value["enabled"].as_bool().unwrap_or(false);
168        let geometry = value["geometry"].as_str().map(str::to_string);
169        let matched = match condition {
170            WaitCondition::TargetVisible(_) => visible,
171            WaitCondition::TargetHidden(_) => !visible,
172            WaitCondition::TargetEnabled(_) => visible && enabled,
173            WaitCondition::TargetStable(_) => {
174                visible
175                    && geometry
176                        .as_deref()
177                        .is_some_and(|geometry| previous_geometry == Some(geometry))
178            }
179            _ => unreachable!(),
180        };
181        Ok((matched, value.to_string(), geometry))
182    }
183
184    async fn wait_for_network_quiet(
185        &self,
186        quiet: Duration,
187        deadline: Duration,
188    ) -> BrowserResult<WaitOutcome> {
189        if quiet.is_zero() {
190            return Err("network quiet duration must be positive".into());
191        }
192        let mut events = self.cdp.subscribe_events_with_params();
193        let mut guard =
194            NetworkDomainGuard::acquire(self.cdp.clone(), Arc::clone(&self.network_wait_leases))
195                .await?;
196        let started = tokio::time::Instant::now();
197        let expires = started + deadline;
198        let mut empty_since = started;
199        let mut in_flight = HashSet::new();
200        let mut overflowed = false;
201        loop {
202            let now = tokio::time::Instant::now();
203            if in_flight.is_empty() && !overflowed && now.duration_since(empty_since) >= quiet {
204                guard.disable().await?;
205                let (target_id, frame_id) = self.route_identity().await?;
206                return Ok(WaitOutcome {
207                    condition: "network_quiet".to_string(),
208                    elapsed_ms: started.elapsed().as_millis() as u64,
209                    last_state: "in_flight=0".to_string(),
210                    target_id,
211                    frame_id,
212                });
213            }
214            if now >= expires {
215                return Err(WaitTimeout {
216                    condition: "network_quiet".to_string(),
217                    deadline_ms: deadline.as_millis() as u64,
218                    last_state: if overflowed {
219                        "in_flight=overflow".to_string()
220                    } else {
221                        format!("in_flight={}", in_flight.len())
222                    },
223                    reason: "deadline_exceeded",
224                }
225                .into());
226            }
227            tokio::select! {
228                _ = tokio::time::sleep((expires - now).min(WAIT_POLL_INTERVAL)) => {}
229                event = events.recv() => match event {
230                    Ok(event) => {
231                      let request_id = event.params["requestId"].as_str();
232                      match event.method.as_str() {
233                        "Network.requestWillBeSent" => {
234                            if let Some(id) = request_id {
235                                if in_flight.len() < NETWORK_IN_FLIGHT_LIMIT {
236                                    in_flight.insert(id.to_string());
237                                } else {
238                                    overflowed = true;
239                                }
240                            }
241                        }
242                        "Network.loadingFinished" | "Network.loadingFailed" => {
243                            if let Some(id) = request_id { in_flight.remove(id); }
244                            if in_flight.is_empty() && !overflowed { empty_since = tokio::time::Instant::now(); }
245                        }
246                        _ => {}
247                      }
248                    }
249                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => return Err("network wait event stream lagged".into()),
250                    Err(tokio::sync::broadcast::error::RecvError::Closed) => return Err("network wait event stream closed".into()),
251                }
252            }
253        }
254    }
255}