Skip to main content

earl_protocol_browser/
steps.rs

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
9// ── URL scheme validation ──────────────────────────────────────────────────────
10
11/// Validate that the given URL has an allowed scheme (http or https only).
12/// Rejects file://, javascript:, data:, blob:, and any other scheme.
13pub 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
24// ── File path validation ───────────────────────────────────────────────────────
25
26/// Reject file paths that could escape the working directory.
27///
28/// Only relative paths are permitted — absolute paths are rejected to prevent
29/// writes to arbitrary filesystem locations. `..` components are also rejected
30/// to block traversal out of the working directory.
31fn 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
46// ── Step execution context ─────────────────────────────────────────────────────
47
48pub 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
55// ── Main step loop ─────────────────────────────────────────────────────────────
56
57pub 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
120/// Attempt to capture a diagnostic screenshot on step failure.
121/// Errors here are silently swallowed so they don't mask the original error.
122async 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            // Restrict permissions so the diagnostic file is not world-readable.
133            #[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
143// ── Step dispatcher ────────────────────────────────────────────────────────────
144
145pub 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        // ── Wait / Assert ──────────────────────────────────────────────────
227        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        // ── JavaScript ────────────────────────────────────────────────────
271        BrowserStep::Evaluate { function, .. } => step_evaluate(ctx, function).await,
272        BrowserStep::RunCode { code, .. } => step_run_code(ctx, code).await,
273
274        // ── Tabs & Viewport ───────────────────────────────────────────────
275        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        // ── Network (stubs — not yet implemented) ────────────────────────
282        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        // ── Cookies ───────────────────────────────────────────────────────
303        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        // ── Web Storage ───────────────────────────────────────────────────
331        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        // ── File / Dialog / Download ──────────────────────────────────────
349        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        // ── Output / Recording ────────────────────────────────────────────
362        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
379// ── Navigation ─────────────────────────────────────────────────────────────────
380
381async 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        // Use the Performance Navigation Timing API to read the HTTP response
395        // status code after the navigation has settled.
396        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        // No history to go back to — treat as no-op.
433        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        // No forward history — treat as no-op.
471        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
497// ── Observation ────────────────────────────────────────────────────────────────
498
499async 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    // Build a flat id→node map and then reconstruct the tree hierarchy.
511    use std::collections::HashMap;
512
513    // Index nodes by their node_id.
514    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    // Convert a CDP AxNode into our simplified AXNode (recursively).
523    // The full tree can be large; we call the flat list version.
524    // CDP `GetFullAXTree` returns all nodes flat with parent_id references.
525    // Build the tree by finding root nodes (no parent_id) and recursing.
526    // A depth limit guards against stack overflow on pathologically deep trees.
527    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    // Collect root nodes (nodes with no parent or whose parent is not in the map).
578    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    // Use page.screenshot() to get bytes directly — avoids a temp-file round-trip
609    // and ensures no world-readable file is left behind when no path is given.
610    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        // User wants the file saved to disk.
622        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        // No path — return bytes as base64 only.
628        let data = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);
629        Ok(json!({"data": data}))
630    }
631}
632
633// ── Interaction helpers ────────────────────────────────────────────────────────
634
635/// Locate a page element by CSS selector. If a `ref_` is provided but no
636/// selector, a helpful error is returned explaining that ref-based targeting
637/// requires session mode (not yet implemented). If neither is provided, an
638/// `ElementNotFound` error is returned.
639async 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        // The two sequential .click() calls don't fire the dblclick DOM event
693        // that many frameworks listen to. Dispatch it explicitly.
694        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    // Clear the existing value before typing.
728    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    // Only click if the current state differs from the desired state.
813    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
878// ── Mouse coordinate steps ─────────────────────────────────────────────────────
879
880async 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    // Use the centre of the viewport as the default position.
992    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
1053// ── Wait / Assert ───────────────────────────────────────────────────────────
1054
1055async 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                // text found — check text_gone too
1103                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        // Check deadline before sleeping so we never overshoot by a full poll interval.
1118        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        // Sleep for at most the remaining time to avoid overshooting the deadline.
1129        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    // Build a simple JS check using aria attributes.
1186    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    // Evaluate the value of the currently focused element (or the first input).
1227    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
1254// ── JavaScript ──────────────────────────────────────────────────────────────
1255
1256async 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
1277// ── Tabs & Viewport ─────────────────────────────────────────────────────────
1278
1279async 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
1320// ── Cookies ─────────────────────────────────────────────────────────────────
1321
1322async 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    // CDP requires at least one of `url` or `domain`; use the current page URL.
1407    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
1426// ── Web Storage ─────────────────────────────────────────────────────────────
1427
1428/// `kind` is either `"local"` or `"session"`.
1429fn 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    // Gather cookies.
1488    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    // Gather localStorage.
1511    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    // Restore cookies.
1550    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    // Restore localStorage.
1580    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
1598// ── Dialog ───────────────────────────────────────────────────────────────────
1599
1600async 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    // Subscribe to dialog-opening events BEFORE attempting to handle, so we
1611    // don't miss a dialog that fires between the check and the dismiss call.
1612    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    // Try to dismiss a dialog that is already open.
1624    if ctx.page.execute(params.clone()).await.is_ok() {
1625        return Ok(json!({"ok": true, "accept": accept}));
1626    }
1627
1628    // Wait up to the global timeout for a dialog to appear.
1629    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    // Dismiss the now-pending dialog.
1637    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
1645// ── PDF ──────────────────────────────────────────────────────────────────────
1646
1647async 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    // result.result.data is a Binary wrapping a base64 string.
1661    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        // User wants the file saved to disk — no temp file needed.
1667        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        // No path given — return bytes as base64; nothing written to disk.
1673        let data = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &pdf_bytes);
1674        Ok(json!({"data": data, "size": pdf_bytes.len()}))
1675    }
1676}
1677
1678// ── GenerateLocator ─────────────────────────────────────────────────────────
1679
1680async fn step_generate_locator(_ctx: &StepContext<'_>, ref_: &str) -> Result<Value> {
1681    // Validate ref_ contains only safe characters for a CSS attribute value
1682    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    // Without a live ref→selector mapping, we return a CSS attribute selector
1691    // based on the ref ID. In session mode this would resolve to a precise selector.
1692    let locator = format!("[data-ref=\"{ref_}\"]");
1693    Ok(json!({"locator": locator, "ref": ref_}))
1694}
1695
1696/// Parse an optional button string into a `MouseButton` enum value.
1697fn 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// ── Tests ──────────────────────────────────────────────────────────────────────
1711
1712#[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    // ── Tests for new step helpers (no browser required) ─────────────────────
1767
1768    #[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        // Anything that isn't "session" defaults to localStorage.
1773        assert_eq!(storage_js_obj("other"), "localStorage");
1774    }
1775
1776    #[test]
1777    fn generate_locator_produces_data_ref_selector() {
1778        // The function is async but we can verify the locator format logic
1779        // by checking the string that would be produced.
1780        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        // Characters that would break a CSS attribute selector must be rejected.
1788        let unsafe_refs = [
1789            "e\"42", // double-quote breaks the attribute value
1790            "e]42",  // bracket closes the selector early
1791            "e[42",  // bracket opens a nested selector
1792            "e 42",  // space is not a valid identifier character
1793            "e<42>", // angle brackets
1794            "e;42",  // semicolons
1795        ];
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        // Safe refs must pass the same check.
1807        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        // WaitFor with only `time` set (no text conditions) returns Ok immediately
1862        // after sleeping — we test that the function signature compiles correctly
1863        // by verifying BrowserStep::WaitFor deserialises with the timeout_ms field.
1864        use crate::schema::BrowserStep;
1865        // timeout_ms is now optional; verify it deserialises both with and without the field.
1866        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}