1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
use super::{Page, Response};
use crate::error::{Error, Result};
use crate::server::channel_owner::ChannelOwner;
use serde::Deserialize;
use std::sync::Arc;
/// Navigation: `goto`, history, load state and URL fragments.
impl Page {
/// Navigates to the specified URL.
///
/// Returns `None` when navigating to URLs that don't produce responses (e.g., data URLs,
/// about:blank). This matches Playwright's behavior across all language bindings.
///
/// # Arguments
///
/// * `url` - The URL to navigate to
/// * `options` - Optional navigation options (timeout, wait_until)
///
/// # Errors
///
/// Returns error if:
/// - URL is invalid
/// - Navigation timeout (default 30s)
/// - Network error
///
/// See: <https://playwright.dev/docs/api/class-page#page-goto>
#[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid(), url = %url, status = tracing::field::Empty))]
pub async fn goto(
&self,
url: &str,
options: impl Into<Option<GotoOptions>>,
) -> Result<Option<Response>> {
let options = options.into();
// Inject the page-level navigation timeout when no explicit timeout is given
let options = self.with_navigation_timeout(options);
// Delegate to main frame
let frame = self.main_frame().await.map_err(|e| match e {
Error::TargetClosed { context, .. } => Error::TargetClosed {
target_type: "Page".to_string(),
context,
},
other => other,
})?;
let response = frame.goto(url, Some(options)).await.map_err(|e| match e {
Error::TargetClosed { context, .. } => Error::TargetClosed {
target_type: "Page".to_string(),
context,
},
other => other,
})?;
if let Some(ref resp) = response {
tracing::Span::current().record("status", resp.status());
}
Ok(response)
}
/// Waits for the required load state to be reached.
///
/// This resolves when the page reaches a required load state, `load` by default.
/// The navigation must have been committed when this method is called. If the current
/// document has already reached the required state, resolves immediately.
///
/// See: <https://playwright.dev/docs/api/class-page#page-wait-for-load-state>
#[tracing::instrument(level = "debug", skip_all, fields(guid = %self.guid()))]
pub async fn wait_for_load_state(&self, state: Option<WaitUntil>) -> Result<()> {
let frame = self.main_frame().await?;
frame.wait_for_load_state(state).await
}
/// Waits for the main frame to navigate to a URL matching the given string or glob pattern.
///
/// See: <https://playwright.dev/docs/api/class-page#page-wait-for-url>
#[tracing::instrument(level = "debug", skip_all, fields(guid = %self.guid(), url = %url))]
pub async fn wait_for_url(
&self,
url: &str,
options: impl Into<Option<GotoOptions>>,
) -> Result<()> {
let options = options.into();
let frame = self.main_frame().await?;
frame.wait_for_url(url, options).await
}
/// Replace the URL fragment without firing a navigation.
///
/// Wraps `history.replaceState(null, '', <pathname+search+#hash>)`.
/// A leading `#` on `hash` is optional — both `"foo"` and `"#foo"`
/// produce the same result.
#[tracing::instrument(level = "debug", skip_all, fields(guid = %self.guid()))]
pub async fn set_url_fragment(&self, hash: &str) -> Result<()> {
let normalized = if hash.starts_with('#') {
hash.to_string()
} else {
format!("#{hash}")
};
// JSON-encode so quotes / backslashes / control chars in `hash`
// don't break the surrounding JS string literal.
let json = serde_json::to_string(&normalized).map_err(|e| {
crate::error::Error::ProtocolError(format!("serialize url fragment: {e}"))
})?;
let js =
format!("history.replaceState(null, '', location.pathname + location.search + {json})");
self.evaluate_expression(&js).await
}
/// Clear the URL fragment without firing a navigation.
///
/// Wraps `history.replaceState(null, '', <pathname+search>)`,
/// stripping any trailing `#...`. Pairs with
/// [`set_url_fragment`](Self::set_url_fragment).
#[tracing::instrument(level = "debug", skip_all, fields(guid = %self.guid()))]
pub async fn clear_url_fragment(&self) -> Result<()> {
self.evaluate_expression(
"history.replaceState(null, '', location.pathname + location.search)",
)
.await
}
/// Reloads the current page.
///
/// # Arguments
///
/// * `options` - Optional reload options (timeout, wait_until)
///
/// Returns `None` when reloading pages that don't produce responses (e.g., data URLs,
/// about:blank). This matches Playwright's behavior across all language bindings.
///
/// See: <https://playwright.dev/docs/api/class-page#page-reload>
#[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
pub async fn reload(
&self,
options: impl Into<Option<GotoOptions>>,
) -> Result<Option<Response>> {
let options = options.into();
self.navigate_history("reload", options).await
}
/// Navigates to the previous page in history.
///
/// Returns the main resource response. In case of multiple server redirects, the navigation
/// will resolve with the response of the last redirect. If can not go back, returns `None`.
///
/// See: <https://playwright.dev/docs/api/class-page#page-go-back>
#[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
pub async fn go_back(
&self,
options: impl Into<Option<GotoOptions>>,
) -> Result<Option<Response>> {
let options = options.into();
self.navigate_history("goBack", options).await
}
/// Navigates to the next page in history.
///
/// Returns the main resource response. In case of multiple server redirects, the navigation
/// will resolve with the response of the last redirect. If can not go forward, returns `None`.
///
/// See: <https://playwright.dev/docs/api/class-page#page-go-forward>
#[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
pub async fn go_forward(
&self,
options: impl Into<Option<GotoOptions>>,
) -> Result<Option<Response>> {
let options = options.into();
self.navigate_history("goForward", options).await
}
/// Shared implementation for reload, go_back and go_forward.
async fn navigate_history(
&self,
method: &str,
options: Option<GotoOptions>,
) -> Result<Option<Response>> {
// Inject the page-level navigation timeout when no explicit timeout is given
let opts = self.with_navigation_timeout(options);
let mut params = serde_json::json!({});
// opts.timeout is always Some(...) because with_navigation_timeout guarantees it
if let Some(timeout) = opts.timeout {
params["timeout"] = serde_json::json!(timeout.as_millis() as u64);
} else {
params["timeout"] = serde_json::json!(crate::DEFAULT_TIMEOUT_MS);
}
if let Some(wait_until) = opts.wait_until {
params["waitUntil"] = serde_json::json!(wait_until.as_str());
}
#[derive(Deserialize)]
struct NavigationResponse {
response: Option<ResponseReference>,
}
#[derive(Deserialize)]
struct ResponseReference {
#[serde(deserialize_with = "crate::server::connection::deserialize_arc_str")]
guid: Arc<str>,
}
let result: NavigationResponse = self.channel().send(method, params).await?;
if let Some(response_ref) = result.response {
// The Response's __create__ may arrive just after the response.
let response_arc = self
.connection()
.wait_for_object(&response_ref.guid)
.await?;
let initializer = response_arc.initializer();
let status = initializer["status"].as_u64().ok_or_else(|| {
crate::error::Error::ProtocolError("Response missing status".to_string())
})? as u16;
let headers = initializer["headers"]
.as_array()
.ok_or_else(|| {
crate::error::Error::ProtocolError("Response missing headers".to_string())
})?
.iter()
.filter_map(|h| {
let name = h["name"].as_str()?;
let value = h["value"].as_str()?;
Some((name.to_string(), value.to_string()))
})
.collect();
let response = Response::new(
initializer["url"]
.as_str()
.ok_or_else(|| {
crate::error::Error::ProtocolError("Response missing url".to_string())
})?
.to_string(),
status,
initializer["statusText"].as_str().unwrap_or("").to_string(),
headers,
Some(response_arc),
);
Ok(Some(response))
} else {
Ok(None)
}
}
/// Returns GotoOptions with the navigation timeout filled in if not already set.
///
/// Used internally to ensure the page's configured default navigation timeout
/// is used when the caller does not provide an explicit timeout.
fn with_navigation_timeout(&self, options: Option<GotoOptions>) -> GotoOptions {
let nav_timeout = self.default_navigation_timeout_ms();
match options {
Some(opts) if opts.timeout.is_some() => opts,
Some(mut opts) => {
opts.timeout = Some(std::time::Duration::from_millis(nav_timeout as u64));
opts
}
None => GotoOptions {
timeout: Some(std::time::Duration::from_millis(nav_timeout as u64)),
wait_until: None,
},
}
}
}
/// Options for page.goto() and page.reload()
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct GotoOptions {
/// Maximum operation time in milliseconds
pub timeout: Option<std::time::Duration>,
/// When to consider operation succeeded
pub wait_until: Option<WaitUntil>,
}
impl GotoOptions {
/// Creates new GotoOptions with default values
pub fn new() -> Self {
Self {
timeout: None,
wait_until: None,
}
}
/// Sets the timeout
pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = Some(timeout);
self
}
/// Sets the wait_until option
pub fn wait_until(mut self, wait_until: WaitUntil) -> Self {
self.wait_until = Some(wait_until);
self
}
}
impl Default for GotoOptions {
fn default() -> Self {
Self::new()
}
}
/// When to consider navigation succeeded
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum WaitUntil {
/// Consider operation to be finished when the `load` event is fired
Load,
/// Consider operation to be finished when the `DOMContentLoaded` event is fired
DomContentLoaded,
/// Consider operation to be finished when there are no network connections for at least 500ms
NetworkIdle,
/// Consider operation to be finished when the commit event is fired
Commit,
}
impl WaitUntil {
pub(crate) fn as_str(&self) -> &'static str {
match self {
WaitUntil::Load => "load",
WaitUntil::DomContentLoaded => "domcontentloaded",
WaitUntil::NetworkIdle => "networkidle",
WaitUntil::Commit => "commit",
}
}
}