1use anyhow::Result;
2use chromiumoxide::Page;
3use serde_json::{Value, json};
4
5use crate::accessibility::{AXNode, render_ax_tree};
6use crate::error::BrowserError;
7use crate::schema::BrowserStep;
8
9pub fn validate_url_scheme(url: &str) -> Result<()> {
14 let scheme = url.split(':').next().unwrap_or("").to_lowercase();
15 match scheme.as_str() {
16 "http" | "https" => Ok(()),
17 other => Err(BrowserError::DisallowedScheme {
18 scheme: other.to_string(),
19 }
20 .into()),
21 }
22}
23
24fn validate_file_path(path: &str) -> Result<()> {
32 let p = std::path::Path::new(path);
33 if p.is_absolute() {
34 return Err(anyhow::anyhow!(
35 "file path \"{path}\" is not allowed: only relative paths are permitted"
36 ));
37 }
38 if p.components().any(|c| c == std::path::Component::ParentDir) {
39 return Err(anyhow::anyhow!(
40 "file path \"{path}\" is not allowed: path traversal (`..`) is not permitted"
41 ));
42 }
43 Ok(())
44}
45
46pub struct StepContext<'a> {
49 pub page: &'a Page,
50 pub step_index: usize,
51 pub total_steps: usize,
52 pub global_timeout_ms: u64,
53}
54
55pub async fn execute_steps(
58 page: &Page,
59 steps: &[BrowserStep],
60 global_timeout_ms: u64,
61 on_failure_screenshot: bool,
62) -> Result<Value> {
63 let total = steps.len();
64 let mut last_result = json!({"ok": true});
65
66 for (i, step) in steps.iter().enumerate() {
67 let ctx = StepContext {
68 page,
69 step_index: i,
70 total_steps: total,
71 global_timeout_ms,
72 };
73 let timeout_duration = std::time::Duration::from_millis(step.timeout_ms(global_timeout_ms));
74
75 let outcome = tokio::time::timeout(timeout_duration, execute_step(&ctx, step)).await;
76
77 match outcome {
78 Ok(Ok(val)) => last_result = val,
79 Ok(Err(e)) => {
80 if step.is_optional() {
81 tracing::warn!(
82 "optional browser step {} ({}) failed (skipping): {e}",
83 i,
84 step.action_name()
85 );
86 continue;
87 }
88 if on_failure_screenshot {
89 attempt_failure_screenshot(page).await;
90 }
91 return Err(e);
92 }
93 Err(_elapsed) => {
94 let timeout_ms = step.timeout_ms(global_timeout_ms);
95 let e: anyhow::Error = BrowserError::Timeout {
96 step: i,
97 action: step.action_name().into(),
98 timeout_ms,
99 }
100 .into();
101 if step.is_optional() {
102 tracing::warn!(
103 "optional browser step {} ({}) timed out (skipping)",
104 i,
105 step.action_name()
106 );
107 continue;
108 }
109 if on_failure_screenshot {
110 attempt_failure_screenshot(page).await;
111 }
112 return Err(e);
113 }
114 }
115 }
116
117 Ok(last_result)
118}
119
120async fn attempt_failure_screenshot(page: &Page) {
123 let params = chromiumoxide::page::ScreenshotParams::builder().build();
124 if let Ok(Ok(bytes)) =
125 tokio::time::timeout(std::time::Duration::from_secs(2), page.screenshot(params)).await
126 {
127 let path = std::env::temp_dir().join(format!(
128 "earl-browser-failure-{}.png",
129 chrono::Utc::now().timestamp_millis()
130 ));
131 if let Ok(()) = std::fs::write(&path, &bytes) {
132 #[cfg(unix)]
134 {
135 use std::os::unix::fs::PermissionsExt;
136 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
137 }
138 eprintln!("diagnostic screenshot saved: {}", path.display());
139 }
140 }
141}
142
143pub async fn execute_step(ctx: &StepContext<'_>, step: &BrowserStep) -> Result<Value> {
146 match step {
147 BrowserStep::Navigate {
148 url,
149 expected_status,
150 ..
151 } => step_navigate(ctx, url, *expected_status).await,
152 BrowserStep::NavigateBack { .. } => step_navigate_back(ctx).await,
153 BrowserStep::NavigateForward { .. } => step_navigate_forward(ctx).await,
154 BrowserStep::Reload { .. } => step_reload(ctx).await,
155 BrowserStep::Snapshot { .. } => step_snapshot(ctx).await,
156 BrowserStep::Screenshot {
157 path, full_page, ..
158 } => step_screenshot(ctx, path.as_deref(), Some(*full_page)).await,
159 BrowserStep::Click {
160 r#ref,
161 selector,
162 double_click,
163 ..
164 } => step_click(ctx, r#ref.as_deref(), selector.as_deref(), *double_click).await,
165 BrowserStep::Hover {
166 r#ref, selector, ..
167 } => step_hover(ctx, r#ref.as_deref(), selector.as_deref()).await,
168 BrowserStep::Fill {
169 r#ref,
170 selector,
171 text,
172 submit,
173 ..
174 } => step_fill(ctx, r#ref.as_deref(), selector.as_deref(), text, *submit).await,
175 BrowserStep::SelectOption {
176 r#ref,
177 selector,
178 values,
179 ..
180 } => step_select_option(ctx, r#ref.as_deref(), selector.as_deref(), values).await,
181 BrowserStep::PressKey { key, .. } => step_press_key(ctx, key).await,
182 BrowserStep::Check {
183 r#ref, selector, ..
184 } => step_set_checked(ctx, r#ref.as_deref(), selector.as_deref(), true).await,
185 BrowserStep::Uncheck {
186 r#ref, selector, ..
187 } => step_set_checked(ctx, r#ref.as_deref(), selector.as_deref(), false).await,
188 BrowserStep::Drag {
189 start_ref,
190 start_selector,
191 end_ref,
192 end_selector,
193 ..
194 } => {
195 step_drag(
196 ctx,
197 start_ref.as_deref(),
198 start_selector.as_deref(),
199 end_ref.as_deref(),
200 end_selector.as_deref(),
201 )
202 .await
203 }
204 BrowserStep::FillForm { fields, .. } => step_fill_form(ctx, fields).await,
205 BrowserStep::MouseMove { x, y, .. } => step_mouse_move(ctx, *x, *y).await,
206 BrowserStep::MouseClick { x, y, button, .. } => {
207 step_mouse_click(ctx, *x, *y, button.as_deref()).await
208 }
209 BrowserStep::MouseDrag {
210 start_x,
211 start_y,
212 end_x,
213 end_y,
214 ..
215 } => step_mouse_drag(ctx, *start_x, *start_y, *end_x, *end_y).await,
216 BrowserStep::MouseDown { button, .. } => {
217 step_mouse_button(ctx, button.as_deref(), true).await
218 }
219 BrowserStep::MouseUp { button, .. } => {
220 step_mouse_button(ctx, button.as_deref(), false).await
221 }
222 BrowserStep::MouseWheel {
223 delta_x, delta_y, ..
224 } => step_mouse_wheel(ctx, *delta_x, *delta_y).await,
225
226 BrowserStep::WaitFor {
228 time,
229 text,
230 text_gone,
231 timeout_ms,
232 ..
233 } => {
234 step_wait_for(
235 ctx,
236 *time,
237 text.as_deref(),
238 text_gone.as_deref(),
239 timeout_ms.unwrap_or(ctx.global_timeout_ms),
240 )
241 .await
242 }
243 BrowserStep::VerifyElementVisible {
244 role,
245 accessible_name,
246 ..
247 } => step_verify_element_visible(ctx, role.as_deref(), accessible_name.as_deref()).await,
248 BrowserStep::VerifyTextVisible { text, .. } => step_verify_text_visible(ctx, text).await,
249 BrowserStep::VerifyListVisible { r#ref, items, .. } => {
250 if r#ref.is_some() {
251 return Err(anyhow::anyhow!(
252 "browser step {} (verify_list_visible): ref-based targeting is not yet \
253 implemented; omit the ref field to match against the full page text",
254 ctx.step_index
255 ));
256 }
257 step_verify_list_visible(ctx, items).await
258 }
259 BrowserStep::VerifyValue { r#ref, value, .. } => {
260 if r#ref.is_some() {
261 return Err(anyhow::anyhow!(
262 "browser step {} (verify_value): ref-based targeting is not yet \
263 implemented; omit the ref field to match against the active element",
264 ctx.step_index
265 ));
266 }
267 step_verify_value(ctx, value).await
268 }
269
270 BrowserStep::Evaluate { function, .. } => step_evaluate(ctx, function).await,
272 BrowserStep::RunCode { code, .. } => step_run_code(ctx, code).await,
273
274 BrowserStep::Tabs {
276 operation, index, ..
277 } => step_tabs(ctx, operation, *index).await,
278 BrowserStep::Resize { width, height, .. } => step_resize(ctx, *width, *height).await,
279 BrowserStep::Close { .. } => step_close(ctx).await,
280
281 BrowserStep::ConsoleMessages { .. } => {
283 Ok(json!({"messages": [], "note": "console_messages: not yet implemented"}))
284 }
285 BrowserStep::ConsoleClear { .. } => {
286 Ok(json!({"ok": true, "note": "console_clear: not yet implemented"}))
287 }
288 BrowserStep::NetworkRequests { .. } => {
289 Ok(json!({"requests": [], "note": "network_requests: not yet implemented"}))
290 }
291 BrowserStep::NetworkClear { .. } => {
292 Ok(json!({"ok": true, "note": "network_clear: not yet implemented"}))
293 }
294 BrowserStep::Route { .. } => Ok(json!({"ok": true, "note": "route: not yet implemented"})),
295 BrowserStep::RouteList { .. } => {
296 Ok(json!({"routes": [], "note": "route_list: not yet implemented"}))
297 }
298 BrowserStep::Unroute { .. } => {
299 Ok(json!({"ok": true, "note": "unroute: not yet implemented"}))
300 }
301
302 BrowserStep::CookieList { domain, .. } => step_cookie_list(ctx, domain.as_deref()).await,
304 BrowserStep::CookieGet { name, .. } => step_cookie_get(ctx, name).await,
305 BrowserStep::CookieSet {
306 name,
307 value,
308 domain,
309 path,
310 expires,
311 http_only,
312 secure,
313 ..
314 } => {
315 step_cookie_set(
316 ctx,
317 name,
318 value,
319 domain.as_deref(),
320 path.as_deref(),
321 *expires,
322 *http_only,
323 *secure,
324 )
325 .await
326 }
327 BrowserStep::CookieDelete { name, .. } => step_cookie_delete(ctx, name).await,
328 BrowserStep::CookieClear { .. } => step_cookie_clear(ctx).await,
329
330 BrowserStep::LocalStorageGet { key, .. } => step_storage_get(ctx, "local", key).await,
332 BrowserStep::LocalStorageSet { key, value, .. } => {
333 step_storage_set(ctx, "local", key, value).await
334 }
335 BrowserStep::LocalStorageDelete { key, .. } => step_storage_delete(ctx, "local", key).await,
336 BrowserStep::LocalStorageClear { .. } => step_storage_clear(ctx, "local").await,
337 BrowserStep::SessionStorageGet { key, .. } => step_storage_get(ctx, "session", key).await,
338 BrowserStep::SessionStorageSet { key, value, .. } => {
339 step_storage_set(ctx, "session", key, value).await
340 }
341 BrowserStep::SessionStorageDelete { key, .. } => {
342 step_storage_delete(ctx, "session", key).await
343 }
344 BrowserStep::SessionStorageClear { .. } => step_storage_clear(ctx, "session").await,
345 BrowserStep::StorageState { path, .. } => step_storage_state(ctx, path.as_deref()).await,
346 BrowserStep::SetStorageState { path, .. } => step_set_storage_state(ctx, path).await,
347
348 BrowserStep::FileUpload { .. } => {
350 Ok(json!({"ok": true, "note": "file_upload: not yet implemented"}))
351 }
352 BrowserStep::HandleDialog {
353 accept,
354 prompt_text,
355 ..
356 } => step_handle_dialog(ctx, *accept, prompt_text.as_deref()).await,
357 BrowserStep::Download { .. } => {
358 Ok(json!({"ok": true, "note": "download: not yet implemented"}))
359 }
360
361 BrowserStep::PdfSave { path, .. } => step_pdf_save(ctx, path.as_deref()).await,
363 BrowserStep::StartVideo { .. } => {
364 Ok(json!({"ok": true, "note": "video recording: not yet implemented"}))
365 }
366 BrowserStep::StopVideo { .. } => {
367 Ok(json!({"ok": true, "note": "video recording: not yet implemented"}))
368 }
369 BrowserStep::StartTracing { .. } => {
370 Ok(json!({"ok": true, "note": "tracing: not yet implemented"}))
371 }
372 BrowserStep::StopTracing { .. } => {
373 Ok(json!({"ok": true, "note": "tracing: not yet implemented"}))
374 }
375 BrowserStep::GenerateLocator { r#ref, .. } => step_generate_locator(ctx, r#ref).await,
376 }
377}
378
379async fn step_navigate(
382 ctx: &StepContext<'_>,
383 url: &str,
384 expected_status: Option<u16>,
385) -> Result<Value> {
386 validate_url_scheme(url)?;
387
388 ctx.page
389 .goto(url)
390 .await
391 .map_err(|e| anyhow::anyhow!("navigate to {url} failed: {e}"))?;
392
393 if let Some(expected) = expected_status {
394 let actual = ctx
397 .page
398 .evaluate("window.performance.getEntriesByType('navigation')[0]?.responseStatus ?? 0")
399 .await
400 .map_err(|e| anyhow::anyhow!("navigate status check failed: {e}"))?
401 .into_value::<serde_json::Value>()
402 .ok()
403 .and_then(|v| v.as_u64())
404 .unwrap_or(0) as u16;
405
406 if actual != expected {
407 return Err(BrowserError::AssertionFailed {
408 step: ctx.step_index,
409 action: "navigate".to_string(),
410 message: format!("expected HTTP status {expected}, got {actual} for {url}"),
411 }
412 .into());
413 }
414 }
415
416 Ok(json!({ "ok": true, "url": url }))
417}
418
419async fn step_navigate_back(ctx: &StepContext<'_>) -> Result<Value> {
420 use chromiumoxide::cdp::browser_protocol::page::{
421 GetNavigationHistoryParams, NavigateToHistoryEntryParams,
422 };
423
424 let history = ctx
425 .page
426 .execute(GetNavigationHistoryParams::default())
427 .await
428 .map_err(|e| anyhow::anyhow!("get navigation history failed: {e}"))?;
429
430 let current_index = history.result.current_index;
431 if current_index <= 0 {
432 return Ok(json!({ "ok": true }));
434 }
435 let target_index = (current_index - 1) as usize;
436 let entries = &history.result.entries;
437 if target_index >= entries.len() {
438 return Ok(json!({ "ok": true }));
439 }
440 let entry_id = entries[target_index].id;
441
442 ctx.page
443 .execute(NavigateToHistoryEntryParams::new(entry_id))
444 .await
445 .map_err(|e| anyhow::anyhow!("navigate back failed: {e}"))?;
446
447 ctx.page
448 .wait_for_navigation()
449 .await
450 .map_err(|e| anyhow::anyhow!("wait for navigation after go-back failed: {e}"))?;
451
452 Ok(json!({ "ok": true }))
453}
454
455async fn step_navigate_forward(ctx: &StepContext<'_>) -> Result<Value> {
456 use chromiumoxide::cdp::browser_protocol::page::{
457 GetNavigationHistoryParams, NavigateToHistoryEntryParams,
458 };
459
460 let history = ctx
461 .page
462 .execute(GetNavigationHistoryParams::default())
463 .await
464 .map_err(|e| anyhow::anyhow!("get navigation history failed: {e}"))?;
465
466 let current_index = history.result.current_index as usize;
467 let entries = &history.result.entries;
468 let next_index = current_index + 1;
469 if next_index >= entries.len() {
470 return Ok(json!({ "ok": true }));
472 }
473 let entry_id = entries[next_index].id;
474
475 ctx.page
476 .execute(NavigateToHistoryEntryParams::new(entry_id))
477 .await
478 .map_err(|e| anyhow::anyhow!("navigate forward failed: {e}"))?;
479
480 ctx.page
481 .wait_for_navigation()
482 .await
483 .map_err(|e| anyhow::anyhow!("wait for navigation after go-forward failed: {e}"))?;
484
485 Ok(json!({ "ok": true }))
486}
487
488async fn step_reload(ctx: &StepContext<'_>) -> Result<Value> {
489 ctx.page
490 .reload()
491 .await
492 .map_err(|e| anyhow::anyhow!("reload failed: {e}"))?;
493
494 Ok(json!({ "ok": true }))
495}
496
497async fn step_snapshot(ctx: &StepContext<'_>) -> Result<Value> {
500 use chromiumoxide::cdp::browser_protocol::accessibility::GetFullAxTreeParams;
501
502 let response = ctx
503 .page
504 .execute(GetFullAxTreeParams::default())
505 .await
506 .map_err(|e| anyhow::anyhow!("get full AX tree failed: {e}"))?;
507
508 let cdp_nodes = response.result.nodes;
509
510 use std::collections::HashMap;
512
513 let mut node_map: HashMap<
515 String,
516 &chromiumoxide::cdp::browser_protocol::accessibility::AxNode,
517 > = HashMap::new();
518 for n in &cdp_nodes {
519 node_map.insert(n.node_id.inner().to_string(), n);
520 }
521
522 const MAX_TREE_DEPTH: usize = 80;
528 fn build_tree(
529 node_id_str: &str,
530 node_map: &HashMap<String, &chromiumoxide::cdp::browser_protocol::accessibility::AxNode>,
531 depth: usize,
532 ) -> Option<AXNode> {
533 if depth > MAX_TREE_DEPTH {
534 return None;
535 }
536 let cdp = node_map.get(node_id_str)?;
537 if cdp.ignored {
538 return None;
539 }
540
541 let role = cdp
542 .role
543 .as_ref()
544 .and_then(|v| v.value.as_ref())
545 .and_then(|v| v.as_str().map(|s| s.to_string()))
546 .unwrap_or_else(|| "unknown".to_string());
547
548 let name = cdp
549 .name
550 .as_ref()
551 .and_then(|v| v.value.as_ref())
552 .and_then(|v| v.as_str().map(|s| s.to_string()))
553 .unwrap_or_default();
554
555 let backend_node_id = cdp
556 .backend_dom_node_id
557 .as_ref()
558 .map(|id| *id.inner() as u64)
559 .unwrap_or(0);
560
561 let children = cdp
562 .child_ids
563 .as_deref()
564 .unwrap_or(&[])
565 .iter()
566 .filter_map(|child_id| build_tree(child_id.inner(), node_map, depth + 1))
567 .collect();
568
569 Some(AXNode {
570 backend_node_id,
571 role,
572 name,
573 children,
574 })
575 }
576
577 let roots: Vec<AXNode> = cdp_nodes
579 .iter()
580 .filter(|n| {
581 !n.ignored
582 && n.parent_id
583 .as_ref()
584 .map(|pid| !node_map.contains_key(pid.inner()))
585 .unwrap_or(true)
586 })
587 .filter_map(|n| build_tree(n.node_id.inner(), &node_map, 0))
588 .collect();
589
590 let max_nodes = 5000;
591 let (markdown, refs) = render_ax_tree(&roots, max_nodes);
592
593 Ok(json!({
594 "text": markdown,
595 "refs": refs,
596 }))
597}
598
599async fn step_screenshot(
600 ctx: &StepContext<'_>,
601 path: Option<&str>,
602 full_page: Option<bool>,
603) -> Result<Value> {
604 if let Some(p) = path {
605 validate_file_path(p)?;
606 }
607
608 let params = chromiumoxide::page::ScreenshotParams::builder()
611 .full_page(full_page.unwrap_or(false))
612 .build();
613
614 let bytes = ctx
615 .page
616 .screenshot(params)
617 .await
618 .map_err(|e| anyhow::anyhow!("screenshot failed: {e}"))?;
619
620 if let Some(p) = path {
621 tokio::fs::write(p, &bytes)
623 .await
624 .map_err(|e| anyhow::anyhow!("screenshot write {p}: {e}"))?;
625 Ok(json!({"path": p}))
626 } else {
627 let data = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);
629 Ok(json!({"data": data}))
630 }
631}
632
633async fn find_element_by_selector(
640 ctx: &StepContext<'_>,
641 selector: Option<&str>,
642 ref_: Option<&str>,
643 action: &str,
644) -> Result<chromiumoxide::element::Element> {
645 let sel = match selector {
646 Some(s) => s,
647 None => {
648 if ref_.is_some() {
649 return Err(anyhow::anyhow!(
650 "browser step {} ({action}): 'ref' targeting requires session mode \
651 (not yet available in this version); use 'selector' instead",
652 ctx.step_index
653 ));
654 }
655 return Err(BrowserError::ElementNotFound {
656 step: ctx.step_index,
657 action: action.to_string(),
658 selector: "(none provided)".to_string(),
659 completed: ctx.step_index,
660 total: ctx.total_steps,
661 }
662 .into());
663 }
664 };
665
666 ctx.page.find_element(sel).await.map_err(|_| {
667 BrowserError::ElementNotFound {
668 step: ctx.step_index,
669 action: action.to_string(),
670 selector: sel.to_string(),
671 completed: ctx.step_index,
672 total: ctx.total_steps,
673 }
674 .into()
675 })
676}
677
678async fn step_click(
679 ctx: &StepContext<'_>,
680 ref_: Option<&str>,
681 selector: Option<&str>,
682 double_click: bool,
683) -> Result<Value> {
684 let el = find_element_by_selector(ctx, selector, ref_, "click").await?;
685 el.click()
686 .await
687 .map_err(|e| anyhow::anyhow!("click failed: {e}"))?;
688 if double_click {
689 el.click()
690 .await
691 .map_err(|e| anyhow::anyhow!("double-click second click failed: {e}"))?;
692 el.call_js_fn(
695 "function() { this.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true})); }",
696 false,
697 )
698 .await
699 .map_err(|e| anyhow::anyhow!("double-click dblclick event dispatch failed: {e}"))?;
700 }
701 Ok(json!({"ok": true}))
702}
703
704async fn step_hover(
705 ctx: &StepContext<'_>,
706 ref_: Option<&str>,
707 selector: Option<&str>,
708) -> Result<Value> {
709 let el = find_element_by_selector(ctx, selector, ref_, "hover").await?;
710 el.hover()
711 .await
712 .map_err(|e| anyhow::anyhow!("hover failed: {e}"))?;
713 Ok(json!({"ok": true}))
714}
715
716async fn step_fill(
717 ctx: &StepContext<'_>,
718 ref_: Option<&str>,
719 selector: Option<&str>,
720 text: &str,
721 submit: Option<bool>,
722) -> Result<Value> {
723 let el = find_element_by_selector(ctx, selector, ref_, "fill").await?;
724 el.click()
725 .await
726 .map_err(|e| anyhow::anyhow!("fill click: {e}"))?;
727 el.call_js_fn(
729 "function() { this.value = ''; this.dispatchEvent(new Event('input', {bubbles: true})); }",
730 false,
731 )
732 .await
733 .map_err(|e| anyhow::anyhow!("fill clear value: {e}"))?;
734 el.type_str(text)
735 .await
736 .map_err(|e| anyhow::anyhow!("fill type_str: {e}"))?;
737 if submit.unwrap_or(false) {
738 el.press_key("Enter")
739 .await
740 .map_err(|e| anyhow::anyhow!("fill submit: {e}"))?;
741 }
742 Ok(json!({"ok": true}))
743}
744
745async fn step_select_option(
746 ctx: &StepContext<'_>,
747 _ref_: Option<&str>,
748 selector: Option<&str>,
749 values: &[String],
750) -> Result<Value> {
751 let sel = selector.unwrap_or("");
752 let values_json = serde_json::to_string(values)?;
753 let sel_json = serde_json::to_string(sel)?;
754 ctx.page
755 .evaluate(format!(
756 r#"(function() {{
757 var el = document.querySelector({sel_json});
758 if (!el) return false;
759 Array.from(el.options).forEach(function(o) {{
760 o.selected = {values_json}.indexOf(o.value) !== -1;
761 }});
762 el.dispatchEvent(new Event('change', {{bubbles: true}}));
763 return true;
764 }})()"#,
765 ))
766 .await
767 .map_err(|e| anyhow::anyhow!("select_option: {e}"))?;
768 Ok(json!({"ok": true}))
769}
770
771async fn step_press_key(ctx: &StepContext<'_>, key: &str) -> Result<Value> {
772 use chromiumoxide::cdp::browser_protocol::input::{
773 DispatchKeyEventParams, DispatchKeyEventType,
774 };
775 use chromiumoxide::keys;
776
777 let key = keys::get_key_definition(key)
778 .ok_or_else(|| anyhow::anyhow!("press_key: unknown key '{key}'"))?;
779 let mut command = DispatchKeyEventParams::builder()
780 .key(key.key)
781 .code(key.code);
782 let key_down_type = if let Some(text) = key.text {
783 command = command.text(text);
784 DispatchKeyEventType::KeyDown
785 } else if key.key.len() == 1 {
786 command = command.text(key.key);
787 DispatchKeyEventType::KeyDown
788 } else {
789 DispatchKeyEventType::RawKeyDown
790 };
791
792 ctx.page
793 .execute(command.clone().r#type(key_down_type).build().unwrap())
794 .await
795 .map_err(|e| anyhow::anyhow!("press_key key_down: {e}"))?;
796 ctx.page
797 .execute(command.r#type(DispatchKeyEventType::KeyUp).build().unwrap())
798 .await
799 .map_err(|e| anyhow::anyhow!("press_key key_up: {e}"))?;
800
801 Ok(json!({"ok": true}))
802}
803
804async fn step_set_checked(
805 ctx: &StepContext<'_>,
806 ref_: Option<&str>,
807 selector: Option<&str>,
808 checked: bool,
809) -> Result<Value> {
810 let action = if checked { "check" } else { "uncheck" };
811 let el = find_element_by_selector(ctx, selector, ref_, action).await?;
812 let result = el
814 .call_js_fn("function() { return this.checked; }", false)
815 .await
816 .map_err(|e| anyhow::anyhow!("set_checked get state: {e}"))?;
817 let current: Value = result.result.value.unwrap_or(Value::Bool(false));
818 if current.as_bool() != Some(checked) {
819 el.click()
820 .await
821 .map_err(|e| anyhow::anyhow!("set_checked click: {e}"))?;
822 }
823 Ok(json!({"ok": true}))
824}
825
826async fn step_drag(
827 ctx: &StepContext<'_>,
828 _start_ref: Option<&str>,
829 start_selector: Option<&str>,
830 _end_ref: Option<&str>,
831 end_selector: Option<&str>,
832) -> Result<Value> {
833 let start_sel = start_selector.unwrap_or("");
834 let end_sel = end_selector.unwrap_or("");
835 let start_json = serde_json::to_string(start_sel)?;
836 let end_json = serde_json::to_string(end_sel)?;
837 ctx.page
838 .evaluate(format!(
839 r#"(function() {{
840 var src = document.querySelector({start_json});
841 var dst = document.querySelector({end_json});
842 if (!src || !dst) return false;
843 src.dispatchEvent(new DragEvent('dragstart', {{bubbles: true, cancelable: true}}));
844 dst.dispatchEvent(new DragEvent('dragenter', {{bubbles: true, cancelable: true}}));
845 dst.dispatchEvent(new DragEvent('dragover', {{bubbles: true, cancelable: true}}));
846 dst.dispatchEvent(new DragEvent('drop', {{bubbles: true, cancelable: true}}));
847 src.dispatchEvent(new DragEvent('dragend', {{bubbles: true, cancelable: true}}));
848 return true;
849 }})()"#,
850 ))
851 .await
852 .map_err(|e| anyhow::anyhow!("drag: {e}"))?;
853 Ok(json!({"ok": true}))
854}
855
856async fn step_fill_form(ctx: &StepContext<'_>, fields: &[Value]) -> Result<Value> {
857 for field in fields {
858 let ref_ = field.get("ref").and_then(|v| v.as_str());
859 let selector = field.get("selector").and_then(|v| v.as_str());
860 let value = field.get("value").and_then(|v| v.as_str()).unwrap_or("");
861 let type_ = field
862 .get("type")
863 .and_then(|v| v.as_str())
864 .unwrap_or("textbox");
865 match type_ {
866 "checkbox" => {
867 let checked = value == "true" || value == "1";
868 step_set_checked(ctx, ref_, selector, checked).await?;
869 }
870 _ => {
871 step_fill(ctx, ref_, selector, value, None).await?;
872 }
873 }
874 }
875 Ok(json!({"ok": true}))
876}
877
878async fn step_mouse_move(ctx: &StepContext<'_>, x: f64, y: f64) -> Result<Value> {
881 use chromiumoxide::cdp::browser_protocol::input::{
882 DispatchMouseEventParams, DispatchMouseEventType,
883 };
884 ctx.page
885 .execute(
886 DispatchMouseEventParams::builder()
887 .r#type(DispatchMouseEventType::MouseMoved)
888 .x(x)
889 .y(y)
890 .build()
891 .unwrap(),
892 )
893 .await
894 .map_err(|e| anyhow::anyhow!("mouse_move: {e}"))?;
895 Ok(json!({"ok": true}))
896}
897
898async fn step_mouse_click(
899 ctx: &StepContext<'_>,
900 x: f64,
901 y: f64,
902 button: Option<&str>,
903) -> Result<Value> {
904 use chromiumoxide::cdp::browser_protocol::input::{
905 DispatchMouseEventParams, DispatchMouseEventType,
906 };
907 let mb = parse_mouse_button(button);
908 ctx.page
909 .execute(
910 DispatchMouseEventParams::builder()
911 .r#type(DispatchMouseEventType::MousePressed)
912 .x(x)
913 .y(y)
914 .button(mb.clone())
915 .click_count(1i64)
916 .build()
917 .unwrap(),
918 )
919 .await
920 .map_err(|e| anyhow::anyhow!("mouse_click pressed: {e}"))?;
921 ctx.page
922 .execute(
923 DispatchMouseEventParams::builder()
924 .r#type(DispatchMouseEventType::MouseReleased)
925 .x(x)
926 .y(y)
927 .button(mb)
928 .click_count(1i64)
929 .build()
930 .unwrap(),
931 )
932 .await
933 .map_err(|e| anyhow::anyhow!("mouse_click released: {e}"))?;
934 Ok(json!({"ok": true}))
935}
936
937async fn step_mouse_drag(
938 ctx: &StepContext<'_>,
939 start_x: f64,
940 start_y: f64,
941 end_x: f64,
942 end_y: f64,
943) -> Result<Value> {
944 use chromiumoxide::cdp::browser_protocol::input::{
945 DispatchMouseEventParams, DispatchMouseEventType,
946 };
947 ctx.page
948 .execute(
949 DispatchMouseEventParams::builder()
950 .r#type(DispatchMouseEventType::MousePressed)
951 .x(start_x)
952 .y(start_y)
953 .build()
954 .unwrap(),
955 )
956 .await
957 .map_err(|e| anyhow::anyhow!("mouse_drag pressed: {e}"))?;
958 ctx.page
959 .execute(
960 DispatchMouseEventParams::builder()
961 .r#type(DispatchMouseEventType::MouseMoved)
962 .x(end_x)
963 .y(end_y)
964 .build()
965 .unwrap(),
966 )
967 .await
968 .map_err(|e| anyhow::anyhow!("mouse_drag moved: {e}"))?;
969 ctx.page
970 .execute(
971 DispatchMouseEventParams::builder()
972 .r#type(DispatchMouseEventType::MouseReleased)
973 .x(end_x)
974 .y(end_y)
975 .build()
976 .unwrap(),
977 )
978 .await
979 .map_err(|e| anyhow::anyhow!("mouse_drag released: {e}"))?;
980 Ok(json!({"ok": true}))
981}
982
983async fn step_mouse_button(
984 ctx: &StepContext<'_>,
985 button: Option<&str>,
986 pressed: bool,
987) -> Result<Value> {
988 use chromiumoxide::cdp::browser_protocol::input::{
989 DispatchMouseEventParams, DispatchMouseEventType,
990 };
991 let pos: Value = ctx
993 .page
994 .evaluate("({x: window.innerWidth / 2, y: window.innerHeight / 2})")
995 .await
996 .map_err(|e| anyhow::anyhow!("mouse_button get position: {e}"))?
997 .into_value()?;
998 let x = pos["x"].as_f64().unwrap_or(400.0);
999 let y = pos["y"].as_f64().unwrap_or(300.0);
1000 let mb = parse_mouse_button(button);
1001 let evt_type = if pressed {
1002 DispatchMouseEventType::MousePressed
1003 } else {
1004 DispatchMouseEventType::MouseReleased
1005 };
1006 ctx.page
1007 .execute(
1008 DispatchMouseEventParams::builder()
1009 .r#type(evt_type)
1010 .x(x)
1011 .y(y)
1012 .button(mb)
1013 .build()
1014 .unwrap(),
1015 )
1016 .await
1017 .map_err(|e| anyhow::anyhow!("mouse_button: {e}"))?;
1018 Ok(json!({"ok": true}))
1019}
1020
1021async fn step_mouse_wheel(ctx: &StepContext<'_>, delta_x: f64, delta_y: f64) -> Result<Value> {
1022 use chromiumoxide::cdp::browser_protocol::input::{
1023 DispatchMouseEventParams, DispatchMouseEventType,
1024 };
1025 let pos: Value = ctx
1026 .page
1027 .evaluate("({x: window.innerWidth / 2, y: window.innerHeight / 2})")
1028 .await
1029 .map_err(|e| anyhow::anyhow!("mouse_wheel get position: {e}"))?
1030 .into_value()?;
1031 let x = pos["x"].as_f64().unwrap_or(400.0);
1032 let y = pos["y"].as_f64().unwrap_or(300.0);
1033 ctx.page
1034 .execute(
1035 DispatchMouseEventParams::builder()
1036 .r#type(DispatchMouseEventType::MouseWheel)
1037 .x(x)
1038 .y(y)
1039 .delta_x(delta_x)
1040 .delta_y(delta_y)
1041 .build()
1042 .unwrap(),
1043 )
1044 .await
1045 .map_err(|e| anyhow::anyhow!("mouse_wheel: {e}"))?;
1046 ctx.page
1047 .evaluate("new Promise(resolve => requestAnimationFrame(() => resolve(true)))")
1048 .await
1049 .map_err(|e| anyhow::anyhow!("mouse_wheel wait for frame: {e}"))?;
1050 Ok(json!({"ok": true}))
1051}
1052
1053async fn step_wait_for(
1056 ctx: &StepContext<'_>,
1057 time: Option<f64>,
1058 text: Option<&str>,
1059 text_gone: Option<&str>,
1060 timeout_ms: u64,
1061) -> Result<Value> {
1062 if let Some(secs) = time {
1063 tokio::time::sleep(std::time::Duration::from_secs_f64(secs)).await;
1064 }
1065
1066 if text.is_none() && text_gone.is_none() {
1067 return Ok(json!({"ok": true}));
1068 }
1069
1070 let deadline =
1071 tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms.max(200));
1072
1073 loop {
1074 if tokio::time::Instant::now() >= deadline {
1075 return Err(BrowserError::Timeout {
1076 step: ctx.step_index,
1077 action: "wait_for".into(),
1078 timeout_ms,
1079 }
1080 .into());
1081 }
1082
1083 let body_text: Value = match ctx
1084 .page
1085 .evaluate("document.body ? document.body.innerText : ''")
1086 .await
1087 {
1088 Ok(result) => result.into_value()?,
1089 Err(chromiumoxide::error::CdpError::Chrome(error))
1090 if error.code == -32000
1091 && error.message == "Cannot find context with specified id" =>
1092 {
1093 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1094 continue;
1095 }
1096 Err(error) => return Err(anyhow::anyhow!("wait_for evaluate: {error}")),
1097 };
1098 let body = body_text.as_str().unwrap_or("");
1099
1100 if let Some(t) = text {
1101 if body.contains(t) {
1102 if let Some(tg) = text_gone {
1104 if !body.contains(tg) {
1105 return Ok(json!({"ok": true}));
1106 }
1107 } else {
1108 return Ok(json!({"ok": true}));
1109 }
1110 }
1111 } else if let Some(tg) = text_gone
1112 && !body.contains(tg)
1113 {
1114 return Ok(json!({"ok": true}));
1115 }
1116
1117 let now = tokio::time::Instant::now();
1119 if now >= deadline {
1120 return Err(BrowserError::Timeout {
1121 step: ctx.step_index,
1122 action: "wait_for".into(),
1123 timeout_ms,
1124 }
1125 .into());
1126 }
1127
1128 let remaining = deadline - now;
1130 tokio::time::sleep(remaining.min(std::time::Duration::from_millis(200))).await;
1131 }
1132}
1133
1134async fn step_verify_text_visible(ctx: &StepContext<'_>, text: &str) -> Result<Value> {
1135 let body_text: Value = ctx
1136 .page
1137 .evaluate("document.body ? document.body.innerText : ''")
1138 .await
1139 .map_err(|e| anyhow::anyhow!("verify_text_visible evaluate: {e}"))?
1140 .into_value()?;
1141 let body = body_text.as_str().unwrap_or("");
1142 if body.contains(text) {
1143 Ok(json!({"ok": true, "text": text}))
1144 } else {
1145 Err(BrowserError::AssertionFailed {
1146 step: ctx.step_index,
1147 action: "verify_text_visible".into(),
1148 message: format!("text not found in page: {text:?}"),
1149 }
1150 .into())
1151 }
1152}
1153
1154async fn step_verify_list_visible(ctx: &StepContext<'_>, items: &[String]) -> Result<Value> {
1155 let body_text: Value = ctx
1156 .page
1157 .evaluate("document.body ? document.body.innerText : ''")
1158 .await
1159 .map_err(|e| anyhow::anyhow!("verify_list_visible evaluate: {e}"))?
1160 .into_value()?;
1161 let body = body_text.as_str().unwrap_or("");
1162 let mut missing = Vec::new();
1163 for item in items {
1164 if !body.contains(item.as_str()) {
1165 missing.push(item.as_str());
1166 }
1167 }
1168 if missing.is_empty() {
1169 Ok(json!({"ok": true}))
1170 } else {
1171 Err(BrowserError::AssertionFailed {
1172 step: ctx.step_index,
1173 action: "verify_list_visible".into(),
1174 message: format!("items not found in page: {:?}", missing),
1175 }
1176 .into())
1177 }
1178}
1179
1180async fn step_verify_element_visible(
1181 ctx: &StepContext<'_>,
1182 role: Option<&str>,
1183 accessible_name: Option<&str>,
1184) -> Result<Value> {
1185 let role_json = serde_json::to_string(role.unwrap_or(""))?;
1187 let name_json = serde_json::to_string(accessible_name.unwrap_or(""))?;
1188 let result: Value = ctx
1189 .page
1190 .evaluate(format!(
1191 r#"(function() {{
1192 var role = {role_json};
1193 var name = {name_json};
1194 var els = document.querySelectorAll('*');
1195 for (var i = 0; i < els.length; i++) {{
1196 var el = els[i];
1197 var elRole = el.getAttribute('role') || el.tagName.toLowerCase();
1198 var elName = el.getAttribute('aria-label') || el.textContent || '';
1199 if ((role === '' || elRole === role) && (name === '' || elName.trim().indexOf(name) !== -1)) {{
1200 return true;
1201 }}
1202 }}
1203 return false;
1204 }})()"#,
1205 ))
1206 .await
1207 .map_err(|e| anyhow::anyhow!("verify_element_visible evaluate: {e}"))?
1208 .into_value()?;
1209
1210 if result.as_bool().unwrap_or(false) {
1211 Ok(json!({"ok": true}))
1212 } else {
1213 Err(BrowserError::AssertionFailed {
1214 step: ctx.step_index,
1215 action: "verify_element_visible".into(),
1216 message: format!(
1217 "element not found — role={:?} name={:?}",
1218 role, accessible_name
1219 ),
1220 }
1221 .into())
1222 }
1223}
1224
1225async fn step_verify_value(ctx: &StepContext<'_>, expected: &str) -> Result<Value> {
1226 let expected_json = serde_json::to_string(expected)?;
1228 let result: Value = ctx
1229 .page
1230 .evaluate(
1231 r#"(function() {
1232 var el = document.activeElement || document.querySelector('input,textarea,select');
1233 if (!el) return null;
1234 return el.value !== undefined ? el.value : el.textContent;
1235 })()"#,
1236 )
1237 .await
1238 .map_err(|e| anyhow::anyhow!("verify_value evaluate: {e}"))?
1239 .into_value()?;
1240
1241 let actual = result.as_str().unwrap_or("");
1242 if actual == expected {
1243 Ok(json!({"ok": true, "value": actual}))
1244 } else {
1245 Err(BrowserError::AssertionFailed {
1246 step: ctx.step_index,
1247 action: "verify_value".into(),
1248 message: format!("expected value {expected_json}, got {:?}", actual),
1249 }
1250 .into())
1251 }
1252}
1253
1254async fn step_evaluate(ctx: &StepContext<'_>, function: &str) -> Result<Value> {
1257 let result: Value = ctx
1258 .page
1259 .evaluate(function)
1260 .await
1261 .map_err(|e| anyhow::anyhow!("evaluate: {e}"))?
1262 .into_value()?;
1263 Ok(json!({"value": result}))
1264}
1265
1266async fn step_run_code(ctx: &StepContext<'_>, code: &str) -> Result<Value> {
1267 let wrapped = format!("(async () => {{ {} }})()", code);
1268 let result: Value = ctx
1269 .page
1270 .evaluate(wrapped)
1271 .await
1272 .map_err(|e| anyhow::anyhow!("run_code: {e}"))?
1273 .into_value()?;
1274 Ok(json!({"value": result}))
1275}
1276
1277async fn step_tabs(ctx: &StepContext<'_>, operation: &str, _index: Option<usize>) -> Result<Value> {
1280 match operation {
1281 "list" => {
1282 let url: Value = ctx
1283 .page
1284 .evaluate("window.location.href")
1285 .await
1286 .map_err(|e| anyhow::anyhow!("tabs list: {e}"))?
1287 .into_value()?;
1288 Ok(json!({"tabs": [{"url": url, "index": 0, "active": true}]}))
1289 }
1290 _ => Ok(json!({
1291 "ok": true,
1292 "note": "full tab management (new/select/close) requires session mode"
1293 })),
1294 }
1295}
1296
1297async fn step_resize(ctx: &StepContext<'_>, width: u32, height: u32) -> Result<Value> {
1298 use chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams;
1299 ctx.page
1300 .execute(SetDeviceMetricsOverrideParams::new(
1301 width as i64,
1302 height as i64,
1303 1.0_f64,
1304 false,
1305 ))
1306 .await
1307 .map_err(|e| anyhow::anyhow!("resize: {e}"))?;
1308 Ok(json!({"ok": true, "width": width, "height": height}))
1309}
1310
1311async fn step_close(ctx: &StepContext<'_>) -> Result<Value> {
1312 use chromiumoxide::cdp::browser_protocol::page::CloseParams;
1313 ctx.page
1314 .execute(CloseParams::default())
1315 .await
1316 .map_err(|e| anyhow::anyhow!("close: {e}"))?;
1317 Ok(json!({"ok": true}))
1318}
1319
1320async fn step_cookie_list(ctx: &StepContext<'_>, domain: Option<&str>) -> Result<Value> {
1323 use chromiumoxide::cdp::browser_protocol::network::GetCookiesParams;
1324 let result = ctx
1325 .page
1326 .execute(GetCookiesParams::default())
1327 .await
1328 .map_err(|e| anyhow::anyhow!("cookie_list: {e}"))?;
1329 let cookies: Vec<Value> = result
1330 .result
1331 .cookies
1332 .iter()
1333 .filter(|c| domain.is_none_or(|d| c.domain.contains(d)))
1334 .map(|c| {
1335 json!({
1336 "name": c.name,
1337 "value": c.value,
1338 "domain": c.domain,
1339 "path": c.path,
1340 "expires": c.expires,
1341 "http_only": c.http_only,
1342 "secure": c.secure,
1343 "session": c.session,
1344 })
1345 })
1346 .collect();
1347 Ok(json!({"cookies": cookies}))
1348}
1349
1350async fn step_cookie_get(ctx: &StepContext<'_>, name: &str) -> Result<Value> {
1351 use chromiumoxide::cdp::browser_protocol::network::GetCookiesParams;
1352 let result = ctx
1353 .page
1354 .execute(GetCookiesParams::default())
1355 .await
1356 .map_err(|e| anyhow::anyhow!("cookie_get: {e}"))?;
1357 let cookie = result.result.cookies.iter().find(|c| c.name == name);
1358 match cookie {
1359 Some(c) => Ok(json!({
1360 "name": c.name,
1361 "value": c.value,
1362 "domain": c.domain,
1363 "path": c.path,
1364 "expires": c.expires,
1365 "http_only": c.http_only,
1366 "secure": c.secure,
1367 })),
1368 None => Ok(json!({"name": name, "value": null})),
1369 }
1370}
1371
1372#[allow(clippy::too_many_arguments)]
1373async fn step_cookie_set(
1374 ctx: &StepContext<'_>,
1375 name: &str,
1376 value: &str,
1377 domain: Option<&str>,
1378 path: Option<&str>,
1379 expires: Option<f64>,
1380 http_only: bool,
1381 secure: bool,
1382) -> Result<Value> {
1383 use chromiumoxide::cdp::browser_protocol::network::SetCookieParams;
1384 let mut params = SetCookieParams::new(name, value);
1385 if let Some(d) = domain {
1386 params.domain = Some(d.to_string());
1387 }
1388 if let Some(p) = path {
1389 params.path = Some(p.to_string());
1390 }
1391 if let Some(e) = expires {
1392 use chromiumoxide::cdp::browser_protocol::network::TimeSinceEpoch;
1393 params.expires = Some(TimeSinceEpoch::new(e));
1394 }
1395 params.http_only = Some(http_only);
1396 params.secure = Some(secure);
1397 ctx.page
1398 .execute(params)
1399 .await
1400 .map_err(|e| anyhow::anyhow!("cookie_set: {e}"))?;
1401 Ok(json!({"ok": true, "name": name}))
1402}
1403
1404async fn step_cookie_delete(ctx: &StepContext<'_>, name: &str) -> Result<Value> {
1405 use chromiumoxide::cdp::browser_protocol::network::DeleteCookiesParams;
1406 let url = ctx.page.url().await.ok().flatten();
1408 let mut params = DeleteCookiesParams::new(name);
1409 params.url = url;
1410 ctx.page
1411 .execute(params)
1412 .await
1413 .map_err(|e| anyhow::anyhow!("cookie_delete: {e}"))?;
1414 Ok(json!({"ok": true, "name": name}))
1415}
1416
1417async fn step_cookie_clear(ctx: &StepContext<'_>) -> Result<Value> {
1418 use chromiumoxide::cdp::browser_protocol::network::ClearBrowserCookiesParams;
1419 ctx.page
1420 .execute(ClearBrowserCookiesParams::default())
1421 .await
1422 .map_err(|e| anyhow::anyhow!("cookie_clear: {e}"))?;
1423 Ok(json!({"ok": true}))
1424}
1425
1426fn storage_js_obj(kind: &str) -> &'static str {
1430 if kind == "session" {
1431 "sessionStorage"
1432 } else {
1433 "localStorage"
1434 }
1435}
1436
1437async fn step_storage_get(ctx: &StepContext<'_>, kind: &str, key: &str) -> Result<Value> {
1438 let key_json = serde_json::to_string(key)?;
1439 let obj = storage_js_obj(kind);
1440 let val: Value = ctx
1441 .page
1442 .evaluate(format!("{obj}.getItem({key_json})"))
1443 .await
1444 .map_err(|e| anyhow::anyhow!("storage_get: {e}"))?
1445 .into_value()?;
1446 Ok(json!({"key": key, "value": val}))
1447}
1448
1449async fn step_storage_set(
1450 ctx: &StepContext<'_>,
1451 kind: &str,
1452 key: &str,
1453 value: &str,
1454) -> Result<Value> {
1455 let key_json = serde_json::to_string(key)?;
1456 let val_json = serde_json::to_string(value)?;
1457 let obj = storage_js_obj(kind);
1458 ctx.page
1459 .evaluate(format!("{obj}.setItem({key_json}, {val_json})"))
1460 .await
1461 .map_err(|e| anyhow::anyhow!("storage_set: {e}"))?;
1462 Ok(json!({"ok": true, "key": key}))
1463}
1464
1465async fn step_storage_delete(ctx: &StepContext<'_>, kind: &str, key: &str) -> Result<Value> {
1466 let key_json = serde_json::to_string(key)?;
1467 let obj = storage_js_obj(kind);
1468 ctx.page
1469 .evaluate(format!("{obj}.removeItem({key_json})"))
1470 .await
1471 .map_err(|e| anyhow::anyhow!("storage_delete: {e}"))?;
1472 Ok(json!({"ok": true, "key": key}))
1473}
1474
1475async fn step_storage_clear(ctx: &StepContext<'_>, kind: &str) -> Result<Value> {
1476 let obj = storage_js_obj(kind);
1477 ctx.page
1478 .evaluate(format!("{obj}.clear()"))
1479 .await
1480 .map_err(|e| anyhow::anyhow!("storage_clear: {e}"))?;
1481 Ok(json!({"ok": true}))
1482}
1483
1484async fn step_storage_state(ctx: &StepContext<'_>, path: Option<&str>) -> Result<Value> {
1485 use chromiumoxide::cdp::browser_protocol::network::GetCookiesParams;
1486
1487 let cookie_result = ctx
1489 .page
1490 .execute(GetCookiesParams::default())
1491 .await
1492 .map_err(|e| anyhow::anyhow!("storage_state cookies: {e}"))?;
1493 let cookies: Vec<Value> = cookie_result
1494 .result
1495 .cookies
1496 .iter()
1497 .map(|c| {
1498 json!({
1499 "name": c.name,
1500 "value": c.value,
1501 "domain": c.domain,
1502 "path": c.path,
1503 "expires": c.expires,
1504 "http_only": c.http_only,
1505 "secure": c.secure,
1506 })
1507 })
1508 .collect();
1509
1510 let ls: Value = ctx
1512 .page
1513 .evaluate(
1514 r#"(function() {
1515 var out = {};
1516 for (var i = 0; i < localStorage.length; i++) {
1517 var k = localStorage.key(i);
1518 out[k] = localStorage.getItem(k);
1519 }
1520 return out;
1521 })()"#,
1522 )
1523 .await
1524 .map_err(|e| anyhow::anyhow!("storage_state localStorage: {e}"))?
1525 .into_value()?;
1526
1527 let state = json!({"cookies": cookies, "local_storage": ls});
1528
1529 if let Some(p) = path {
1530 validate_file_path(p)?;
1531 let bytes = serde_json::to_vec_pretty(&state)?;
1532 tokio::fs::write(p, &bytes)
1533 .await
1534 .map_err(|e| anyhow::anyhow!("storage_state write {p}: {e}"))?;
1535 Ok(json!({"path": p, "cookies": cookies.len()}))
1536 } else {
1537 Ok(state)
1538 }
1539}
1540
1541async fn step_set_storage_state(ctx: &StepContext<'_>, path: &str) -> Result<Value> {
1542 validate_file_path(path)?;
1543 let bytes = tokio::fs::read(path)
1544 .await
1545 .map_err(|e| anyhow::anyhow!("set_storage_state read {path}: {e}"))?;
1546 let state: Value = serde_json::from_slice(&bytes)
1547 .map_err(|e| anyhow::anyhow!("set_storage_state parse: {e}"))?;
1548
1549 if let Some(cookies) = state.get("cookies").and_then(|v| v.as_array()) {
1551 use chromiumoxide::cdp::browser_protocol::network::SetCookieParams;
1552 for c in cookies {
1553 let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
1554 let value = c.get("value").and_then(|v| v.as_str()).unwrap_or("");
1555 let mut params = SetCookieParams::new(name, value);
1556 if let Some(d) = c.get("domain").and_then(|v| v.as_str()) {
1557 params.domain = Some(d.to_string());
1558 }
1559 if let Some(p) = c.get("path").and_then(|v| v.as_str()) {
1560 params.path = Some(p.to_string());
1561 }
1562 if let Some(e) = c.get("expires").and_then(|v| v.as_f64()) {
1563 use chromiumoxide::cdp::browser_protocol::network::TimeSinceEpoch;
1564 params.expires = Some(TimeSinceEpoch::new(e));
1565 }
1566 if let Some(ho) = c.get("http_only").and_then(|v| v.as_bool()) {
1567 params.http_only = Some(ho);
1568 }
1569 if let Some(s) = c.get("secure").and_then(|v| v.as_bool()) {
1570 params.secure = Some(s);
1571 }
1572 ctx.page
1573 .execute(params)
1574 .await
1575 .map_err(|e| anyhow::anyhow!("set_storage_state set cookie: {e}"))?;
1576 }
1577 }
1578
1579 if let Some(ls) = state.get("local_storage").and_then(|v| v.as_object()) {
1581 let entries_json = serde_json::to_string(ls)?;
1582 ctx.page
1583 .evaluate(format!(
1584 r#"(function(entries) {{
1585 localStorage.clear();
1586 for (var k in entries) {{
1587 localStorage.setItem(k, entries[k]);
1588 }}
1589 }})({entries_json})"#,
1590 ))
1591 .await
1592 .map_err(|e| anyhow::anyhow!("set_storage_state localStorage: {e}"))?;
1593 }
1594
1595 Ok(json!({"ok": true, "path": path}))
1596}
1597
1598async fn step_handle_dialog(
1601 ctx: &StepContext<'_>,
1602 accept: bool,
1603 prompt_text: Option<&str>,
1604) -> Result<Value> {
1605 use chromiumoxide::cdp::browser_protocol::page::{
1606 EventJavascriptDialogOpening, HandleJavaScriptDialogParams,
1607 };
1608 use futures::StreamExt;
1609
1610 let mut dialog_events = ctx
1613 .page
1614 .event_listener::<EventJavascriptDialogOpening>()
1615 .await
1616 .map_err(|e| anyhow::anyhow!("handle_dialog: subscribe: {e}"))?;
1617
1618 let mut params = HandleJavaScriptDialogParams::new(accept);
1619 if let Some(t) = prompt_text {
1620 params.prompt_text = Some(t.to_string());
1621 }
1622
1623 if ctx.page.execute(params.clone()).await.is_ok() {
1625 return Ok(json!({"ok": true, "accept": accept}));
1626 }
1627
1628 tokio::time::timeout(
1630 std::time::Duration::from_millis(ctx.global_timeout_ms),
1631 dialog_events.next(),
1632 )
1633 .await
1634 .map_err(|_| anyhow::anyhow!("handle_dialog: timed out waiting for dialog to appear"))?;
1635
1636 ctx.page
1638 .execute(params)
1639 .await
1640 .map_err(|e| anyhow::anyhow!("handle_dialog: {e}"))?;
1641
1642 Ok(json!({"ok": true, "accept": accept}))
1643}
1644
1645async fn step_pdf_save(ctx: &StepContext<'_>, path: Option<&str>) -> Result<Value> {
1648 use chromiumoxide::cdp::browser_protocol::page::PrintToPdfParams;
1649
1650 if let Some(p) = path {
1651 validate_file_path(p)?;
1652 }
1653
1654 let result = ctx
1655 .page
1656 .execute(PrintToPdfParams::default())
1657 .await
1658 .map_err(|e| anyhow::anyhow!("pdf_save: {e}"))?;
1659
1660 let b64: String = result.result.data.into();
1662 let pdf_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64.trim())
1663 .map_err(|e| anyhow::anyhow!("pdf_save base64 decode: {e}"))?;
1664
1665 if let Some(p) = path {
1666 tokio::fs::write(p, &pdf_bytes)
1668 .await
1669 .map_err(|e| anyhow::anyhow!("pdf_save write {p}: {e}"))?;
1670 Ok(json!({"path": p, "size": pdf_bytes.len()}))
1671 } else {
1672 let data = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &pdf_bytes);
1674 Ok(json!({"data": data, "size": pdf_bytes.len()}))
1675 }
1676}
1677
1678async fn step_generate_locator(_ctx: &StepContext<'_>, ref_: &str) -> Result<Value> {
1681 if !ref_
1683 .chars()
1684 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
1685 {
1686 return Err(anyhow::anyhow!(
1687 "generate_locator: ref '{ref_}' contains characters unsafe for CSS attribute selector"
1688 ));
1689 }
1690 let locator = format!("[data-ref=\"{ref_}\"]");
1693 Ok(json!({"locator": locator, "ref": ref_}))
1694}
1695
1696fn parse_mouse_button(
1698 button: Option<&str>,
1699) -> chromiumoxide::cdp::browser_protocol::input::MouseButton {
1700 use chromiumoxide::cdp::browser_protocol::input::MouseButton;
1701 match button {
1702 Some("right") => MouseButton::Right,
1703 Some("middle") => MouseButton::Middle,
1704 Some("back") => MouseButton::Back,
1705 Some("forward") => MouseButton::Forward,
1706 _ => MouseButton::Left,
1707 }
1708}
1709
1710#[cfg(test)]
1713mod tests {
1714 use super::*;
1715
1716 #[test]
1717 fn disallowed_scheme_rejected() {
1718 assert!(validate_url_scheme("file:///etc/passwd").is_err());
1719 let err = validate_url_scheme("file:///etc/passwd").unwrap_err();
1720 assert!(err.to_string().contains("file"));
1721 assert!(err.to_string().contains("http"));
1722 }
1723
1724 #[test]
1725 fn javascript_uri_rejected() {
1726 assert!(validate_url_scheme("javascript:alert(1)").is_err());
1727 }
1728
1729 #[test]
1730 fn data_uri_rejected() {
1731 assert!(validate_url_scheme("data:text/html,<h1>test</h1>").is_err());
1732 }
1733
1734 #[test]
1735 fn validate_file_path_rejects_traversal() {
1736 assert!(validate_file_path("../secret.txt").is_err());
1737 assert!(validate_file_path("/tmp/../../etc/passwd").is_err());
1738 assert!(validate_file_path("foo/../bar").is_err());
1739 }
1740
1741 #[test]
1742 fn validate_file_path_rejects_absolute_paths() {
1743 assert!(validate_file_path("/tmp/output.png").is_err());
1744 assert!(validate_file_path("/etc/passwd").is_err());
1745 assert!(validate_file_path("/home/user/.bashrc").is_err());
1746 }
1747
1748 #[test]
1749 fn validate_file_path_accepts_relative_paths() {
1750 assert!(validate_file_path("relative/path.pdf").is_ok());
1751 assert!(validate_file_path("file.json").is_ok());
1752 assert!(validate_file_path("output/screenshot.png").is_ok());
1753 }
1754
1755 #[test]
1756 fn http_scheme_allowed() {
1757 assert!(validate_url_scheme("https://example.com").is_ok());
1758 assert!(validate_url_scheme("http://example.com/path?q=1").is_ok());
1759 }
1760
1761 #[test]
1762 fn blob_uri_rejected() {
1763 assert!(validate_url_scheme("blob:https://example.com/abc").is_err());
1764 }
1765
1766 #[test]
1769 fn storage_js_obj_returns_correct_object() {
1770 assert_eq!(storage_js_obj("local"), "localStorage");
1771 assert_eq!(storage_js_obj("session"), "sessionStorage");
1772 assert_eq!(storage_js_obj("other"), "localStorage");
1774 }
1775
1776 #[test]
1777 fn generate_locator_produces_data_ref_selector() {
1778 let ref_id = "e42";
1781 let expected = format!("[data-ref=\"{ref_id}\"]");
1782 assert_eq!(expected, "[data-ref=\"e42\"]");
1783 }
1784
1785 #[test]
1786 fn generate_locator_rejects_unsafe_ref() {
1787 let unsafe_refs = [
1789 "e\"42", "e]42", "e[42", "e 42", "e<42>", "e;42", ];
1796 for bad in &unsafe_refs {
1797 let is_safe = bad
1798 .chars()
1799 .all(|c| c.is_alphanumeric() || c == '-' || c == '_');
1800 assert!(
1801 !is_safe,
1802 "expected '{bad}' to be rejected as unsafe for CSS attribute selector"
1803 );
1804 }
1805
1806 let safe_refs = ["e42", "my-ref", "some_id", "Abc123", "a-b_c"];
1808 for good in &safe_refs {
1809 let is_safe = good
1810 .chars()
1811 .all(|c| c.is_alphanumeric() || c == '-' || c == '_');
1812 assert!(
1813 is_safe,
1814 "expected '{good}' to be accepted as safe for CSS attribute selector"
1815 );
1816 }
1817 }
1818
1819 #[test]
1820 fn cookie_set_params_new_api() {
1821 use chromiumoxide::cdp::browser_protocol::network::SetCookieParams;
1822 let params = SetCookieParams::new("session", "abc123");
1823 assert_eq!(params.name, "session");
1824 assert_eq!(params.value, "abc123");
1825 assert!(params.domain.is_none());
1826 }
1827
1828 #[test]
1829 fn cookie_delete_params_new_api() {
1830 use chromiumoxide::cdp::browser_protocol::network::DeleteCookiesParams;
1831 let params = DeleteCookiesParams::new("session");
1832 assert_eq!(params.name, "session");
1833 }
1834
1835 #[test]
1836 fn time_since_epoch_new_api() {
1837 use chromiumoxide::cdp::browser_protocol::network::TimeSinceEpoch;
1838 let t = TimeSinceEpoch::new(1_700_000_000.0_f64);
1839 assert_eq!(*t.inner(), 1_700_000_000.0_f64);
1840 }
1841
1842 #[test]
1843 fn resize_params_new_api() {
1844 use chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams;
1845 let params = SetDeviceMetricsOverrideParams::new(1280_i64, 720_i64, 1.0_f64, false);
1846 assert_eq!(params.width, 1280);
1847 assert_eq!(params.height, 720);
1848 assert!(!params.mobile);
1849 }
1850
1851 #[test]
1852 fn handle_dialog_params_new_api() {
1853 use chromiumoxide::cdp::browser_protocol::page::HandleJavaScriptDialogParams;
1854 let params = HandleJavaScriptDialogParams::new(true);
1855 assert!(params.accept);
1856 assert!(params.prompt_text.is_none());
1857 }
1858
1859 #[test]
1860 fn wait_for_time_only_does_not_poll() {
1861 use crate::schema::BrowserStep;
1865 let json_with = r#"{"action":"wait_for","time":0.001,"timeout_ms":5000}"#;
1867 let step: BrowserStep = serde_json::from_str(json_with).unwrap();
1868 assert!(
1869 matches!(step, BrowserStep::WaitFor { time: Some(t), timeout_ms: Some(5000), .. } if t < 1.0)
1870 );
1871 let json_without = r#"{"action":"wait_for","time":0.001}"#;
1872 let step2: BrowserStep = serde_json::from_str(json_without).unwrap();
1873 assert!(
1874 matches!(step2, BrowserStep::WaitFor { time: Some(t), timeout_ms: None, .. } if t < 1.0)
1875 );
1876 }
1877
1878 #[test]
1879 fn verify_text_visible_step_deserialises() {
1880 use crate::schema::BrowserStep;
1881 let json = r#"{"action":"verify_text_visible","text":"Hello world"}"#;
1882 let step: BrowserStep = serde_json::from_str(json).unwrap();
1883 assert!(
1884 matches!(step, BrowserStep::VerifyTextVisible { text, .. } if text == "Hello world")
1885 );
1886 }
1887
1888 #[test]
1889 fn verify_list_visible_step_deserialises() {
1890 use crate::schema::BrowserStep;
1891 let json = r#"{"action":"verify_list_visible","ref":"root","items":["Apple","Banana"]}"#;
1892 let step: BrowserStep = serde_json::from_str(json).unwrap();
1893 assert!(matches!(step, BrowserStep::VerifyListVisible { items, .. } if items.len() == 2));
1894 }
1895
1896 #[test]
1897 fn evaluate_step_deserialises() {
1898 use crate::schema::BrowserStep;
1899 let json = r#"{"action":"evaluate","function":"() => document.title"}"#;
1900 let step: BrowserStep = serde_json::from_str(json).unwrap();
1901 assert!(
1902 matches!(step, BrowserStep::Evaluate { function, .. } if function.contains("document.title"))
1903 );
1904 }
1905
1906 #[test]
1907 fn run_code_step_deserialises() {
1908 use crate::schema::BrowserStep;
1909 let json = r#"{"action":"run_code","code":"return 42;"}"#;
1910 let step: BrowserStep = serde_json::from_str(json).unwrap();
1911 assert!(matches!(step, BrowserStep::RunCode { code, .. } if code == "return 42;"));
1912 }
1913
1914 #[test]
1915 fn cookie_list_step_deserialises() {
1916 use crate::schema::BrowserStep;
1917 let json = r#"{"action":"cookie_list","domain":"example.com"}"#;
1918 let step: BrowserStep = serde_json::from_str(json).unwrap();
1919 assert!(
1920 matches!(step, BrowserStep::CookieList { domain: Some(d), .. } if d == "example.com")
1921 );
1922 }
1923
1924 #[test]
1925 fn cookie_set_step_deserialises() {
1926 use crate::schema::BrowserStep;
1927 let json = r#"{"action":"cookie_set","name":"tok","value":"xyz","http_only":true}"#;
1928 let step: BrowserStep = serde_json::from_str(json).unwrap();
1929 assert!(
1930 matches!(step, BrowserStep::CookieSet { name, http_only: true, .. } if name == "tok")
1931 );
1932 }
1933
1934 #[test]
1935 fn local_storage_get_step_deserialises() {
1936 use crate::schema::BrowserStep;
1937 let json = r#"{"action":"local_storage_get","key":"auth_token"}"#;
1938 let step: BrowserStep = serde_json::from_str(json).unwrap();
1939 assert!(matches!(step, BrowserStep::LocalStorageGet { key, .. } if key == "auth_token"));
1940 }
1941
1942 #[test]
1943 fn session_storage_set_step_deserialises() {
1944 use crate::schema::BrowserStep;
1945 let json = r#"{"action":"session_storage_set","key":"sid","value":"abc"}"#;
1946 let step: BrowserStep = serde_json::from_str(json).unwrap();
1947 assert!(
1948 matches!(step, BrowserStep::SessionStorageSet { key, value, .. } if key == "sid" && value == "abc")
1949 );
1950 }
1951
1952 #[test]
1953 fn tabs_step_deserialises() {
1954 use crate::schema::BrowserStep;
1955 let json = r#"{"action":"tabs","operation":"list"}"#;
1956 let step: BrowserStep = serde_json::from_str(json).unwrap();
1957 assert!(matches!(step, BrowserStep::Tabs { operation, .. } if operation == "list"));
1958 }
1959
1960 #[test]
1961 fn resize_step_deserialises() {
1962 use crate::schema::BrowserStep;
1963 let json = r#"{"action":"resize","width":1280,"height":720}"#;
1964 let step: BrowserStep = serde_json::from_str(json).unwrap();
1965 assert!(matches!(
1966 step,
1967 BrowserStep::Resize {
1968 width: 1280,
1969 height: 720,
1970 ..
1971 }
1972 ));
1973 }
1974
1975 #[test]
1976 fn handle_dialog_step_deserialises() {
1977 use crate::schema::BrowserStep;
1978 let json = r#"{"action":"handle_dialog","accept":false,"prompt_text":"no"}"#;
1979 let step: BrowserStep = serde_json::from_str(json).unwrap();
1980 assert!(
1981 matches!(step, BrowserStep::HandleDialog { accept: false, prompt_text: Some(t), .. } if t == "no")
1982 );
1983 }
1984
1985 #[test]
1986 fn pdf_save_step_deserialises() {
1987 use crate::schema::BrowserStep;
1988 let json = r#"{"action":"pdf_save","path":"/tmp/out.pdf"}"#;
1989 let step: BrowserStep = serde_json::from_str(json).unwrap();
1990 assert!(matches!(step, BrowserStep::PdfSave { path: Some(p), .. } if p == "/tmp/out.pdf"));
1991 }
1992
1993 #[test]
1994 fn generate_locator_step_deserialises() {
1995 use crate::schema::BrowserStep;
1996 let json = r#"{"action":"generate_locator","ref":"e7"}"#;
1997 let step: BrowserStep = serde_json::from_str(json).unwrap();
1998 assert!(matches!(step, BrowserStep::GenerateLocator { r#ref, .. } if r#ref == "e7"));
1999 }
2000
2001 #[test]
2002 fn storage_state_step_deserialises() {
2003 use crate::schema::BrowserStep;
2004 let json = r#"{"action":"storage_state","path":"/tmp/state.json"}"#;
2005 let step: BrowserStep = serde_json::from_str(json).unwrap();
2006 assert!(
2007 matches!(step, BrowserStep::StorageState { path: Some(p), .. } if p == "/tmp/state.json")
2008 );
2009 }
2010
2011 #[test]
2012 fn set_storage_state_step_deserialises() {
2013 use crate::schema::BrowserStep;
2014 let json = r#"{"action":"set_storage_state","path":"/tmp/state.json"}"#;
2015 let step: BrowserStep = serde_json::from_str(json).unwrap();
2016 assert!(
2017 matches!(step, BrowserStep::SetStorageState { path, .. } if path == "/tmp/state.json")
2018 );
2019 }
2020
2021 #[test]
2022 fn route_step_deserialises() {
2023 use crate::schema::BrowserStep;
2024 let json = r#"{"action":"route","pattern":"**/api/data","status":200,"body":"{}"}"#;
2025 let step: BrowserStep = serde_json::from_str(json).unwrap();
2026 assert!(
2027 matches!(step, BrowserStep::Route { pattern, status: Some(200), .. } if pattern == "**/api/data")
2028 );
2029 }
2030
2031 #[test]
2032 fn console_messages_step_deserialises() {
2033 use crate::schema::BrowserStep;
2034 let json = r#"{"action":"console_messages","level":"error"}"#;
2035 let step: BrowserStep = serde_json::from_str(json).unwrap();
2036 assert!(
2037 matches!(step, BrowserStep::ConsoleMessages { level: Some(l), .. } if l == "error")
2038 );
2039 }
2040
2041 #[test]
2042 fn network_requests_step_deserialises() {
2043 use crate::schema::BrowserStep;
2044 let json = r#"{"action":"network_requests","include_static":true}"#;
2045 let step: BrowserStep = serde_json::from_str(json).unwrap();
2046 assert!(matches!(
2047 step,
2048 BrowserStep::NetworkRequests {
2049 include_static: true,
2050 ..
2051 }
2052 ));
2053 }
2054
2055 #[test]
2056 fn verify_value_step_deserialises() {
2057 use crate::schema::BrowserStep;
2058 let json = r#"{"action":"verify_value","ref":"e1","value":"expected"}"#;
2059 let step: BrowserStep = serde_json::from_str(json).unwrap();
2060 assert!(matches!(step, BrowserStep::VerifyValue { value, .. } if value == "expected"));
2061 }
2062
2063 #[test]
2064 fn start_video_step_deserialises() {
2065 use crate::schema::BrowserStep;
2066 let json = r#"{"action":"start_video","width":1280,"height":720}"#;
2067 let step: BrowserStep = serde_json::from_str(json).unwrap();
2068 assert!(matches!(
2069 step,
2070 BrowserStep::StartVideo {
2071 width: Some(1280),
2072 height: Some(720),
2073 ..
2074 }
2075 ));
2076 }
2077}