1pub mod locator;
4mod navigation;
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use viewpoint_cdp::protocol::page::{NavigateParams, NavigateResult};
10use viewpoint_cdp::protocol::target::CloseTargetParams;
11use viewpoint_cdp::CdpConnection;
12use tracing::{debug, info, instrument, trace, warn};
13
14use crate::error::{NavigationError, PageError};
15use crate::wait::{DocumentLoadState, LoadStateWaiter};
16
17pub use locator::{AriaRole, Locator, LocatorOptions, Selector, TextOptions};
18pub use navigation::GotoBuilder;
19
20const DEFAULT_NAVIGATION_TIMEOUT: Duration = Duration::from_secs(30);
22
23#[derive(Debug)]
25pub struct Page {
26 connection: Arc<CdpConnection>,
28 target_id: String,
30 session_id: String,
32 frame_id: String,
34 closed: bool,
36}
37
38impl Page {
39 pub(crate) fn new(
41 connection: Arc<CdpConnection>,
42 target_id: String,
43 session_id: String,
44 frame_id: String,
45 ) -> Self {
46 Self {
47 connection,
48 target_id,
49 session_id,
50 frame_id,
51 closed: false,
52 }
53 }
54
55 pub fn goto(&self, url: impl Into<String>) -> GotoBuilder<'_> {
80 GotoBuilder::new(self, url.into())
81 }
82
83 pub async fn goto_url(&self, url: &str) -> Result<NavigationResponse, NavigationError> {
94 self.goto(url).goto().await
95 }
96
97 #[instrument(level = "info", skip(self), fields(target_id = %self.target_id, url = %url, wait_until = ?wait_until, timeout_ms = timeout.as_millis()))]
99 pub(crate) async fn navigate_internal(
100 &self,
101 url: &str,
102 wait_until: DocumentLoadState,
103 timeout: Duration,
104 referer: Option<&str>,
105 ) -> Result<NavigationResponse, NavigationError> {
106 if self.closed {
107 warn!("Attempted navigation on closed page");
108 return Err(NavigationError::Cancelled);
109 }
110
111 info!("Starting navigation");
112
113 let event_rx = self.connection.subscribe_events();
115 let mut waiter = LoadStateWaiter::new(
116 event_rx,
117 self.session_id.clone(),
118 self.frame_id.clone(),
119 );
120 trace!("Created load state waiter");
121
122 debug!("Sending Page.navigate command");
124 let result: NavigateResult = self
125 .connection
126 .send_command(
127 "Page.navigate",
128 Some(NavigateParams {
129 url: url.to_string(),
130 referrer: referer.map(ToString::to_string),
131 transition_type: None,
132 frame_id: None,
133 }),
134 Some(&self.session_id),
135 )
136 .await?;
137
138 debug!(frame_id = %result.frame_id, loader_id = ?result.loader_id, "Page.navigate completed");
139
140 if let Some(error_text) = result.error_text {
142 warn!(error = %error_text, "Navigation failed with error");
143 return Err(NavigationError::NetworkError(error_text));
144 }
145
146 trace!("Setting commit received");
148 waiter.set_commit_received().await;
149
150 debug!(wait_until = ?wait_until, "Waiting for load state");
152 waiter
153 .wait_for_load_state_with_timeout(wait_until, timeout)
154 .await?;
155
156 info!(frame_id = %result.frame_id, "Navigation completed successfully");
157
158 Ok(NavigationResponse {
159 url: url.to_string(),
160 frame_id: result.frame_id,
161 })
162 }
163
164 #[instrument(level = "info", skip(self), fields(target_id = %self.target_id))]
170 pub async fn close(&mut self) -> Result<(), PageError> {
171 if self.closed {
172 debug!("Page already closed");
173 return Ok(());
174 }
175
176 info!("Closing page");
177
178 self.connection
179 .send_command::<_, serde_json::Value>(
180 "Target.closeTarget",
181 Some(CloseTargetParams {
182 target_id: self.target_id.clone(),
183 }),
184 None,
185 )
186 .await?;
187
188 self.closed = true;
189 info!("Page closed");
190 Ok(())
191 }
192
193 pub fn target_id(&self) -> &str {
195 &self.target_id
196 }
197
198 pub fn session_id(&self) -> &str {
200 &self.session_id
201 }
202
203 pub fn frame_id(&self) -> &str {
205 &self.frame_id
206 }
207
208 pub fn is_closed(&self) -> bool {
210 self.closed
211 }
212
213 pub fn connection(&self) -> &Arc<CdpConnection> {
215 &self.connection
216 }
217
218 pub async fn url(&self) -> Result<String, PageError> {
224 if self.closed {
225 return Err(PageError::Closed);
226 }
227
228 let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
229 .connection
230 .send_command(
231 "Runtime.evaluate",
232 Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
233 expression: "window.location.href".to_string(),
234 object_group: None,
235 include_command_line_api: None,
236 silent: Some(true),
237 context_id: None,
238 return_by_value: Some(true),
239 await_promise: Some(false),
240 }),
241 Some(&self.session_id),
242 )
243 .await?;
244
245 result
246 .result
247 .value
248 .and_then(|v| v.as_str().map(std::string::ToString::to_string))
249 .ok_or_else(|| PageError::EvaluationFailed("Failed to get URL".to_string()))
250 }
251
252 pub async fn title(&self) -> Result<String, PageError> {
258 if self.closed {
259 return Err(PageError::Closed);
260 }
261
262 let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
263 .connection
264 .send_command(
265 "Runtime.evaluate",
266 Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
267 expression: "document.title".to_string(),
268 object_group: None,
269 include_command_line_api: None,
270 silent: Some(true),
271 context_id: None,
272 return_by_value: Some(true),
273 await_promise: Some(false),
274 }),
275 Some(&self.session_id),
276 )
277 .await?;
278
279 result
280 .result
281 .value
282 .and_then(|v| v.as_str().map(std::string::ToString::to_string))
283 .ok_or_else(|| PageError::EvaluationFailed("Failed to get title".to_string()))
284 }
285
286 pub fn locator(&self, selector: impl Into<String>) -> Locator<'_> {
299 Locator::new(self, Selector::Css(selector.into()))
300 }
301
302 pub fn get_by_text(&self, text: impl Into<String>) -> Locator<'_> {
311 Locator::new(
312 self,
313 Selector::Text {
314 text: text.into(),
315 exact: false,
316 },
317 )
318 }
319
320 pub fn get_by_text_exact(&self, text: impl Into<String>) -> Locator<'_> {
322 Locator::new(
323 self,
324 Selector::Text {
325 text: text.into(),
326 exact: true,
327 },
328 )
329 }
330
331 pub fn get_by_role(&self, role: AriaRole) -> RoleLocatorBuilder<'_> {
340 RoleLocatorBuilder::new(self, role)
341 }
342
343 pub fn get_by_test_id(&self, test_id: impl Into<String>) -> Locator<'_> {
353 Locator::new(self, Selector::TestId(test_id.into()))
354 }
355
356 pub fn get_by_label(&self, label: impl Into<String>) -> Locator<'_> {
364 Locator::new(self, Selector::Label(label.into()))
365 }
366
367 pub fn get_by_placeholder(&self, placeholder: impl Into<String>) -> Locator<'_> {
375 Locator::new(self, Selector::Placeholder(placeholder.into()))
376 }
377}
378
379#[derive(Debug)]
381pub struct RoleLocatorBuilder<'a> {
382 page: &'a Page,
383 role: AriaRole,
384 name: Option<String>,
385}
386
387impl<'a> RoleLocatorBuilder<'a> {
388 fn new(page: &'a Page, role: AriaRole) -> Self {
389 Self {
390 page,
391 role,
392 name: None,
393 }
394 }
395
396 #[must_use]
398 pub fn with_name(mut self, name: impl Into<String>) -> Self {
399 self.name = Some(name.into());
400 self
401 }
402
403 pub fn build(self) -> Locator<'a> {
405 Locator::new(
406 self.page,
407 Selector::Role {
408 role: self.role,
409 name: self.name,
410 },
411 )
412 }
413}
414
415impl<'a> From<RoleLocatorBuilder<'a>> for Locator<'a> {
416 fn from(builder: RoleLocatorBuilder<'a>) -> Self {
417 builder.build()
418 }
419}
420
421#[derive(Debug, Clone)]
423pub struct NavigationResponse {
424 pub url: String,
426 pub frame_id: String,
428}