Skip to main content

allwright_surface_web/
lib.rs

1use allwright_plugin_sdk::{
2    ALLWRIGHT_PLUGIN_API_VERSION, AutomationSessionInfo, BrowserKind, BrowserLaunchInfo,
3    BrowserSessionHandle, ChromeLaunchInfo, ChromeTabInfo, ChromiumBidiMapperInfo, ClickInfo,
4    ElementCountInfo, FillInfo, FocusInfo, HighlightElementsInfo, HoverInfo, PageInfo,
5    PageSessionHandle, PluginCommand, PluginEnvelope, PluginResult, PressKeyInfo, ScreenshotInfo,
6    SurfaceFamily, SurfacePlugin, SurfacePluginDescriptor, TabNavigationInfo, TextInfo,
7    WaitForSelectorInfo,
8};
9use base64::Engine as _;
10use std::borrow::Cow;
11use std::ffi::{CStr, CString, c_char};
12use std::fs;
13use std::future::Future;
14use std::net::TcpListener;
15use std::path::PathBuf;
16use std::process::{Command, Stdio};
17use std::thread;
18use std::time::{Duration as StdDuration, SystemTime, UNIX_EPOCH};
19
20use futures_util::{SinkExt, StreamExt};
21use serde_json::{Value, json};
22use std::collections::HashMap;
23use std::sync::OnceLock;
24use tokio::net::TcpStream;
25use tokio::sync::{Mutex, MutexGuard};
26use tokio::time::{Duration, sleep};
27use tokio_tungstenite::MaybeTlsStream;
28use tokio_tungstenite::WebSocketStream;
29use tokio_tungstenite::connect_async;
30use tokio_tungstenite::tungstenite::Message;
31
32#[derive(Debug, Clone, PartialEq)]
33struct DiscoveredElements {
34    count: u32,
35    first_center: Option<ElementCenter>,
36}
37
38#[derive(Debug, Clone, PartialEq)]
39struct ElementCenter {
40    x: f64,
41    y: f64,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum SelectorKind {
46    Css,
47    XPath,
48}
49
50#[derive(Debug, Clone, Copy, Default)]
51struct DiscoveryRequirements {
52    require_match: bool,
53    require_visible: bool,
54    require_pointer_interactable: bool,
55    require_editable: bool,
56    focus_first_match: bool,
57    require_focus: bool,
58}
59
60#[derive(Debug, Clone)]
61struct ParsedSelector<'a> {
62    raw: &'a str,
63    value: Cow<'a, str>,
64    kind: SelectorKind,
65}
66
67#[derive(Debug, Clone, Copy)]
68struct ExplicitSelectorPrefix {
69    kind: SelectorKind,
70    prefix_len: usize,
71}
72
73const CHROMIUM_BIDI_NPM_VERSION: &str = "17.0.2";
74const CHROMIUM_BIDI_MAPPER_BUNDLE: &str =
75    include_str!("../third_party/chromium-bidi/17.0.2/mapperTab.js");
76
77struct FirefoxSessionState {
78    process_id: u32,
79    connection: BidiConnection,
80}
81
82static FIREFOX_SESSION_REGISTRY: OnceLock<Mutex<HashMap<String, FirefoxSessionState>>> =
83    OnceLock::new();
84static PLUGIN_RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
85
86#[derive(Debug, Clone, Copy, Default)]
87pub struct WebPlugin;
88
89impl SurfacePlugin for WebPlugin {
90    fn descriptor(&self) -> SurfacePluginDescriptor {
91        SurfacePluginDescriptor {
92            id: "web",
93            family: SurfaceFamily::Web,
94            version: env!("CARGO_PKG_VERSION"),
95            description: "Web surface plugin for the allwright engine.",
96        }
97    }
98}
99
100pub fn open_chrome_window(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
101    launch_chrome_for_platform(chrome_binary.filter(|value| !value.trim().is_empty()))
102}
103
104pub async fn discover_initial_tab(cdp_websocket_url: &str) -> Result<ChromeTabInfo, String> {
105    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
106
107    for _ in 0..100 {
108        let targets = cdp
109            .send_command("Target.getTargets", json!({}), None)
110            .await?;
111        if let Some(page_target) = targets
112            .get("targetInfos")
113            .and_then(Value::as_array)
114            .and_then(|target_infos| {
115                target_infos.iter().find(|target_info| {
116                    target_info.get("type").and_then(Value::as_str) == Some("page")
117                })
118            })
119        {
120            let target_id = required_string(page_target, "/targetId")?;
121            return Ok(ChromeTabInfo {
122                note: "tracked initial Chrome tab via CDP".to_string(),
123                target_id,
124            });
125        }
126
127        sleep(Duration::from_millis(50)).await;
128    }
129
130    Err("timed out waiting for the initial Chrome page target over CDP".to_string())
131}
132
133pub async fn open_chrome_tab(cdp_websocket_url: &str) -> Result<ChromeTabInfo, String> {
134    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
135    let create_target = cdp
136        .send_command(
137            "Target.createTarget",
138            json!({
139                "url": "about:blank",
140                "background": false,
141            }),
142            None,
143        )
144        .await?;
145    let target_id = required_string(&create_target, "/targetId")?;
146
147    Ok(ChromeTabInfo {
148        note: "opened Chrome tab via CDP".to_string(),
149        target_id,
150    })
151}
152
153pub fn close_browser_process(process_id: u32) -> Result<(), String> {
154    block_on_plugin_future(async {
155        let registry = firefox_session_registry();
156        let mut sessions = registry.lock().await;
157        sessions.retain(|_, state| state.process_id != process_id);
158        Ok::<(), String>(())
159    })?;
160    close_browser_process_for_platform(process_id)
161}
162
163pub fn launch_browser(
164    browser_kind: BrowserKind,
165    browser_binary: Option<&str>,
166) -> Result<BrowserLaunchInfo, String> {
167    match browser_kind {
168        BrowserKind::Chromium => {
169            let launch = open_chrome_window(browser_binary)?;
170            let initial_page = block_on_plugin_future(async {
171                discover_initial_tab(&launch.cdp_websocket_url).await
172            })?;
173            Ok(BrowserLaunchInfo {
174                browser_kind,
175                browser: launch.browser,
176                note: launch.note,
177                user_data_dir: launch.user_data_dir,
178                process_id: launch.process_id,
179                browser_session: BrowserSessionHandle::Chromium {
180                    cdp_websocket_url: launch.cdp_websocket_url,
181                },
182                initial_page: PageInfo {
183                    note: initial_page.note,
184                    page_session: PageSessionHandle::Chromium {
185                        target_id: initial_page.target_id,
186                        browsing_context_id: None,
187                        mapper_target_id: None,
188                    },
189                },
190            })
191        }
192        BrowserKind::Firefox => launch_firefox_with_bidi(browser_binary),
193    }
194}
195
196pub async fn open_page(browser_session: &BrowserSessionHandle) -> Result<PageInfo, String> {
197    match browser_session {
198        BrowserSessionHandle::Chromium { cdp_websocket_url } => {
199            let tab = open_chrome_tab(cdp_websocket_url).await?;
200            Ok(PageInfo {
201                note: tab.note,
202                page_session: PageSessionHandle::Chromium {
203                    target_id: tab.target_id,
204                    browsing_context_id: None,
205                    mapper_target_id: None,
206                },
207            })
208        }
209        BrowserSessionHandle::Firefox { connection_id, .. } => {
210            let mut sessions = firefox_session_guard(connection_id).await?;
211            let bidi = &mut sessions
212                .get_mut(connection_id)
213                .expect("Firefox session guard validated connection id")
214                .connection;
215            let created = bidi
216                .send_command(
217                    "browsingContext.create",
218                    json!({
219                        "type": "tab",
220                        "background": false,
221                    }),
222                )
223                .await?;
224            Ok(PageInfo {
225                note: "opened Firefox tab via native WebDriver BiDi".to_string(),
226                page_session: PageSessionHandle::Firefox {
227                    browsing_context_id: required_string(&created, "/result/context")?,
228                },
229            })
230        }
231    }
232}
233
234pub async fn close_page(
235    browser_session: &BrowserSessionHandle,
236    page_session: &PageSessionHandle,
237) -> Result<(), String> {
238    match (browser_session, page_session) {
239        (
240            BrowserSessionHandle::Chromium { cdp_websocket_url },
241            PageSessionHandle::Chromium { target_id, .. },
242        ) => close_chrome_tab(cdp_websocket_url, target_id).await,
243        (
244            BrowserSessionHandle::Firefox { connection_id, .. },
245            PageSessionHandle::Firefox {
246                browsing_context_id,
247            },
248        ) => {
249            let mut sessions = firefox_session_guard(connection_id).await?;
250            let bidi = &mut sessions
251                .get_mut(connection_id)
252                .expect("Firefox session guard validated connection id")
253                .connection;
254            bidi.send_command(
255                "browsingContext.close",
256                json!({
257                    "context": browsing_context_id,
258                }),
259            )
260            .await?;
261            Ok(())
262        }
263        _ => Err("browser/page backend mismatch while closing page".to_string()),
264    }
265}
266
267pub async fn close_chrome_tab(cdp_websocket_url: &str, target_id: &str) -> Result<(), String> {
268    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
269    let response = cdp
270        .send_command(
271            "Target.closeTarget",
272            json!({
273                "targetId": target_id,
274            }),
275            None,
276        )
277        .await?;
278    let success = response
279        .get("success")
280        .and_then(Value::as_bool)
281        .unwrap_or(false);
282    if success {
283        Ok(())
284    } else {
285        Err(format!(
286            "CDP Target.closeTarget reported unsuccessful close for target {target_id}"
287        ))
288    }
289}
290
291pub async fn navigate_chrome_tab(
292    cdp_websocket_url: &str,
293    target_id: &str,
294    url: &str,
295) -> Result<TabNavigationInfo, String> {
296    if url.trim().is_empty() {
297        return Err("navigate command requires a non-empty url".to_string());
298    }
299
300    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
301    let attach = cdp
302        .send_command(
303            "Target.attachToTarget",
304            json!({
305                "targetId": target_id,
306                "flatten": true,
307            }),
308            None,
309        )
310        .await?;
311    let session_id = required_string(&attach, "/sessionId")?;
312
313    cdp.send_command("Page.enable", json!({}), Some(&session_id))
314        .await?;
315    cdp.navigate_and_wait_for_load(&session_id, url).await?;
316    let frame_tree = cdp
317        .send_command("Page.getFrameTree", json!({}), Some(&session_id))
318        .await?;
319    let browsing_context_id = required_string(&frame_tree, "/frameTree/frame/id")?;
320    cdp.send_command(
321        "Target.detachFromTarget",
322        json!({
323            "sessionId": session_id,
324        }),
325        None,
326    )
327    .await?;
328
329    Ok(TabNavigationInfo {
330        url: url.to_string(),
331        note: "navigated Chrome tab via CDP and observed Page.loadEventFired".to_string(),
332        page_session: PageSessionHandle::Chromium {
333            target_id: target_id.to_string(),
334            browsing_context_id: Some(browsing_context_id),
335            mapper_target_id: None,
336        },
337        automation: AutomationSessionInfo {
338            bidi_session_id: String::new(),
339            note: String::new(),
340            mapper_target_id: None,
341            mapper_session_id: None,
342            package_version: None,
343        },
344    })
345}
346
347pub async fn navigate_page(
348    browser_session: &BrowserSessionHandle,
349    page_session: &PageSessionHandle,
350    url: &str,
351) -> Result<TabNavigationInfo, String> {
352    match (browser_session, page_session) {
353        (
354            BrowserSessionHandle::Chromium { cdp_websocket_url },
355            PageSessionHandle::Chromium {
356                target_id,
357                browsing_context_id,
358                ..
359            },
360        ) => {
361            let navigation = navigate_chrome_tab(cdp_websocket_url, target_id, url).await?;
362            let (resolved_browsing_context_id, mapper) = resolve_bidi_context_for_tab(
363                cdp_websocket_url,
364                None,
365                browsing_context_id.as_deref(),
366                Some(&navigation.url),
367            )
368            .await?;
369            Ok(TabNavigationInfo {
370                url: navigation.url,
371                note: navigation.note,
372                page_session: PageSessionHandle::Chromium {
373                    target_id: target_id.to_string(),
374                    browsing_context_id: Some(resolved_browsing_context_id),
375                    mapper_target_id: Some(mapper.mapper_target_id.clone()),
376                },
377                automation: AutomationSessionInfo {
378                    bidi_session_id: format!("chromium-bidi:{target_id}"),
379                    note: format!(
380                        "chromium-bidi mapper ready from pinned chromium-bidi@{} after navigation",
381                        mapper.package_version
382                    ),
383                    mapper_target_id: Some(mapper.mapper_target_id),
384                    mapper_session_id: Some(mapper.mapper_session_id),
385                    package_version: Some(mapper.package_version),
386                },
387            })
388        }
389        (
390            BrowserSessionHandle::Firefox {
391                connection_id,
392                bidi_session_id,
393            },
394            PageSessionHandle::Firefox {
395                browsing_context_id,
396            },
397        ) => {
398            let mut sessions = firefox_session_guard(connection_id).await?;
399            let bidi = &mut sessions
400                .get_mut(connection_id)
401                .expect("Firefox session guard validated connection id")
402                .connection;
403            let result = bidi
404                .send_command(
405                    "browsingContext.navigate",
406                    json!({
407                        "context": browsing_context_id,
408                        "url": url,
409                        "wait": "complete",
410                    }),
411                )
412                .await?;
413            Ok(TabNavigationInfo {
414                url: json_string(&result, "/result/url")?,
415                note: "navigated Firefox tab via native WebDriver BiDi".to_string(),
416                page_session: page_session.clone(),
417                automation: AutomationSessionInfo {
418                    bidi_session_id: bidi_session_id.clone(),
419                    note: "connected to native Firefox WebDriver BiDi session".to_string(),
420                    mapper_target_id: None,
421                    mapper_session_id: None,
422                    package_version: None,
423                },
424            })
425        }
426        _ => Err("browser/page backend mismatch while navigating".to_string()),
427    }
428}
429
430pub async fn inject_chromium_bidi_mapper(
431    cdp_websocket_url: &str,
432) -> Result<ChromiumBidiMapperInfo, String> {
433    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
434    let mapper_target = cdp
435        .send_command(
436            "Target.createTarget",
437            json!({
438                "url": "about:blank#MAPPER_TARGET",
439                "hidden": true,
440                "background": true,
441            }),
442            None,
443        )
444        .await?;
445    let mapper_target_id = required_string(&mapper_target, "/targetId")?;
446
447    let attached = cdp
448        .send_command(
449            "Target.attachToTarget",
450            json!({
451                "targetId": mapper_target_id,
452                "flatten": true,
453            }),
454            None,
455        )
456        .await?;
457    let mapper_session_id = required_string(&attached, "/sessionId")?;
458
459    cdp.send_command("Runtime.enable", json!({}), Some(&mapper_session_id))
460        .await?;
461    cdp.send_command(
462        "Target.exposeDevToolsProtocol",
463        json!({
464            "bindingName": "cdp",
465            "targetId": mapper_target_id,
466            "inheritPermissions": true,
467        }),
468        None,
469    )
470    .await?;
471    cdp.send_command(
472        "Runtime.addBinding",
473        json!({
474            "name": "sendBidiResponse",
475        }),
476        Some(&mapper_session_id),
477    )
478    .await?;
479    cdp.evaluate_expression(&mapper_session_id, CHROMIUM_BIDI_MAPPER_BUNDLE, false)
480        .await?;
481    cdp.evaluate_expression(
482        &mapper_session_id,
483        &format!(
484            "window.runMapperInstance('{}')",
485            js_single_quote(&mapper_target_id)
486        ),
487        true,
488    )
489    .await?;
490
491    Ok(ChromiumBidiMapperInfo {
492        package_version: CHROMIUM_BIDI_NPM_VERSION.to_string(),
493        mapper_target_id,
494        mapper_session_id,
495        note: format!(
496            "injected chromium-bidi mapper from pinned published chromium-bidi@{} artifact into hidden mapper target",
497            CHROMIUM_BIDI_NPM_VERSION
498        ),
499    })
500}
501
502pub async fn click_element_via_cdp(
503    cdp_websocket_url: &str,
504    target_id: &str,
505    css_selector: &str,
506) -> Result<ClickInfo, String> {
507    let parsed = parse_selector("click_element", css_selector)?;
508
509    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
510    let session_id = cdp.prepare_page_target_session(target_id).await?;
511    let discovered = discover_elements_via_cdp(
512        &mut cdp,
513        &session_id,
514        css_selector,
515        DiscoveryRequirements {
516            require_match: true,
517            require_visible: true,
518            require_pointer_interactable: true,
519            ..DiscoveryRequirements::default()
520        },
521    )
522    .await?;
523    let center = discovered.first_center.ok_or_else(|| {
524        format!(
525            "element discovery did not return a clickable center for {} {}",
526            selector_kind_label(parsed.kind),
527            parsed.value
528        )
529    })?;
530    cdp.dispatch_mouse_click(&session_id, center.x, center.y)
531        .await?;
532    cdp.detach_from_target(&session_id).await?;
533
534    Ok(ClickInfo {
535        css_selector: css_selector.to_string(),
536        note: format!(
537            "clicked element via CDP mouse events using {} {}",
538            selector_kind_label(parsed.kind),
539            parsed.value
540        ),
541        bidi_session_id: String::new(),
542    })
543}
544
545pub async fn count_elements_via_cdp(
546    cdp_websocket_url: &str,
547    target_id: &str,
548    css_selector: &str,
549) -> Result<ElementCountInfo, String> {
550    let parsed = parse_selector("count_elements", css_selector)?;
551
552    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
553    let session_id = cdp.prepare_page_target_session(target_id).await?;
554    let discovered = discover_elements_via_cdp(
555        &mut cdp,
556        &session_id,
557        css_selector,
558        DiscoveryRequirements::default(),
559    )
560    .await?;
561    cdp.detach_from_target(&session_id).await?;
562
563    Ok(ElementCountInfo {
564        css_selector: css_selector.to_string(),
565        count: discovered.count,
566        note: format!(
567            "counted {} element(s) matching {} {}",
568            discovered.count,
569            selector_kind_label(parsed.kind),
570            parsed.value
571        ),
572    })
573}
574
575pub async fn highlight_elements_via_cdp(
576    cdp_websocket_url: &str,
577    target_id: &str,
578    css_selector: &str,
579    duration_ms: u32,
580) -> Result<HighlightElementsInfo, String> {
581    let parsed = parse_selector("highlight_elements", css_selector)?;
582
583    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
584    let session_id = cdp.prepare_page_target_session(target_id).await?;
585    let discovered = discover_elements_via_cdp(
586        &mut cdp,
587        &session_id,
588        css_selector,
589        DiscoveryRequirements::default(),
590    )
591    .await?;
592
593    let selector_literal = serde_json::to_string(&parsed.value)
594        .map_err(|error| format!("failed to serialize selector for highlight: {error}"))?;
595    let query_all_js = selector_query_all_js(parsed.kind);
596    let duration_ms = duration_ms.max(1);
597    cdp.evaluate_expression(
598        &session_id,
599        &format!(
600            "(() => {{
601                const selector = {selector_literal};
602                const durationMs = {duration_ms};
603                const elements = {query_all_js};
604                for (const element of elements) {{
605                    if (!(element instanceof HTMLElement)) {{
606                        continue;
607                    }}
608                    element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
609                    const priorOutline = element.style.outline;
610                    const priorOutlineOffset = element.style.outlineOffset;
611                    const priorBackgroundColor = element.style.backgroundColor;
612                    element.style.outline = '3px solid #ff5a36';
613                    element.style.outlineOffset = '2px';
614                    element.style.backgroundColor = 'rgba(255, 235, 59, 0.35)';
615                    window.setTimeout(() => {{
616                        element.style.outline = priorOutline;
617                        element.style.outlineOffset = priorOutlineOffset;
618                        element.style.backgroundColor = priorBackgroundColor;
619                    }}, durationMs);
620                }}
621                return elements.length;
622            }})()"
623        ),
624        true,
625    )
626    .await?;
627    cdp.detach_from_target(&session_id).await?;
628
629    Ok(HighlightElementsInfo {
630        css_selector: css_selector.to_string(),
631        count: discovered.count,
632        note: format!(
633            "highlighted {} element(s) matching {} {} for {duration_ms}ms",
634            discovered.count,
635            selector_kind_label(parsed.kind),
636            parsed.value
637        ),
638    })
639}
640
641pub async fn focus_element_via_cdp(
642    cdp_websocket_url: &str,
643    target_id: &str,
644    css_selector: &str,
645) -> Result<FocusInfo, String> {
646    let parsed = parse_selector("focus_element", css_selector)?;
647    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
648    let session_id = cdp.prepare_page_target_session(target_id).await?;
649    discover_elements_via_cdp(
650        &mut cdp,
651        &session_id,
652        css_selector,
653        DiscoveryRequirements {
654            require_match: true,
655            require_visible: true,
656            focus_first_match: true,
657            require_focus: true,
658            ..DiscoveryRequirements::default()
659        },
660    )
661    .await?;
662    cdp.detach_from_target(&session_id).await?;
663
664    Ok(FocusInfo {
665        css_selector: css_selector.to_string(),
666        note: format!(
667            "focused element matching {} {}",
668            selector_kind_label(parsed.kind),
669            parsed.value
670        ),
671    })
672}
673
674pub async fn fill_element_via_cdp(
675    cdp_websocket_url: &str,
676    target_id: &str,
677    css_selector: &str,
678    value: &str,
679) -> Result<FillInfo, String> {
680    let parsed = parse_selector("fill_element", css_selector)?;
681    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
682    let session_id = cdp.prepare_page_target_session(target_id).await?;
683    discover_elements_via_cdp(
684        &mut cdp,
685        &session_id,
686        css_selector,
687        DiscoveryRequirements {
688            require_match: true,
689            require_visible: true,
690            require_editable: true,
691            focus_first_match: true,
692            require_focus: true,
693            ..DiscoveryRequirements::default()
694        },
695    )
696    .await?;
697    let selector_literal = json_string_literal(&parsed.value, "fill selector")?;
698    let value_literal = json_string_literal(value, "fill value")?;
699    cdp.evaluate_expression(
700        &session_id,
701        &dom_set_value_and_dispatch_events_js(parsed.kind, &selector_literal, &value_literal),
702        true,
703    )
704    .await?;
705    cdp.detach_from_target(&session_id).await?;
706
707    Ok(FillInfo {
708        css_selector: css_selector.to_string(),
709        value: value.to_string(),
710        note: format!(
711            "filled element matching {} {}",
712            selector_kind_label(parsed.kind),
713            parsed.value
714        ),
715    })
716}
717
718pub async fn hover_element_via_cdp(
719    cdp_websocket_url: &str,
720    target_id: &str,
721    css_selector: &str,
722) -> Result<HoverInfo, String> {
723    let parsed = parse_selector("hover_element", css_selector)?;
724    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
725    let session_id = cdp.prepare_page_target_session(target_id).await?;
726    let discovered = discover_elements_via_cdp(
727        &mut cdp,
728        &session_id,
729        css_selector,
730        DiscoveryRequirements {
731            require_match: true,
732            require_visible: true,
733            require_pointer_interactable: true,
734            ..DiscoveryRequirements::default()
735        },
736    )
737    .await?;
738    let center = discovered.first_center.ok_or_else(|| {
739        format!(
740            "element discovery did not return a hover center for {} {}",
741            selector_kind_label(parsed.kind),
742            parsed.value
743        )
744    })?;
745    cdp.dispatch_mouse_move(&session_id, center.x, center.y)
746        .await?;
747    cdp.detach_from_target(&session_id).await?;
748
749    Ok(HoverInfo {
750        css_selector: css_selector.to_string(),
751        note: format!(
752            "hovered element matching {} {}",
753            selector_kind_label(parsed.kind),
754            parsed.value
755        ),
756    })
757}
758
759pub async fn press_key_via_cdp(
760    cdp_websocket_url: &str,
761    target_id: &str,
762    css_selector: &str,
763    key: &str,
764    text: Option<&str>,
765) -> Result<PressKeyInfo, String> {
766    let parsed = parse_selector("press_key", css_selector)?;
767    if key.trim().is_empty() {
768        return Err("press_key command requires a non-empty key".to_string());
769    }
770
771    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
772    let session_id = cdp.prepare_page_target_session(target_id).await?;
773    discover_elements_via_cdp(
774        &mut cdp,
775        &session_id,
776        css_selector,
777        DiscoveryRequirements {
778            require_match: true,
779            require_visible: true,
780            focus_first_match: true,
781            require_focus: true,
782            ..DiscoveryRequirements::default()
783        },
784    )
785    .await?;
786    cdp.dispatch_key_press(&session_id, key, text).await?;
787    cdp.detach_from_target(&session_id).await?;
788
789    Ok(PressKeyInfo {
790        css_selector: css_selector.to_string(),
791        key: key.to_string(),
792        note: format!(
793            "pressed key {key} on element matching {} {}",
794            selector_kind_label(parsed.kind),
795            parsed.value
796        ),
797    })
798}
799
800pub async fn get_text_content_via_cdp(
801    cdp_websocket_url: &str,
802    target_id: &str,
803    css_selector: &str,
804) -> Result<TextInfo, String> {
805    get_text_via_cdp(cdp_websocket_url, target_id, css_selector, "textContent")
806        .await
807        .map(|mut info| {
808            let parsed = parse_selector("get_text_content", css_selector)
809                .expect("selector was already validated for textContent resolution");
810            info.note = format!(
811                "resolved textContent for {} {}",
812                selector_kind_label(parsed.kind),
813                parsed.value
814            );
815            info
816        })
817}
818
819pub async fn get_inner_text_via_cdp(
820    cdp_websocket_url: &str,
821    target_id: &str,
822    css_selector: &str,
823) -> Result<TextInfo, String> {
824    get_text_via_cdp(cdp_websocket_url, target_id, css_selector, "innerText")
825        .await
826        .map(|mut info| {
827            let parsed = parse_selector("get_inner_text", css_selector)
828                .expect("selector was already validated for innerText resolution");
829            info.note = format!(
830                "resolved innerText for {} {}",
831                selector_kind_label(parsed.kind),
832                parsed.value
833            );
834            info
835        })
836}
837
838pub async fn wait_for_selector_via_cdp(
839    cdp_websocket_url: &str,
840    target_id: &str,
841    css_selector: &str,
842    visible: bool,
843) -> Result<WaitForSelectorInfo, String> {
844    let parsed = parse_selector("wait_for_selector", css_selector)?;
845    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
846    let session_id = cdp.prepare_page_target_session(target_id).await?;
847    let discovered = discover_elements_via_cdp(
848        &mut cdp,
849        &session_id,
850        css_selector,
851        DiscoveryRequirements {
852            require_visible: visible,
853            ..DiscoveryRequirements::default()
854        },
855    )
856    .await?;
857    let success = if visible {
858        discovered.first_center.is_some()
859    } else {
860        discovered.count > 0
861    };
862    cdp.detach_from_target(&session_id).await?;
863
864    if !success {
865        return Err(if visible {
866            format!(
867                "no visible element matches {} {}",
868                selector_kind_label(parsed.kind),
869                parsed.value
870            )
871        } else {
872            format!(
873                "no element matches {} {}",
874                selector_kind_label(parsed.kind),
875                parsed.value
876            )
877        });
878    }
879
880    Ok(WaitForSelectorInfo {
881        css_selector: css_selector.to_string(),
882        visible,
883        note: if visible {
884            format!(
885                "visible element matched {} {}",
886                selector_kind_label(parsed.kind),
887                parsed.value
888            )
889        } else {
890            format!(
891                "element matched {} {}",
892                selector_kind_label(parsed.kind),
893                parsed.value
894            )
895        },
896    })
897}
898
899pub async fn click_element(
900    browser_session: &BrowserSessionHandle,
901    page_session: &PageSessionHandle,
902    css_selector: &str,
903) -> Result<ClickInfo, String> {
904    match (browser_session, page_session) {
905        (
906            BrowserSessionHandle::Chromium { cdp_websocket_url },
907            PageSessionHandle::Chromium {
908                browsing_context_id,
909                mapper_target_id,
910                ..
911            },
912        ) => {
913            click_element_via_bidi(
914                cdp_websocket_url,
915                mapper_target_id.as_deref(),
916                browsing_context_id.as_deref(),
917                css_selector,
918            )
919            .await
920        }
921        (
922            BrowserSessionHandle::Firefox {
923                connection_id,
924                bidi_session_id,
925            },
926            PageSessionHandle::Firefox {
927                browsing_context_id,
928            },
929        ) => {
930            let parsed = parse_selector("click_element", css_selector)?;
931            let mut sessions = firefox_session_guard(connection_id).await?;
932            let bidi = &mut sessions
933                .get_mut(connection_id)
934                .expect("Firefox session guard validated connection id")
935                .connection;
936            let query_first_js = selector_query_first_js(parsed.kind);
937            firefox_evaluate_void(
938                bidi,
939                browsing_context_id,
940                &format!(
941                    "(() => {{
942                        const selector = {};
943                        const requirements = {};
944                        {}
945                        const element = {query_first_js};
946                        const prepared = allwrightPrepareElement(element, selector, '{}', requirements);
947                        prepared.element.click();
948                    }})()",
949                    json_string_literal(&parsed.value, "click selector")?,
950                    discovery_requirements_literal(DiscoveryRequirements {
951                        require_match: true,
952                        require_visible: true,
953                        require_pointer_interactable: true,
954                        ..DiscoveryRequirements::default()
955                    }),
956                    actionability_helpers_js(),
957                    selector_kind_label(parsed.kind),
958                ),
959            )
960            .await?;
961            Ok(ClickInfo {
962                css_selector: css_selector.to_string(),
963                note: format!(
964                    "clicked element via Firefox WebDriver BiDi using {} {}",
965                    selector_kind_label(parsed.kind),
966                    parsed.value
967                ),
968                bidi_session_id: bidi_session_id.clone(),
969            })
970        }
971        _ => Err("browser/page backend mismatch while clicking".to_string()),
972    }
973}
974
975pub async fn count_elements(
976    browser_session: &BrowserSessionHandle,
977    page_session: &PageSessionHandle,
978    css_selector: &str,
979) -> Result<ElementCountInfo, String> {
980    match (browser_session, page_session) {
981        (
982            BrowserSessionHandle::Chromium { cdp_websocket_url },
983            PageSessionHandle::Chromium {
984                browsing_context_id,
985                mapper_target_id,
986                ..
987            },
988        ) => {
989            let parsed = parse_selector("count_elements", css_selector)?;
990            let discovered = discover_elements_via_bidi(
991                cdp_websocket_url,
992                mapper_target_id.as_deref(),
993                browsing_context_id.as_deref(),
994                css_selector,
995                DiscoveryRequirements::default(),
996            )
997            .await?;
998            Ok(ElementCountInfo {
999                css_selector: css_selector.to_string(),
1000                count: discovered.count,
1001                note: format!(
1002                    "counted {} element(s) matching {} {}",
1003                    discovered.count,
1004                    selector_kind_label(parsed.kind),
1005                    parsed.value
1006                ),
1007            })
1008        }
1009        (
1010            BrowserSessionHandle::Firefox { connection_id, .. },
1011            PageSessionHandle::Firefox {
1012                browsing_context_id,
1013            },
1014        ) => {
1015            let parsed = parse_selector("count_elements", css_selector)?;
1016            let mut sessions = firefox_session_guard(connection_id).await?;
1017            let bidi = &mut sessions
1018                .get_mut(connection_id)
1019                .expect("Firefox session guard validated connection id")
1020                .connection;
1021            let query_all_js = selector_query_all_js(parsed.kind);
1022            let count = firefox_evaluate_u32(
1023                bidi,
1024                browsing_context_id,
1025                &format!(
1026                    "(() => {{
1027                        const selector = {};
1028                        return {query_all_js}.length;
1029                    }})()",
1030                    json_string_literal(&parsed.value, "count selector")?,
1031                ),
1032            )
1033            .await?;
1034            Ok(ElementCountInfo {
1035                css_selector: css_selector.to_string(),
1036                count,
1037                note: format!(
1038                    "counted {count} element(s) matching {} {}",
1039                    selector_kind_label(parsed.kind),
1040                    parsed.value
1041                ),
1042            })
1043        }
1044        _ => Err("browser/page backend mismatch while counting elements".to_string()),
1045    }
1046}
1047
1048pub async fn highlight_elements(
1049    browser_session: &BrowserSessionHandle,
1050    page_session: &PageSessionHandle,
1051    css_selector: &str,
1052    duration_ms: u64,
1053) -> Result<HighlightElementsInfo, String> {
1054    match (browser_session, page_session) {
1055        (
1056            BrowserSessionHandle::Chromium { cdp_websocket_url },
1057            PageSessionHandle::Chromium {
1058                browsing_context_id,
1059                mapper_target_id,
1060                ..
1061            },
1062        ) => {
1063            let parsed = parse_selector("highlight_elements", css_selector)?;
1064            let duration_ms = u32::try_from(duration_ms).unwrap_or(u32::MAX).max(1);
1065            let count = chromium_bidi_evaluate_u32(cdp_websocket_url, mapper_target_id.as_deref(), browsing_context_id.as_deref(), &format!(
1066                "(() => {{ const selector = {}; const durationMs = {duration_ms}; const elements = {}; for (const element of elements) {{ if (!(element instanceof HTMLElement)) continue; element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }}); const outline = element.style.outline; const outlineOffset = element.style.outlineOffset; const backgroundColor = element.style.backgroundColor; element.style.outline = '3px solid #ff5a36'; element.style.outlineOffset = '2px'; element.style.backgroundColor = 'rgba(255, 235, 59, 0.35)'; window.setTimeout(() => {{ element.style.outline = outline; element.style.outlineOffset = outlineOffset; element.style.backgroundColor = backgroundColor; }}, durationMs); }} return elements.length; }})()",
1067                json_string_literal(&parsed.value, "highlight selector")?, selector_query_all_js(parsed.kind),
1068            )).await?;
1069            Ok(HighlightElementsInfo {
1070                css_selector: css_selector.to_string(),
1071                count,
1072                note: format!(
1073                    "highlighted {count} element(s) matching {} {} for {duration_ms}ms",
1074                    selector_kind_label(parsed.kind),
1075                    parsed.value
1076                ),
1077            })
1078        }
1079        (
1080            BrowserSessionHandle::Firefox { connection_id, .. },
1081            PageSessionHandle::Firefox {
1082                browsing_context_id,
1083            },
1084        ) => {
1085            let parsed = parse_selector("highlight_elements", css_selector)?;
1086            let duration_ms = u32::try_from(duration_ms).unwrap_or(u32::MAX).max(1);
1087            let mut sessions = firefox_session_guard(connection_id).await?;
1088            let bidi = &mut sessions
1089                .get_mut(connection_id)
1090                .expect("Firefox session guard validated connection id")
1091                .connection;
1092            let query_all_js = selector_query_all_js(parsed.kind);
1093            let count = firefox_evaluate_u32(
1094                bidi,
1095                browsing_context_id,
1096                &format!(
1097                    "(() => {{
1098                        const selector = {};
1099                        const durationMs = {duration_ms};
1100                        const elements = {query_all_js};
1101                        for (const element of elements) {{
1102                            if (!(element instanceof HTMLElement)) {{
1103                                continue;
1104                            }}
1105                            element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
1106                            const priorOutline = element.style.outline;
1107                            const priorOutlineOffset = element.style.outlineOffset;
1108                            const priorBackgroundColor = element.style.backgroundColor;
1109                            element.style.outline = '3px solid #ff5a36';
1110                            element.style.outlineOffset = '2px';
1111                            element.style.backgroundColor = 'rgba(255, 235, 59, 0.35)';
1112                            window.setTimeout(() => {{
1113                                element.style.outline = priorOutline;
1114                                element.style.outlineOffset = priorOutlineOffset;
1115                                element.style.backgroundColor = priorBackgroundColor;
1116                            }}, durationMs);
1117                        }}
1118                        return elements.length;
1119                    }})()",
1120                    json_string_literal(&parsed.value, "highlight selector")?,
1121                ),
1122            )
1123            .await?;
1124            Ok(HighlightElementsInfo {
1125                css_selector: css_selector.to_string(),
1126                count,
1127                note: format!(
1128                    "highlighted {count} element(s) matching {} {} for {duration_ms}ms",
1129                    selector_kind_label(parsed.kind),
1130                    parsed.value
1131                ),
1132            })
1133        }
1134        _ => Err("browser/page backend mismatch while highlighting elements".to_string()),
1135    }
1136}
1137
1138pub async fn focus_element(
1139    browser_session: &BrowserSessionHandle,
1140    page_session: &PageSessionHandle,
1141    css_selector: &str,
1142) -> Result<FocusInfo, String> {
1143    match (browser_session, page_session) {
1144        (
1145            BrowserSessionHandle::Chromium { cdp_websocket_url },
1146            PageSessionHandle::Chromium {
1147                browsing_context_id,
1148                mapper_target_id,
1149                ..
1150            },
1151        ) => {
1152            let parsed = parse_selector("focus_element", css_selector)?;
1153            discover_elements_via_bidi(
1154                cdp_websocket_url,
1155                mapper_target_id.as_deref(),
1156                browsing_context_id.as_deref(),
1157                css_selector,
1158                DiscoveryRequirements {
1159                    require_match: true,
1160                    require_visible: true,
1161                    focus_first_match: true,
1162                    require_focus: true,
1163                    ..DiscoveryRequirements::default()
1164                },
1165            )
1166            .await?;
1167            Ok(FocusInfo {
1168                css_selector: css_selector.to_string(),
1169                note: format!(
1170                    "focused element matching {} {}",
1171                    selector_kind_label(parsed.kind),
1172                    parsed.value
1173                ),
1174            })
1175        }
1176        (
1177            BrowserSessionHandle::Firefox { connection_id, .. },
1178            PageSessionHandle::Firefox {
1179                browsing_context_id,
1180            },
1181        ) => {
1182            let parsed = parse_selector("focus_element", css_selector)?;
1183            let mut sessions = firefox_session_guard(connection_id).await?;
1184            let bidi = &mut sessions
1185                .get_mut(connection_id)
1186                .expect("Firefox session guard validated connection id")
1187                .connection;
1188            let query_first_js = selector_query_first_js(parsed.kind);
1189            firefox_evaluate_void(
1190                bidi,
1191                browsing_context_id,
1192                &format!(
1193                    "(() => {{
1194                        const selector = {};
1195                        const requirements = {};
1196                        {}
1197                        const element = {query_first_js};
1198                        allwrightPrepareElement(element, selector, '{}', requirements);
1199                    }})()",
1200                    json_string_literal(&parsed.value, "focus selector")?,
1201                    discovery_requirements_literal(DiscoveryRequirements {
1202                        require_match: true,
1203                        require_visible: true,
1204                        focus_first_match: true,
1205                        require_focus: true,
1206                        ..DiscoveryRequirements::default()
1207                    }),
1208                    actionability_helpers_js(),
1209                    selector_kind_label(parsed.kind),
1210                ),
1211            )
1212            .await?;
1213            Ok(FocusInfo {
1214                css_selector: css_selector.to_string(),
1215                note: format!(
1216                    "focused element matching {} {}",
1217                    selector_kind_label(parsed.kind),
1218                    parsed.value
1219                ),
1220            })
1221        }
1222        _ => Err("browser/page backend mismatch while focusing element".to_string()),
1223    }
1224}
1225
1226pub async fn fill_element(
1227    browser_session: &BrowserSessionHandle,
1228    page_session: &PageSessionHandle,
1229    css_selector: &str,
1230    value: &str,
1231) -> Result<FillInfo, String> {
1232    match (browser_session, page_session) {
1233        (
1234            BrowserSessionHandle::Chromium { cdp_websocket_url },
1235            PageSessionHandle::Chromium {
1236                browsing_context_id,
1237                mapper_target_id,
1238                ..
1239            },
1240        ) => {
1241            let parsed = parse_selector("fill_element", css_selector)?;
1242            chromium_bidi_evaluate_void(
1243                cdp_websocket_url,
1244                mapper_target_id.as_deref(),
1245                browsing_context_id.as_deref(),
1246                &dom_set_value_and_dispatch_events_js(
1247                    parsed.kind,
1248                    &json_string_literal(&parsed.value, "fill selector")?,
1249                    &json_string_literal(value, "fill value")?,
1250                ),
1251            )
1252            .await?;
1253            Ok(FillInfo {
1254                css_selector: css_selector.to_string(),
1255                value: value.to_string(),
1256                note: format!(
1257                    "filled element matching {} {}",
1258                    selector_kind_label(parsed.kind),
1259                    parsed.value
1260                ),
1261            })
1262        }
1263        (
1264            BrowserSessionHandle::Firefox { connection_id, .. },
1265            PageSessionHandle::Firefox {
1266                browsing_context_id,
1267            },
1268        ) => {
1269            let parsed = parse_selector("fill_element", css_selector)?;
1270            let mut sessions = firefox_session_guard(connection_id).await?;
1271            let bidi = &mut sessions
1272                .get_mut(connection_id)
1273                .expect("Firefox session guard validated connection id")
1274                .connection;
1275            firefox_evaluate_void(
1276                bidi,
1277                browsing_context_id,
1278                &dom_set_value_and_dispatch_events_js(
1279                    parsed.kind,
1280                    &json_string_literal(&parsed.value, "fill selector")?,
1281                    &json_string_literal(value, "fill value")?,
1282                ),
1283            )
1284            .await?;
1285            Ok(FillInfo {
1286                css_selector: css_selector.to_string(),
1287                value: value.to_string(),
1288                note: format!(
1289                    "filled element matching {} {}",
1290                    selector_kind_label(parsed.kind),
1291                    parsed.value
1292                ),
1293            })
1294        }
1295        _ => Err("browser/page backend mismatch while filling element".to_string()),
1296    }
1297}
1298
1299pub async fn hover_element(
1300    browser_session: &BrowserSessionHandle,
1301    page_session: &PageSessionHandle,
1302    css_selector: &str,
1303) -> Result<HoverInfo, String> {
1304    match (browser_session, page_session) {
1305        (
1306            BrowserSessionHandle::Chromium { cdp_websocket_url },
1307            PageSessionHandle::Chromium {
1308                browsing_context_id,
1309                mapper_target_id,
1310                ..
1311            },
1312        ) => {
1313            let parsed = parse_selector("hover_element", css_selector)?;
1314            let center = discover_elements_via_bidi(
1315                cdp_websocket_url,
1316                mapper_target_id.as_deref(),
1317                browsing_context_id.as_deref(),
1318                css_selector,
1319                DiscoveryRequirements {
1320                    require_match: true,
1321                    require_visible: true,
1322                    require_pointer_interactable: true,
1323                    ..DiscoveryRequirements::default()
1324                },
1325            )
1326            .await?
1327            .first_center
1328            .ok_or_else(|| {
1329                format!(
1330                    "element discovery did not return a hover center for {} {}",
1331                    selector_kind_label(parsed.kind),
1332                    parsed.value
1333                )
1334            })?;
1335            let (mut cdp, mapper, context) = chromium_bidi_session(
1336                cdp_websocket_url,
1337                mapper_target_id.as_deref(),
1338                browsing_context_id.as_deref(),
1339            )
1340            .await?;
1341            cdp.send_bidi_command(&mapper.mapper_session_id, &json!({"id": 2, "method": "input.performActions", "params": {"context": context, "actions": [{"type":"pointer", "id":"allwright-mouse", "parameters":{"pointerType":"mouse"}, "actions":[{"type":"pointerMove", "origin":"viewport", "x":center.x, "y":center.y}]}]}})).await?;
1342            Ok(HoverInfo {
1343                css_selector: css_selector.to_string(),
1344                note: format!(
1345                    "hovered element matching {} {}",
1346                    selector_kind_label(parsed.kind),
1347                    parsed.value
1348                ),
1349            })
1350        }
1351        (
1352            BrowserSessionHandle::Firefox { connection_id, .. },
1353            PageSessionHandle::Firefox {
1354                browsing_context_id,
1355            },
1356        ) => {
1357            let parsed = parse_selector("hover_element", css_selector)?;
1358            let mut sessions = firefox_session_guard(connection_id).await?;
1359            let bidi = &mut sessions
1360                .get_mut(connection_id)
1361                .expect("Firefox session guard validated connection id")
1362                .connection;
1363            let query_first_js = selector_query_first_js(parsed.kind);
1364            firefox_evaluate_void(
1365                bidi,
1366                browsing_context_id,
1367                &format!(
1368                    "(() => {{
1369                        const selector = {};
1370                        const requirements = {};
1371                        {}
1372                        const element = {query_first_js};
1373                        const prepared = allwrightPrepareElement(element, selector, '{}', requirements);
1374                        for (const type of ['pointerover', 'mouseover', 'mouseenter', 'mousemove']) {{
1375                            prepared.element.dispatchEvent(new MouseEvent(type, {{ bubbles: true, cancelable: true, view: window }}));
1376                        }}
1377                    }})()",
1378                    json_string_literal(&parsed.value, "hover selector")?,
1379                    discovery_requirements_literal(DiscoveryRequirements {
1380                        require_match: true,
1381                        require_visible: true,
1382                        require_pointer_interactable: true,
1383                        ..DiscoveryRequirements::default()
1384                    }),
1385                    actionability_helpers_js(),
1386                    selector_kind_label(parsed.kind),
1387                ),
1388            )
1389            .await?;
1390            Ok(HoverInfo {
1391                css_selector: css_selector.to_string(),
1392                note: format!(
1393                    "hovered element matching {} {}",
1394                    selector_kind_label(parsed.kind),
1395                    parsed.value
1396                ),
1397            })
1398        }
1399        _ => Err("browser/page backend mismatch while hovering element".to_string()),
1400    }
1401}
1402
1403pub async fn press_key(
1404    browser_session: &BrowserSessionHandle,
1405    page_session: &PageSessionHandle,
1406    css_selector: &str,
1407    key: &str,
1408    text: Option<&str>,
1409) -> Result<PressKeyInfo, String> {
1410    match (browser_session, page_session) {
1411        (
1412            BrowserSessionHandle::Chromium { cdp_websocket_url },
1413            PageSessionHandle::Chromium {
1414                browsing_context_id,
1415                mapper_target_id,
1416                ..
1417            },
1418        ) => {
1419            let parsed = parse_selector("press_key", css_selector)?;
1420            if key.trim().is_empty() {
1421                return Err("press_key command requires a non-empty key".to_string());
1422            }
1423            let query_first_js = selector_query_first_js(parsed.kind);
1424            chromium_bidi_evaluate_void(cdp_websocket_url, mapper_target_id.as_deref(), browsing_context_id.as_deref(), &format!(
1425                "(() => {{ const selector = {}; const key = {}; const text = {}; const requirements = {}; {} const element = {query_first_js}; const prepared = allwrightPrepareElement(element, selector, '{}', requirements); prepared.element.dispatchEvent(new KeyboardEvent('keydown', {{ key, bubbles: true }})); if (text && 'value' in prepared.element) {{ prepared.element.value += text; prepared.element.dispatchEvent(new Event('input', {{ bubbles: true }})); prepared.element.dispatchEvent(new Event('change', {{ bubbles: true }})); }} prepared.element.dispatchEvent(new KeyboardEvent('keyup', {{ key, bubbles: true }})); }})()",
1426                json_string_literal(&parsed.value, "press selector")?, json_string_literal(key, "press key")?, json_string_literal(text.unwrap_or(""), "press text")?,
1427                discovery_requirements_literal(DiscoveryRequirements { require_match: true, require_visible: true, focus_first_match: true, require_focus: true, ..DiscoveryRequirements::default() }), actionability_helpers_js(), selector_kind_label(parsed.kind),
1428            )).await?;
1429            Ok(PressKeyInfo {
1430                css_selector: css_selector.to_string(),
1431                key: key.to_string(),
1432                note: format!(
1433                    "pressed key {key} on element matching {} {}",
1434                    selector_kind_label(parsed.kind),
1435                    parsed.value
1436                ),
1437            })
1438        }
1439        (
1440            BrowserSessionHandle::Firefox { connection_id, .. },
1441            PageSessionHandle::Firefox {
1442                browsing_context_id,
1443            },
1444        ) => {
1445            let parsed = parse_selector("press_key", css_selector)?;
1446            if key.trim().is_empty() {
1447                return Err("press_key command requires a non-empty key".to_string());
1448            }
1449            let mut sessions = firefox_session_guard(connection_id).await?;
1450            let bidi = &mut sessions
1451                .get_mut(connection_id)
1452                .expect("Firefox session guard validated connection id")
1453                .connection;
1454            let query_first_js = selector_query_first_js(parsed.kind);
1455            firefox_evaluate_void(
1456                bidi,
1457                browsing_context_id,
1458                &format!(
1459                    "(() => {{
1460                        const selector = {};
1461                        const key = {};
1462                        const text = {};
1463                        const requirements = {};
1464                        {}
1465                        const element = {query_first_js};
1466                        const prepared = allwrightPrepareElement(element, selector, '{}', requirements);
1467                        prepared.element.dispatchEvent(new KeyboardEvent('keydown', {{ key, bubbles: true }}));
1468                        if (text && 'value' in prepared.element) {{
1469                            prepared.element.value += text;
1470                            prepared.element.dispatchEvent(new Event('input', {{ bubbles: true }}));
1471                            prepared.element.dispatchEvent(new Event('change', {{ bubbles: true }}));
1472                        }}
1473                        prepared.element.dispatchEvent(new KeyboardEvent('keyup', {{ key, bubbles: true }}));
1474                    }})()",
1475                    json_string_literal(&parsed.value, "press selector")?,
1476                    json_string_literal(key, "press key")?,
1477                    json_string_literal(text.unwrap_or(""), "press text")?,
1478                    discovery_requirements_literal(DiscoveryRequirements {
1479                        require_match: true,
1480                        require_visible: true,
1481                        focus_first_match: true,
1482                        require_focus: true,
1483                        ..DiscoveryRequirements::default()
1484                    }),
1485                    actionability_helpers_js(),
1486                    selector_kind_label(parsed.kind),
1487                ),
1488            )
1489            .await?;
1490            Ok(PressKeyInfo {
1491                css_selector: css_selector.to_string(),
1492                key: key.to_string(),
1493                note: format!(
1494                    "pressed key {key} on element matching {} {}",
1495                    selector_kind_label(parsed.kind),
1496                    parsed.value
1497                ),
1498            })
1499        }
1500        _ => Err("browser/page backend mismatch while pressing a key".to_string()),
1501    }
1502}
1503
1504pub async fn get_text_content(
1505    browser_session: &BrowserSessionHandle,
1506    page_session: &PageSessionHandle,
1507    css_selector: &str,
1508) -> Result<TextInfo, String> {
1509    match (browser_session, page_session) {
1510        (
1511            BrowserSessionHandle::Chromium { cdp_websocket_url },
1512            PageSessionHandle::Chromium {
1513                browsing_context_id,
1514                mapper_target_id,
1515                ..
1516            },
1517        ) => chromium_bidi_get_text(
1518            cdp_websocket_url,
1519            mapper_target_id.as_deref(),
1520            browsing_context_id.as_deref(),
1521            css_selector,
1522            "textContent",
1523        )
1524        .await
1525        .map(|mut info| {
1526            info.note = format!("resolved textContent for {}", css_selector);
1527            info
1528        }),
1529        (
1530            BrowserSessionHandle::Firefox { connection_id, .. },
1531            PageSessionHandle::Firefox {
1532                browsing_context_id,
1533            },
1534        ) => {
1535            firefox_get_text(
1536                connection_id,
1537                browsing_context_id,
1538                css_selector,
1539                "textContent",
1540            )
1541            .await
1542        }
1543        _ => Err("browser/page backend mismatch while resolving textContent".to_string()),
1544    }
1545}
1546
1547pub async fn get_inner_text(
1548    browser_session: &BrowserSessionHandle,
1549    page_session: &PageSessionHandle,
1550    css_selector: &str,
1551) -> Result<TextInfo, String> {
1552    match (browser_session, page_session) {
1553        (
1554            BrowserSessionHandle::Chromium { cdp_websocket_url },
1555            PageSessionHandle::Chromium {
1556                browsing_context_id,
1557                mapper_target_id,
1558                ..
1559            },
1560        ) => chromium_bidi_get_text(
1561            cdp_websocket_url,
1562            mapper_target_id.as_deref(),
1563            browsing_context_id.as_deref(),
1564            css_selector,
1565            "innerText",
1566        )
1567        .await
1568        .map(|mut info| {
1569            info.note = format!("resolved innerText for {}", css_selector);
1570            info
1571        }),
1572        (
1573            BrowserSessionHandle::Firefox { connection_id, .. },
1574            PageSessionHandle::Firefox {
1575                browsing_context_id,
1576            },
1577        ) => {
1578            firefox_get_text(
1579                connection_id,
1580                browsing_context_id,
1581                css_selector,
1582                "innerText",
1583            )
1584            .await
1585        }
1586        _ => Err("browser/page backend mismatch while resolving innerText".to_string()),
1587    }
1588}
1589
1590pub async fn wait_for_selector(
1591    browser_session: &BrowserSessionHandle,
1592    page_session: &PageSessionHandle,
1593    css_selector: &str,
1594    visible: bool,
1595) -> Result<WaitForSelectorInfo, String> {
1596    match (browser_session, page_session) {
1597        (
1598            BrowserSessionHandle::Chromium { cdp_websocket_url },
1599            PageSessionHandle::Chromium {
1600                browsing_context_id,
1601                mapper_target_id,
1602                ..
1603            },
1604        ) => {
1605            let parsed = parse_selector("wait_for_selector", css_selector)?;
1606            let discovered = discover_elements_via_bidi(
1607                cdp_websocket_url,
1608                mapper_target_id.as_deref(),
1609                browsing_context_id.as_deref(),
1610                css_selector,
1611                DiscoveryRequirements {
1612                    require_visible: visible,
1613                    ..DiscoveryRequirements::default()
1614                },
1615            )
1616            .await?;
1617            let success = if visible {
1618                discovered.first_center.is_some()
1619            } else {
1620                discovered.count > 0
1621            };
1622            if !success {
1623                return Err(if visible {
1624                    format!(
1625                        "no visible element matches {} {}",
1626                        selector_kind_label(parsed.kind),
1627                        parsed.value
1628                    )
1629                } else {
1630                    format!(
1631                        "no element matches {} {}",
1632                        selector_kind_label(parsed.kind),
1633                        parsed.value
1634                    )
1635                });
1636            }
1637            Ok(WaitForSelectorInfo {
1638                css_selector: css_selector.to_string(),
1639                visible,
1640                note: if visible {
1641                    format!(
1642                        "visible element matched {} {}",
1643                        selector_kind_label(parsed.kind),
1644                        parsed.value
1645                    )
1646                } else {
1647                    format!(
1648                        "element matched {} {}",
1649                        selector_kind_label(parsed.kind),
1650                        parsed.value
1651                    )
1652                },
1653            })
1654        }
1655        (
1656            BrowserSessionHandle::Firefox { connection_id, .. },
1657            PageSessionHandle::Firefox {
1658                browsing_context_id,
1659            },
1660        ) => {
1661            let parsed = parse_selector("wait_for_selector", css_selector)?;
1662            let mut sessions = firefox_session_guard(connection_id).await?;
1663            let bidi = &mut sessions
1664                .get_mut(connection_id)
1665                .expect("Firefox session guard validated connection id")
1666                .connection;
1667            let query_first_js = selector_query_first_js(parsed.kind);
1668            let success = firefox_evaluate_bool(
1669                bidi,
1670                browsing_context_id,
1671                &format!(
1672                    "(() => {{
1673                        const selector = {};
1674                        const requirements = {};
1675                        {}
1676                        const element = {query_first_js};
1677                        if (!element) {{
1678                            return false;
1679                        }}
1680                        if (!{}) {{
1681                            return true;
1682                        }}
1683                        try {{
1684                            return !!allwrightPrepareElement(element, selector, '{}', requirements);
1685                        }} catch (_error) {{
1686                            return false;
1687                        }}
1688                    }})()",
1689                    json_string_literal(&parsed.value, "wait selector")?,
1690                    discovery_requirements_literal(DiscoveryRequirements {
1691                        require_visible: visible,
1692                        ..DiscoveryRequirements::default()
1693                    }),
1694                    actionability_helpers_js(),
1695                    if visible { "true" } else { "false" },
1696                    selector_kind_label(parsed.kind),
1697                ),
1698            )
1699            .await?;
1700            if !success {
1701                return Err(if visible {
1702                    format!(
1703                        "no visible element matches {} {}",
1704                        selector_kind_label(parsed.kind),
1705                        parsed.value
1706                    )
1707                } else {
1708                    format!(
1709                        "no element matches {} {}",
1710                        selector_kind_label(parsed.kind),
1711                        parsed.value
1712                    )
1713                });
1714            }
1715            Ok(WaitForSelectorInfo {
1716                css_selector: css_selector.to_string(),
1717                visible,
1718                note: if visible {
1719                    format!(
1720                        "visible element matched {} {}",
1721                        selector_kind_label(parsed.kind),
1722                        parsed.value
1723                    )
1724                } else {
1725                    format!(
1726                        "element matched {} {}",
1727                        selector_kind_label(parsed.kind),
1728                        parsed.value
1729                    )
1730                },
1731            })
1732        }
1733        _ => Err("browser/page backend mismatch while waiting for selector".to_string()),
1734    }
1735}
1736
1737pub async fn screenshot_via_cdp(
1738    cdp_websocket_url: &str,
1739    target_id: &str,
1740    full_page: bool,
1741) -> Result<ScreenshotInfo, String> {
1742    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
1743    let session_id = cdp.prepare_page_target_session(target_id).await?;
1744    let response = cdp
1745        .send_command(
1746            "Page.captureScreenshot",
1747            json!({ "captureBeyondViewport": full_page }),
1748            Some(&session_id),
1749        )
1750        .await?;
1751    cdp.detach_from_target(&session_id).await?;
1752
1753    Ok(ScreenshotInfo {
1754        png_data: decode_base64_field(&response, "/data", "CDP screenshot data")?,
1755        note: "captured Chromium screenshot via CDP Page.captureScreenshot".to_string(),
1756    })
1757}
1758
1759pub async fn screenshot(
1760    browser_session: &BrowserSessionHandle,
1761    page_session: &PageSessionHandle,
1762    full_page: bool,
1763) -> Result<ScreenshotInfo, String> {
1764    match (browser_session, page_session) {
1765        (
1766            BrowserSessionHandle::Chromium { cdp_websocket_url },
1767            PageSessionHandle::Chromium {
1768                browsing_context_id,
1769                mapper_target_id,
1770                ..
1771            },
1772        ) => {
1773            chromium_bidi_screenshot(
1774                cdp_websocket_url,
1775                mapper_target_id.as_deref(),
1776                browsing_context_id.as_deref(),
1777                full_page,
1778            )
1779            .await
1780        }
1781        (
1782            BrowserSessionHandle::Firefox { connection_id, .. },
1783            PageSessionHandle::Firefox {
1784                browsing_context_id,
1785            },
1786        ) => {
1787            let mut sessions = firefox_session_guard(connection_id).await?;
1788            let bidi = &mut sessions
1789                .get_mut(connection_id)
1790                .expect("Firefox session guard validated connection id")
1791                .connection;
1792            let response = bidi
1793                .send_command(
1794                    "browsingContext.captureScreenshot",
1795                    json!({
1796                        "context": browsing_context_id,
1797                        "origin": if full_page { "document" } else { "viewport" },
1798                    }),
1799                )
1800                .await?;
1801            Ok(ScreenshotInfo {
1802                png_data: decode_base64_field(
1803                    &response,
1804                    "/result/data",
1805                    "Firefox screenshot data",
1806                )?,
1807                note: if full_page {
1808                    "captured full-page Firefox screenshot via native WebDriver BiDi".to_string()
1809                } else {
1810                    "captured Firefox screenshot via native WebDriver BiDi".to_string()
1811                },
1812            })
1813        }
1814        _ => Err("browser/page backend mismatch while capturing screenshot".to_string()),
1815    }
1816}
1817
1818async fn discover_elements_via_cdp(
1819    cdp: &mut CdpConnection,
1820    session_id: &str,
1821    selector: &str,
1822    requirements: DiscoveryRequirements,
1823) -> Result<DiscoveredElements, String> {
1824    let selector_literal = json_string_literal(selector, "selector for discovery")?;
1825    let selector_segments_literal = selector_segments_literal("discover_elements", selector)?;
1826    let selector_kind_label = selector_chain_kind_label("discover_elements", selector)?;
1827    let query_all_js = selector_chain_query_all_js();
1828    let requirements_literal = discovery_requirements_literal(requirements);
1829    let expression = format!(
1830        "(() => {{
1831            const selector = {selector_literal};
1832            const selectorSegments = {selector_segments_literal};
1833            const selectorChainQueryAll = () => {query_all_js};
1834            const elements = {query_all_js};
1835            const first = elements[0] ?? null;
1836            const requirements = {requirements_literal};
1837            {actionability_helpers_js}
1838            if (requirements.requireMatch && !first) {{
1839                throw new Error(`No element matches {selector_kind_label}: ${{selector}}`);
1840            }}
1841            const prepared = first
1842                ? allwrightPrepareElement(first, selector, '{selector_kind_label}', requirements)
1843                : null;
1844            return {{
1845                count: elements.length,
1846                first: prepared?.center ?? null
1847            }};
1848        }})()",
1849        actionability_helpers_js = actionability_helpers_js(),
1850    );
1851    let result = cdp
1852        .evaluate_expression(session_id, &expression, true)
1853        .await?;
1854    let count = json_u32(&result, "/result/value/count")?;
1855    let first_center = if result.pointer("/result/value/first").is_some()
1856        && !result
1857            .pointer("/result/value/first")
1858            .is_some_and(Value::is_null)
1859    {
1860        Some(ElementCenter {
1861            x: json_f64(&result, "/result/value/first/x")?,
1862            y: json_f64(&result, "/result/value/first/y")?,
1863        })
1864    } else {
1865        None
1866    };
1867
1868    Ok(DiscoveredElements {
1869        count,
1870        first_center,
1871    })
1872}
1873
1874async fn get_text_via_cdp(
1875    cdp_websocket_url: &str,
1876    target_id: &str,
1877    selector: &str,
1878    property: &str,
1879) -> Result<TextInfo, String> {
1880    let parsed = parse_selector("get_text", selector)?;
1881    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
1882    let session_id = cdp.prepare_page_target_session(target_id).await?;
1883    discover_elements_via_cdp(
1884        &mut cdp,
1885        &session_id,
1886        selector,
1887        DiscoveryRequirements {
1888            require_match: true,
1889            ..DiscoveryRequirements::default()
1890        },
1891    )
1892    .await?;
1893    let selector_literal = json_string_literal(selector, "text selector")?;
1894    let selector_segments_literal = selector_segments_literal("get_text", selector)?;
1895    let property_literal = json_string_literal(property, "text property")?;
1896    let selector_kind_label = selector_chain_kind_label("get_text", selector)?;
1897    let query_first_js = selector_chain_query_first_js();
1898    let result = cdp
1899        .evaluate_expression(
1900            &session_id,
1901            &format!(
1902                "(() => {{
1903                    const selector = {selector_literal};
1904                    const selectorSegments = {selector_segments_literal};
1905                    const selectorChainQueryAll = () => {};
1906                    const property = {property_literal};
1907                    const element = {query_first_js};
1908                    if (!element) {{
1909                        throw new Error(`No element matches {selector_kind_label}: ${{selector}}`);
1910                    }}
1911                    const value = element[property];
1912                    return typeof value === 'string' ? value : '';
1913                }})()",
1914                selector_chain_query_all_js(),
1915            ),
1916            true,
1917        )
1918        .await?;
1919    cdp.detach_from_target(&session_id).await?;
1920
1921    Ok(TextInfo {
1922        css_selector: parsed.raw.to_string(),
1923        text: json_string(&result, "/result/value")?,
1924        note: String::new(),
1925    })
1926}
1927
1928fn dom_set_value_and_dispatch_events_js(
1929    selector_kind: SelectorKind,
1930    selector_literal: &str,
1931    value_literal: &str,
1932) -> String {
1933    let query_first_js = selector_query_first_js(selector_kind);
1934    let selector_kind_label = selector_kind_label(selector_kind);
1935    format!(
1936        "(() => {{
1937            const selector = {selector_literal};
1938            const nextValue = {value_literal};
1939            const requirements = {requirements_literal};
1940            {actionability_helpers_js}
1941            const element = {query_first_js};
1942            const prepared = allwrightPrepareElement(
1943                element,
1944                selector,
1945                '{selector_kind_label}',
1946                requirements,
1947            );
1948            const editableElement = prepared.element;
1949
1950            if (editableElement instanceof HTMLInputElement || editableElement instanceof HTMLTextAreaElement) {{
1951                const prototype = editableElement instanceof HTMLInputElement
1952                    ? HTMLInputElement.prototype
1953                    : HTMLTextAreaElement.prototype;
1954                const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
1955                const setter = descriptor?.set;
1956                if (!setter) {{
1957                    throw new Error(`Element value setter is unavailable for {selector_kind_label}: ${{selector}}`);
1958                }}
1959                setter.call(editableElement, nextValue);
1960            }} else if (editableElement.isContentEditable) {{
1961                editableElement.textContent = nextValue;
1962            }} else {{
1963                throw new Error(`Element matched by {selector_kind_label} does not support fill: ${{selector}}`);
1964            }}
1965
1966            const inputEvent = typeof InputEvent === 'function'
1967                ? new InputEvent('input', {{
1968                    bubbles: true,
1969                    composed: true,
1970                    data: nextValue,
1971                    inputType: 'insertText'
1972                }})
1973                : new Event('input', {{ bubbles: true, composed: true }});
1974            editableElement.dispatchEvent(inputEvent);
1975            editableElement.dispatchEvent(new Event('change', {{ bubbles: true, composed: true }}));
1976            return true;
1977        }})()",
1978        requirements_literal = discovery_requirements_literal(DiscoveryRequirements {
1979            require_match: true,
1980            require_visible: true,
1981            require_editable: true,
1982            focus_first_match: true,
1983            require_focus: true,
1984            ..DiscoveryRequirements::default()
1985        }),
1986        actionability_helpers_js = actionability_helpers_js(),
1987    )
1988}
1989
1990fn discovery_requirements_literal(requirements: DiscoveryRequirements) -> String {
1991    format!(
1992        "{{ requireMatch: {}, requireVisible: {}, requirePointerInteractable: {}, requireEditable: {}, focusFirstMatch: {}, requireFocus: {} }}",
1993        requirements.require_match,
1994        requirements.require_visible,
1995        requirements.require_pointer_interactable,
1996        requirements.require_editable,
1997        requirements.focus_first_match,
1998        requirements.require_focus,
1999    )
2000}
2001
2002fn actionability_helpers_js() -> &'static str {
2003    r#"
2004            const allwrightCanFocusElement = (element) => {
2005                if (!(element instanceof HTMLElement)) {
2006                    return false;
2007                }
2008                if (typeof element.matches === 'function' && element.matches(':disabled')) {
2009                    return false;
2010                }
2011                return !element.closest('[inert]');
2012            };
2013            const allwrightIsEditableElement = (element) => {
2014                if (!(element instanceof HTMLElement)) {
2015                    return false;
2016                }
2017                if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
2018                    return !element.disabled && !element.readOnly;
2019                }
2020                return element.isContentEditable && !element.closest('[inert]');
2021            };
2022            const allwrightIsVisibleElement = (rect, style) => {
2023                if (!rect || rect.width <= 0 || rect.height <= 0) {
2024                    return false;
2025                }
2026                if (!style) {
2027                    return false;
2028                }
2029                if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') {
2030                    return false;
2031                }
2032                const opacity = Number.parseFloat(style.opacity ?? '1');
2033                if (!Number.isNaN(opacity) && opacity <= 0) {
2034                    return false;
2035                }
2036                return true;
2037            };
2038            const allwrightReceivesPointerEvents = (element, center, style) => {
2039                if (style.pointerEvents === 'none') {
2040                    return false;
2041                }
2042                const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
2043                const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
2044                if (center.x < 0 || center.y < 0 || center.x > viewportWidth || center.y > viewportHeight) {
2045                    return false;
2046                }
2047                const hit = document.elementFromPoint(center.x, center.y);
2048                return !!hit && (hit === element || element.contains(hit) || hit.contains(element));
2049            };
2050            const allwrightPrepareElement = (element, selector, selectorKindLabel, requirements) => {
2051                if (!element) {
2052                    throw new Error(`No element matches ${selectorKindLabel}: ${selector}`);
2053                }
2054                if (!(element instanceof Element)) {
2055                    throw new Error(`${selectorKindLabel} did not resolve to a DOM Element: ${selector}`);
2056                }
2057                if (!element.isConnected) {
2058                    throw new Error(`Element matched by ${selectorKindLabel} is detached from the live DOM: ${selector}`);
2059                }
2060                element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
2061                const rect = element.getBoundingClientRect();
2062                const style = window.getComputedStyle(element);
2063                if (requirements.requireVisible && !allwrightIsVisibleElement(rect, style)) {
2064                    throw new Error(`Element matched by ${selectorKindLabel} is not visible yet: ${selector}`);
2065                }
2066                const center = rect.width > 0 && rect.height > 0
2067                    ? { x: rect.left + (rect.width / 2), y: rect.top + (rect.height / 2) }
2068                    : null;
2069                if (requirements.requirePointerInteractable) {
2070                    if (!center || !allwrightReceivesPointerEvents(element, center, style)) {
2071                        throw new Error(`Element matched by ${selectorKindLabel} is not clickable yet: ${selector}`);
2072                    }
2073                }
2074                if (requirements.requireEditable && !allwrightIsEditableElement(element)) {
2075                    throw new Error(`Element matched by ${selectorKindLabel} is not editable yet: ${selector}`);
2076                }
2077                if (requirements.focusFirstMatch || requirements.requireFocus) {
2078                    if (!allwrightCanFocusElement(element)) {
2079                        throw new Error(`Element matched by ${selectorKindLabel} is not focusable yet: ${selector}`);
2080                    }
2081                    element.focus();
2082                    const activeElement = document.activeElement;
2083                    if (requirements.requireFocus && activeElement !== element && !element.contains(activeElement)) {
2084                        throw new Error(`Element matched by ${selectorKindLabel} did not receive focus yet: ${selector}`);
2085                    }
2086                }
2087                return { element, rect, center, style };
2088            };
2089    "#
2090}
2091
2092pub async fn resolve_bidi_context_for_tab(
2093    cdp_websocket_url: &str,
2094    existing_mapper_target_id: Option<&str>,
2095    existing_context_id: Option<&str>,
2096    current_url: Option<&str>,
2097) -> Result<(String, ChromiumBidiMapperInfo), String> {
2098    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
2099    let mapper = cdp
2100        .ensure_chromium_bidi_mapper(existing_mapper_target_id)
2101        .await?;
2102    let context_id = cdp
2103        .resolve_bidi_context_id(&mapper.mapper_session_id, existing_context_id, current_url)
2104        .await?;
2105
2106    Ok((
2107        context_id,
2108        ChromiumBidiMapperInfo {
2109            package_version: mapper.package_version,
2110            mapper_target_id: mapper.mapper_target_id,
2111            mapper_session_id: mapper.mapper_session_id,
2112            note: "resolved BiDi browsing context for tab".to_string(),
2113        },
2114    ))
2115}
2116
2117async fn chromium_bidi_session(
2118    cdp_websocket_url: &str,
2119    existing_mapper_target_id: Option<&str>,
2120    existing_context_id: Option<&str>,
2121) -> Result<(CdpConnection, MapperConnectionInfo, String), String> {
2122    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
2123    let mapper = cdp
2124        .ensure_chromium_bidi_mapper(existing_mapper_target_id)
2125        .await?;
2126    let browsing_context_id = cdp
2127        .resolve_bidi_context_id(&mapper.mapper_session_id, existing_context_id, None)
2128        .await?;
2129    Ok((cdp, mapper, browsing_context_id))
2130}
2131
2132async fn chromium_bidi_evaluate_json_on_session(
2133    cdp: &mut CdpConnection,
2134    mapper_session_id: &str,
2135    browsing_context_id: &str,
2136    expression: &str,
2137) -> Result<Value, String> {
2138    let response = cdp
2139        .send_bidi_command(
2140            mapper_session_id,
2141            &json!({
2142                "id": 2,
2143                "method": "script.evaluate",
2144                "params": {
2145                    "expression": expression,
2146                    "target": { "context": browsing_context_id },
2147                    "awaitPromise": true,
2148                },
2149            }),
2150        )
2151        .await?;
2152    let result_type = json_string(&response, "/result/type")?;
2153    if result_type != "success" {
2154        let details = response
2155            .pointer("/result/exceptionDetails")
2156            .cloned()
2157            .unwrap_or(Value::Null);
2158        return Err(format!("Chromium BiDi script.evaluate failed: {details}"));
2159    }
2160    let value = json_string(&response, "/result/result/value")?;
2161    serde_json::from_str::<Value>(&value)
2162        .map_err(|error| format!("failed to parse Chromium BiDi script result JSON: {error}"))
2163}
2164
2165async fn chromium_bidi_evaluate_json(
2166    cdp_websocket_url: &str,
2167    existing_mapper_target_id: Option<&str>,
2168    existing_context_id: Option<&str>,
2169    expression: &str,
2170) -> Result<Value, String> {
2171    let (mut cdp, mapper, browsing_context_id) = chromium_bidi_session(
2172        cdp_websocket_url,
2173        existing_mapper_target_id,
2174        existing_context_id,
2175    )
2176    .await?;
2177    chromium_bidi_evaluate_json_on_session(
2178        &mut cdp,
2179        &mapper.mapper_session_id,
2180        &browsing_context_id,
2181        expression,
2182    )
2183    .await
2184}
2185
2186async fn chromium_bidi_evaluate_void(
2187    cdp_websocket_url: &str,
2188    existing_mapper_target_id: Option<&str>,
2189    existing_context_id: Option<&str>,
2190    expression: &str,
2191) -> Result<(), String> {
2192    chromium_bidi_evaluate_json(
2193        cdp_websocket_url,
2194        existing_mapper_target_id,
2195        existing_context_id,
2196        &format!("JSON.stringify((() => {{ {expression}; return null; }})())"),
2197    )
2198    .await
2199    .map(|_| ())
2200}
2201
2202async fn chromium_bidi_evaluate_u32(
2203    cdp_websocket_url: &str,
2204    existing_mapper_target_id: Option<&str>,
2205    existing_context_id: Option<&str>,
2206    expression: &str,
2207) -> Result<u32, String> {
2208    let value = chromium_bidi_evaluate_json(
2209        cdp_websocket_url,
2210        existing_mapper_target_id,
2211        existing_context_id,
2212        &format!("JSON.stringify({expression})"),
2213    )
2214    .await?;
2215    json_u32(&value, "")
2216}
2217
2218pub async fn click_element_via_bidi(
2219    cdp_websocket_url: &str,
2220    existing_mapper_target_id: Option<&str>,
2221    existing_context_id: Option<&str>,
2222    css_selector: &str,
2223) -> Result<ClickInfo, String> {
2224    let parsed = parse_selector("click_element", css_selector)?;
2225    let selector_literal = json_string_literal(&parsed.value, "click selector")?;
2226    let query_first_js = selector_query_first_js(parsed.kind);
2227    let expression = format!(
2228        "JSON.stringify((() => {{
2229            const selector = {selector_literal};
2230            const requirements = {};
2231            {}
2232            const element = {query_first_js};
2233            return allwrightPrepareElement(element, selector, '{}', requirements).center;
2234        }})())",
2235        discovery_requirements_literal(DiscoveryRequirements {
2236            require_match: true,
2237            require_visible: true,
2238            require_pointer_interactable: true,
2239            ..DiscoveryRequirements::default()
2240        }),
2241        actionability_helpers_js(),
2242        selector_kind_label(parsed.kind),
2243    );
2244    let (mut cdp, mapper, browsing_context_id) = chromium_bidi_session(
2245        cdp_websocket_url,
2246        existing_mapper_target_id,
2247        existing_context_id,
2248    )
2249    .await?;
2250    let center = chromium_bidi_evaluate_json_on_session(
2251        &mut cdp,
2252        &mapper.mapper_session_id,
2253        &browsing_context_id,
2254        &expression,
2255    )
2256    .await?;
2257    let x = json_f64(&center, "/x")?;
2258    let y = json_f64(&center, "/y")?;
2259    cdp.send_bidi_command(
2260        &mapper.mapper_session_id,
2261        &json!({
2262            "id": 3,
2263            "method": "input.performActions",
2264            "params": {
2265                "context": browsing_context_id,
2266                "actions": [{
2267                    "type": "pointer",
2268                    "id": "allwright-mouse",
2269                    "parameters": { "pointerType": "mouse" },
2270                    "actions": [
2271                        { "type": "pointerMove", "origin": "viewport", "x": x, "y": y },
2272                        { "type": "pointerDown", "button": 0 },
2273                        { "type": "pointerUp", "button": 0 }
2274                    ]
2275                }]
2276            }
2277        }),
2278    )
2279    .await?;
2280    Ok(ClickInfo {
2281        css_selector: css_selector.to_string(),
2282        note: format!(
2283            "clicked element via Chromium WebDriver BiDi input.performActions using {} {}",
2284            selector_kind_label(parsed.kind),
2285            parsed.value
2286        ),
2287        bidi_session_id: format!("chromium-bidi:{browsing_context_id}"),
2288    })
2289}
2290
2291async fn discover_elements_via_bidi(
2292    cdp_websocket_url: &str,
2293    mapper_target_id: Option<&str>,
2294    browsing_context_id: Option<&str>,
2295    selector: &str,
2296    requirements: DiscoveryRequirements,
2297) -> Result<DiscoveredElements, String> {
2298    let selector_literal = json_string_literal(selector, "selector for discovery")?;
2299    let selector_segments_literal = selector_segments_literal("discover_elements", selector)?;
2300    let selector_kind_label = selector_chain_kind_label("discover_elements", selector)?;
2301    let query_all_js = selector_chain_query_all_js();
2302    let requirements_literal = discovery_requirements_literal(requirements);
2303    let value = chromium_bidi_evaluate_json(
2304        cdp_websocket_url,
2305        mapper_target_id,
2306        browsing_context_id,
2307        &format!(
2308            "JSON.stringify((() => {{
2309                const selector = {selector_literal};
2310                const selectorSegments = {selector_segments_literal};
2311                const selectorChainQueryAll = () => {query_all_js};
2312                const elements = {query_all_js};
2313                const first = elements[0] ?? null;
2314                const requirements = {requirements_literal};
2315                {}
2316                if (requirements.requireMatch && !first) {{
2317                    throw new Error(`No element matches {selector_kind_label}: ${{selector}}`);
2318                }}
2319                const prepared = first
2320                    ? allwrightPrepareElement(first, selector, '{selector_kind_label}', requirements)
2321                    : null;
2322                return {{ count: elements.length, first: prepared?.center ?? null }};
2323            }})())",
2324            actionability_helpers_js(),
2325        ),
2326    )
2327    .await?;
2328    let count = json_u32(&value, "/count")?;
2329    let first_center = if value
2330        .pointer("/first")
2331        .is_some_and(|center| !center.is_null())
2332    {
2333        Some(ElementCenter {
2334            x: json_f64(&value, "/first/x")?,
2335            y: json_f64(&value, "/first/y")?,
2336        })
2337    } else {
2338        None
2339    };
2340    Ok(DiscoveredElements {
2341        count,
2342        first_center,
2343    })
2344}
2345
2346async fn chromium_bidi_get_text(
2347    cdp_websocket_url: &str,
2348    mapper_target_id: Option<&str>,
2349    browsing_context_id: Option<&str>,
2350    css_selector: &str,
2351    property: &str,
2352) -> Result<TextInfo, String> {
2353    let parsed = parse_selector("get_text", css_selector)?;
2354    discover_elements_via_bidi(
2355        cdp_websocket_url,
2356        mapper_target_id,
2357        browsing_context_id,
2358        css_selector,
2359        DiscoveryRequirements {
2360            require_match: true,
2361            ..DiscoveryRequirements::default()
2362        },
2363    )
2364    .await?;
2365    let value = chromium_bidi_evaluate_json(
2366        cdp_websocket_url,
2367        mapper_target_id,
2368        browsing_context_id,
2369        &format!(
2370            "JSON.stringify((() => {{
2371                const selector = {};
2372                const element = {};
2373                if (!element) {{ throw new Error(`No element matches {}: ${{selector}}`); }}
2374                const value = element[{}];
2375                return typeof value === 'string' ? value : '';
2376            }})())",
2377            json_string_literal(&parsed.value, "text selector")?,
2378            selector_query_first_js(parsed.kind),
2379            selector_kind_label(parsed.kind),
2380            json_string_literal(property, "text property")?,
2381        ),
2382    )
2383    .await?;
2384    let text = value.as_str().ok_or_else(|| {
2385        format!("expected Chromium BiDi text result to be a string, found {value}")
2386    })?;
2387    Ok(TextInfo {
2388        css_selector: css_selector.to_string(),
2389        text: text.to_string(),
2390        note: String::new(),
2391    })
2392}
2393
2394async fn chromium_bidi_screenshot(
2395    cdp_websocket_url: &str,
2396    mapper_target_id: Option<&str>,
2397    browsing_context_id: Option<&str>,
2398    full_page: bool,
2399) -> Result<ScreenshotInfo, String> {
2400    let (mut cdp, mapper, browsing_context_id) =
2401        chromium_bidi_session(cdp_websocket_url, mapper_target_id, browsing_context_id).await?;
2402    let response = cdp
2403        .send_bidi_command(
2404            &mapper.mapper_session_id,
2405            &json!({
2406                "id": 2,
2407                "method": "browsingContext.captureScreenshot",
2408                "params": {
2409                    "context": browsing_context_id,
2410                    "origin": if full_page { "document" } else { "viewport" },
2411                }
2412            }),
2413        )
2414        .await?;
2415    Ok(ScreenshotInfo {
2416        png_data: decode_base64_field(&response, "/result/data", "Chromium BiDi screenshot data")?,
2417        note: if full_page {
2418            "captured full-page Chromium screenshot via WebDriver BiDi".to_string()
2419        } else {
2420            "captured Chromium screenshot via WebDriver BiDi".to_string()
2421        },
2422    })
2423}
2424
2425#[cfg(target_os = "macos")]
2426fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
2427    let browser_binary = chrome_binary.map(ToOwned::to_owned).unwrap_or_else(|| {
2428        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".to_string()
2429    });
2430    launch_chrome_with_cdp(&browser_binary)
2431}
2432
2433#[cfg(target_os = "linux")]
2434fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
2435    if let Some(chrome_binary) = chrome_binary {
2436        return launch_chrome_with_cdp(chrome_binary);
2437    }
2438
2439    let candidates = ["google-chrome", "chromium", "chromium-browser"];
2440
2441    for candidate in candidates {
2442        match launch_chrome_with_cdp(candidate) {
2443            Ok(launch_info) => return Ok(launch_info),
2444            Err(error) => {
2445                if error.contains("No such file or directory")
2446                    || error.contains("os error 2")
2447                    || error.contains("not found")
2448                {
2449                    continue;
2450                }
2451                return Err(format!("failed to launch {candidate}: {error}"));
2452            }
2453        }
2454    }
2455
2456    Err("could not find a Chrome-compatible browser binary".to_string())
2457}
2458
2459#[cfg(target_os = "windows")]
2460fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
2461    if let Some(chrome_binary) = chrome_binary {
2462        return launch_chrome_with_cdp(chrome_binary);
2463    }
2464
2465    launch_chrome_with_cdp("chrome")
2466}
2467
2468#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
2469fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
2470    match chrome_binary {
2471        Some(chrome_binary) => launch_chrome_with_cdp(chrome_binary),
2472        None => Err("Chrome launching is not supported on this platform yet".to_string()),
2473    }
2474}
2475
2476fn launch_chrome_with_cdp(browser_binary: &str) -> Result<ChromeLaunchInfo, String> {
2477    let user_data_dir = create_chrome_user_data_dir()?;
2478    let user_data_dir_str = user_data_dir.to_string_lossy().to_string();
2479
2480    let child = Command::new(browser_binary)
2481        .args([
2482            "--new-window",
2483            "--remote-debugging-port=0",
2484            "--no-first-run",
2485            "--no-default-browser-check",
2486            "--disable-sync",
2487            &format!("--user-data-dir={user_data_dir_str}"),
2488        ])
2489        .stdout(Stdio::null())
2490        .stderr(Stdio::null())
2491        .spawn()
2492        .map_err(|error| format!("failed to launch Chrome binary {browser_binary}: {error}"))?;
2493    let process_id = child.id();
2494
2495    let cdp_websocket_url = wait_for_cdp_endpoint(&user_data_dir)?;
2496
2497    Ok(ChromeLaunchInfo {
2498        browser: browser_binary.to_string(),
2499        note: "launched Chrome window with CDP enabled".to_string(),
2500        cdp_websocket_url,
2501        user_data_dir: user_data_dir_str,
2502        process_id,
2503    })
2504}
2505
2506fn create_chrome_user_data_dir() -> Result<PathBuf, String> {
2507    let timestamp = SystemTime::now()
2508        .duration_since(UNIX_EPOCH)
2509        .map_err(|error| format!("failed to compute timestamp: {error}"))?
2510        .as_millis();
2511    let pid = std::process::id();
2512    let dir = std::env::temp_dir().join(format!("allwright-chrome-{pid}-{timestamp}"));
2513    fs::create_dir_all(&dir).map_err(|error| {
2514        format!(
2515            "failed to create Chrome user data dir {}: {error}",
2516            dir.display()
2517        )
2518    })?;
2519    Ok(dir)
2520}
2521
2522fn wait_for_cdp_endpoint(user_data_dir: &std::path::Path) -> Result<String, String> {
2523    let active_port_file = user_data_dir.join("DevToolsActivePort");
2524
2525    for _ in 0..100 {
2526        if active_port_file.exists() {
2527            let contents = fs::read_to_string(&active_port_file).map_err(|error| {
2528                format!(
2529                    "failed to read DevToolsActivePort file {}: {error}",
2530                    active_port_file.display()
2531                )
2532            })?;
2533            return parse_devtools_active_port(&contents);
2534        }
2535        thread::sleep(StdDuration::from_millis(50));
2536    }
2537
2538    Err(format!(
2539        "timed out waiting for DevToolsActivePort in {}",
2540        user_data_dir.display()
2541    ))
2542}
2543
2544fn parse_devtools_active_port(contents: &str) -> Result<String, String> {
2545    let mut lines = contents.lines();
2546    let port = lines
2547        .next()
2548        .map(str::trim)
2549        .filter(|value| !value.is_empty())
2550        .ok_or_else(|| "DevToolsActivePort missing port line".to_string())?;
2551    let browser_path = lines
2552        .next()
2553        .map(str::trim)
2554        .filter(|value| !value.is_empty())
2555        .ok_or_else(|| "DevToolsActivePort missing websocket path line".to_string())?;
2556
2557    Ok(format!("ws://127.0.0.1:{port}{browser_path}"))
2558}
2559
2560#[cfg(target_os = "macos")]
2561fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
2562    let browser_binary = browser_binary
2563        .map(ToOwned::to_owned)
2564        .unwrap_or_else(|| "/Applications/Firefox.app/Contents/MacOS/firefox".to_string());
2565    launch_firefox_with_remote_agent(&browser_binary)
2566}
2567
2568#[cfg(target_os = "linux")]
2569fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
2570    if let Some(browser_binary) = browser_binary {
2571        return launch_firefox_with_remote_agent(browser_binary);
2572    }
2573
2574    for candidate in ["firefox", "firefox-esr"] {
2575        match launch_firefox_with_remote_agent(candidate) {
2576            Ok(launch_info) => return Ok(launch_info),
2577            Err(error)
2578                if error.contains("No such file or directory")
2579                    || error.contains("os error 2")
2580                    || error.contains("not found") => {}
2581            Err(error) => return Err(format!("failed to launch {candidate}: {error}")),
2582        }
2583    }
2584
2585    Err("could not find a Firefox browser binary".to_string())
2586}
2587
2588#[cfg(target_os = "windows")]
2589fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
2590    let browser_binary = browser_binary.unwrap_or("firefox");
2591    launch_firefox_with_remote_agent(browser_binary)
2592}
2593
2594#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
2595fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
2596    match browser_binary {
2597        Some(browser_binary) => launch_firefox_with_remote_agent(browser_binary),
2598        None => Err("Firefox launching is not supported on this platform yet".to_string()),
2599    }
2600}
2601
2602fn launch_firefox_with_remote_agent(browser_binary: &str) -> Result<BrowserLaunchInfo, String> {
2603    let user_data_dir = create_firefox_user_data_dir()?;
2604    let user_data_dir_str = user_data_dir.to_string_lossy().to_string();
2605    let remote_port = choose_loopback_port()?;
2606
2607    let child = Command::new(browser_binary)
2608        .args([
2609            "--new-instance",
2610            "--no-remote",
2611            "--remote-debugging-port",
2612            &remote_port.to_string(),
2613            "--profile",
2614            &user_data_dir_str,
2615            "about:blank",
2616        ])
2617        .stdout(Stdio::null())
2618        .stderr(Stdio::null())
2619        .spawn()
2620        .map_err(|error| format!("failed to launch Firefox binary {browser_binary}: {error}"))?;
2621    let process_id = child.id();
2622
2623    let bootstrap_ws_url = format!("ws://127.0.0.1:{remote_port}/session");
2624    let (connection_id, bidi_session_id, initial_context_id) = block_on_plugin_future(async {
2625        let mut bidi = wait_for_bidi_endpoint(&bootstrap_ws_url).await?;
2626        let session = bidi
2627            .send_command(
2628                "session.new",
2629                json!({
2630                    "capabilities": {
2631                        "alwaysMatch": {
2632                            "acceptInsecureCerts": true
2633                        }
2634                    }
2635                }),
2636            )
2637            .await?;
2638        let session_id = required_string(&session, "/result/sessionId")?;
2639        let initial_context_id = bidi.wait_for_top_level_context().await?;
2640        let connection_id = format!("firefox-bidi-{process_id}-{session_id}");
2641        firefox_session_registry().lock().await.insert(
2642            connection_id.clone(),
2643            FirefoxSessionState {
2644                process_id,
2645                connection: bidi,
2646            },
2647        );
2648        Ok((connection_id, session_id, initial_context_id))
2649    })?;
2650
2651    Ok(BrowserLaunchInfo {
2652        browser_kind: BrowserKind::Firefox,
2653        browser: browser_binary.to_string(),
2654        note: "launched Firefox window with native WebDriver BiDi enabled".to_string(),
2655        user_data_dir: user_data_dir_str,
2656        process_id,
2657        browser_session: BrowserSessionHandle::Firefox {
2658            connection_id,
2659            bidi_session_id: bidi_session_id.clone(),
2660        },
2661        initial_page: PageInfo {
2662            note: "tracked initial Firefox tab via native WebDriver BiDi".to_string(),
2663            page_session: PageSessionHandle::Firefox {
2664                browsing_context_id: initial_context_id,
2665            },
2666        },
2667    })
2668}
2669
2670fn create_firefox_user_data_dir() -> Result<PathBuf, String> {
2671    let timestamp = SystemTime::now()
2672        .duration_since(UNIX_EPOCH)
2673        .map_err(|error| format!("failed to compute timestamp: {error}"))?
2674        .as_millis();
2675    let pid = std::process::id();
2676    let dir = std::env::temp_dir().join(format!("allwright-firefox-{pid}-{timestamp}"));
2677    fs::create_dir_all(&dir).map_err(|error| {
2678        format!(
2679            "failed to create Firefox profile dir {}: {error}",
2680            dir.display()
2681        )
2682    })?;
2683    Ok(dir)
2684}
2685
2686fn choose_loopback_port() -> Result<u16, String> {
2687    let listener = TcpListener::bind("127.0.0.1:0")
2688        .map_err(|error| format!("failed to reserve a loopback port for Firefox: {error}"))?;
2689    let port = listener
2690        .local_addr()
2691        .map_err(|error| format!("failed to read reserved loopback port: {error}"))?
2692        .port();
2693    drop(listener);
2694    Ok(port)
2695}
2696
2697async fn wait_for_bidi_endpoint(ws_url: &str) -> Result<BidiConnection, String> {
2698    let mut last_error = None;
2699    for _ in 0..100 {
2700        match BidiConnection::connect(ws_url).await {
2701            Ok(connection) => return Ok(connection),
2702            Err(error) => {
2703                last_error = Some(error);
2704                sleep(Duration::from_millis(50)).await;
2705            }
2706        }
2707    }
2708
2709    Err(format!(
2710        "timed out waiting for WebDriver BiDi endpoint {ws_url}: {}",
2711        last_error.unwrap_or_else(|| "unknown error".to_string())
2712    ))
2713}
2714
2715fn parse_selector<'a>(command_name: &str, selector: &'a str) -> Result<ParsedSelector<'a>, String> {
2716    let trimmed = selector.trim();
2717    if trimmed.is_empty() {
2718        return Err(format!(
2719            "{command_name} command requires a non-empty selector"
2720        ));
2721    }
2722
2723    let lowercase = trimmed.to_ascii_lowercase();
2724    let parsed = if lowercase.starts_with("xpath=") {
2725        let value = parse_selector_value(&trimmed[6..])?;
2726        ParsedSelector {
2727            raw: selector,
2728            value,
2729            kind: SelectorKind::XPath,
2730        }
2731    } else if lowercase.starts_with("xpath:") {
2732        let value = parse_selector_value(&trimmed[6..])?;
2733        ParsedSelector {
2734            raw: selector,
2735            value,
2736            kind: SelectorKind::XPath,
2737        }
2738    } else if lowercase.starts_with("css=") {
2739        let value = parse_selector_value(&trimmed[4..])?;
2740        ParsedSelector {
2741            raw: selector,
2742            value,
2743            kind: SelectorKind::Css,
2744        }
2745    } else if lowercase.starts_with("css:") {
2746        let value = parse_selector_value(&trimmed[4..])?;
2747        ParsedSelector {
2748            raw: selector,
2749            value,
2750            kind: SelectorKind::Css,
2751        }
2752    } else if looks_like_xpath(trimmed) {
2753        ParsedSelector {
2754            raw: selector,
2755            value: Cow::Borrowed(trimmed),
2756            kind: SelectorKind::XPath,
2757        }
2758    } else {
2759        ParsedSelector {
2760            raw: selector,
2761            value: Cow::Borrowed(trimmed),
2762            kind: SelectorKind::Css,
2763        }
2764    };
2765
2766    if parsed.value.is_empty() {
2767        return Err(format!(
2768            "{command_name} command requires a non-empty selector"
2769        ));
2770    }
2771
2772    Ok(parsed)
2773}
2774
2775fn parse_selector_value(raw: &str) -> Result<Cow<'_, str>, String> {
2776    let trimmed = raw.trim();
2777    if trimmed.is_empty() {
2778        return Err("selector value must not be empty".to_string());
2779    }
2780    if trimmed.starts_with('"') {
2781        let end = find_json_string_end(trimmed)
2782            .ok_or_else(|| "selector value contains an unterminated JSON string".to_string())?;
2783        if trimmed[end..].trim().is_empty() {
2784            let decoded = serde_json::from_str::<String>(&trimmed[..end])
2785                .map_err(|error| format!("failed to decode selector JSON string: {error}"))?;
2786            return Ok(Cow::Owned(decoded));
2787        }
2788    }
2789    Ok(Cow::Borrowed(trimmed))
2790}
2791
2792fn find_json_string_end(value: &str) -> Option<usize> {
2793    let bytes = value.as_bytes();
2794    if bytes.first().copied()? != b'"' {
2795        return None;
2796    }
2797
2798    let mut index = 1usize;
2799    let mut escaped = false;
2800    while index < bytes.len() {
2801        let byte = bytes[index];
2802        if escaped {
2803            escaped = false;
2804            index += 1;
2805            continue;
2806        }
2807        match byte {
2808            b'\\' => escaped = true,
2809            b'"' => return Some(index + 1),
2810            _ => {}
2811        }
2812        index += 1;
2813    }
2814    None
2815}
2816
2817fn looks_like_xpath(selector: &str) -> bool {
2818    selector.starts_with("//")
2819        || selector.starts_with(".//")
2820        || selector.starts_with("../")
2821        || selector.starts_with('/')
2822        || selector.starts_with('(')
2823}
2824
2825fn parse_explicit_selector_prefix(selector: &str) -> Option<ExplicitSelectorPrefix> {
2826    let lower = selector.to_ascii_lowercase();
2827    if lower.starts_with("xpath=") || lower.starts_with("xpath:") {
2828        return Some(ExplicitSelectorPrefix {
2829            kind: SelectorKind::XPath,
2830            prefix_len: 6,
2831        });
2832    }
2833    if lower.starts_with("css=") || lower.starts_with("css:") {
2834        return Some(ExplicitSelectorPrefix {
2835            kind: SelectorKind::Css,
2836            prefix_len: 4,
2837        });
2838    }
2839    None
2840}
2841
2842fn parse_selector_chain<'a>(
2843    command_name: &str,
2844    selector: &'a str,
2845) -> Result<Vec<ParsedSelector<'a>>, String> {
2846    let trimmed = selector.trim();
2847    if trimmed.is_empty() {
2848        return Err(format!(
2849            "{command_name} command requires a non-empty selector"
2850        ));
2851    }
2852
2853    let Some(first_prefix) = parse_explicit_selector_prefix(trimmed) else {
2854        return Ok(vec![parse_selector(command_name, selector)?]);
2855    };
2856
2857    let mut segments = Vec::new();
2858    let mut current_start = 0usize;
2859    let mut current_prefix = first_prefix;
2860    let bytes = trimmed.as_bytes();
2861    let mut scan = current_prefix.prefix_len;
2862    let mut in_json_string = false;
2863    let mut escaped = false;
2864
2865    while scan < bytes.len() {
2866        let byte = bytes[scan];
2867        if in_json_string {
2868            if escaped {
2869                escaped = false;
2870            } else if byte == b'\\' {
2871                escaped = true;
2872            } else if byte == b'"' {
2873                in_json_string = false;
2874            }
2875            scan += 1;
2876            continue;
2877        }
2878
2879        if byte == b'"' {
2880            in_json_string = true;
2881            scan += 1;
2882            continue;
2883        }
2884
2885        if byte.is_ascii_whitespace() {
2886            let next = scan + 1;
2887            if next < bytes.len()
2888                && let Some(prefix) = parse_explicit_selector_prefix(&trimmed[next..])
2889            {
2890                let value = parse_selector_value(
2891                    &trimmed[current_start + current_prefix.prefix_len..scan],
2892                )?;
2893                if value.is_empty() {
2894                    return Err(format!(
2895                        "{command_name} command requires a non-empty selector"
2896                    ));
2897                }
2898                segments.push(ParsedSelector {
2899                    raw: selector,
2900                    value,
2901                    kind: current_prefix.kind,
2902                });
2903                current_start = next;
2904                current_prefix = prefix;
2905                scan = current_start + current_prefix.prefix_len;
2906                continue;
2907            }
2908        }
2909        scan += 1;
2910    }
2911
2912    let value = parse_selector_value(&trimmed[current_start + current_prefix.prefix_len..])?;
2913    if value.is_empty() {
2914        return Err(format!(
2915            "{command_name} command requires a non-empty selector"
2916        ));
2917    }
2918    segments.push(ParsedSelector {
2919        raw: selector,
2920        value,
2921        kind: current_prefix.kind,
2922    });
2923    Ok(segments)
2924}
2925
2926fn selector_kind_label(kind: SelectorKind) -> &'static str {
2927    match kind {
2928        SelectorKind::Css => "css selector",
2929        SelectorKind::XPath => "xpath selector",
2930    }
2931}
2932
2933fn decode_base64_field(value: &Value, pointer: &str, label: &str) -> Result<Vec<u8>, String> {
2934    let encoded = value
2935        .pointer(pointer)
2936        .and_then(Value::as_str)
2937        .ok_or_else(|| format!("{label} is missing from protocol response: {value}"))?;
2938    base64::engine::general_purpose::STANDARD
2939        .decode(encoded)
2940        .map_err(|error| format!("failed to decode {label}: {error}"))
2941}
2942
2943fn selector_query_all_js(kind: SelectorKind) -> &'static str {
2944    match kind {
2945        SelectorKind::Css => "Array.from(document.querySelectorAll(selector))",
2946        SelectorKind::XPath => {
2947            "(() => {
2948                const result = document.evaluate(
2949                    selector,
2950                    document,
2951                    null,
2952                    XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
2953                    null
2954                );
2955                const elements = [];
2956                for (let index = 0; index < result.snapshotLength; index += 1) {
2957                    const candidate = result.snapshotItem(index);
2958                    if (candidate instanceof Element) {
2959                        elements.push(candidate);
2960                    }
2961                }
2962                return elements;
2963            })()"
2964        }
2965    }
2966}
2967
2968fn selector_query_first_js(kind: SelectorKind) -> &'static str {
2969    match kind {
2970        SelectorKind::Css => "document.querySelector(selector)",
2971        SelectorKind::XPath => {
2972            "(() => {
2973                const result = document.evaluate(
2974                    selector,
2975                    document,
2976                    null,
2977                    XPathResult.FIRST_ORDERED_NODE_TYPE,
2978                    null
2979                );
2980                const candidate = result.singleNodeValue;
2981                return candidate instanceof Element ? candidate : null;
2982            })()"
2983        }
2984    }
2985}
2986
2987fn selector_segments_literal(command_name: &str, selector: &str) -> Result<String, String> {
2988    let segments = parse_selector_chain(command_name, selector)?;
2989    let serialized = segments
2990        .into_iter()
2991        .enumerate()
2992        .map(|(index, segment)| {
2993            let value = if index > 0 && segment.kind == SelectorKind::XPath {
2994                if segment.value.starts_with("//") {
2995                    format!(".{}", segment.value)
2996                } else if segment.value.starts_with('/') {
2997                    format!(".{}", segment.value)
2998                } else if segment.value.starts_with('.') {
2999                    segment.value.to_string()
3000                } else {
3001                    format!(".//{}", segment.value)
3002                }
3003            } else {
3004                segment.value.to_string()
3005            };
3006            json!({
3007                "kind": match segment.kind {
3008                    SelectorKind::Css => "css",
3009                    SelectorKind::XPath => "xpath",
3010                },
3011                "value": value,
3012            })
3013        })
3014        .collect::<Vec<_>>();
3015    serde_json::to_string(&serialized)
3016        .map_err(|error| format!("failed to serialize selector chain: {error}"))
3017}
3018
3019fn selector_chain_kind_label(command_name: &str, selector: &str) -> Result<String, String> {
3020    let segments = parse_selector_chain(command_name, selector)?;
3021    if segments.len() == 1 {
3022        Ok(selector_kind_label(segments[0].kind).to_string())
3023    } else {
3024        Ok("selector chain".to_string())
3025    }
3026}
3027
3028fn selector_chain_query_all_js() -> &'static str {
3029    "(() => {
3030        let roots = [document];
3031        for (const segment of selectorSegments) {
3032            const nextRoots = [];
3033            for (const root of roots) {
3034                if (segment.kind === 'css') {
3035                    const matches = root.querySelectorAll(segment.value);
3036                    for (const match of matches) {
3037                        if (match instanceof Element) {
3038                            nextRoots.push(match);
3039                        }
3040                    }
3041                    continue;
3042                }
3043                const result = document.evaluate(
3044                    segment.value,
3045                    root,
3046                    null,
3047                    XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
3048                    null
3049                );
3050                for (let index = 0; index < result.snapshotLength; index += 1) {
3051                    const candidate = result.snapshotItem(index);
3052                    if (candidate instanceof Element) {
3053                        nextRoots.push(candidate);
3054                    }
3055                }
3056            }
3057            roots = nextRoots;
3058        }
3059        return roots;
3060    })()"
3061}
3062
3063fn selector_chain_query_first_js() -> &'static str {
3064    "(() => {
3065        const elements = selectorChainQueryAll();
3066        return elements[0] ?? null;
3067    })()"
3068}
3069
3070fn firefox_session_registry() -> &'static Mutex<HashMap<String, FirefoxSessionState>> {
3071    FIREFOX_SESSION_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
3072}
3073
3074async fn firefox_session_guard(
3075    connection_id: &str,
3076) -> Result<MutexGuard<'static, HashMap<String, FirefoxSessionState>>, String> {
3077    let registry = firefox_session_registry();
3078    let sessions = registry.lock().await;
3079    if sessions.contains_key(connection_id) {
3080        Ok(sessions)
3081    } else {
3082        Err(format!(
3083            "Firefox automation session {connection_id} is not available"
3084        ))
3085    }
3086}
3087
3088async fn firefox_evaluate_json(
3089    bidi: &mut BidiConnection,
3090    browsing_context_id: &str,
3091    expression: &str,
3092) -> Result<Value, String> {
3093    let result = bidi
3094        .send_command(
3095            "script.evaluate",
3096            json!({
3097                "expression": expression,
3098                "target": {
3099                    "context": browsing_context_id,
3100                },
3101                "awaitPromise": true,
3102            }),
3103        )
3104        .await?;
3105    let result_type = json_string(&result, "/result/type")?;
3106    if result_type != "success" {
3107        let details = result
3108            .pointer("/result/exceptionDetails")
3109            .cloned()
3110            .unwrap_or(Value::Null);
3111        return Err(format!("Firefox script.evaluate failed: {details}"));
3112    }
3113    let value = json_string(&result, "/result/result/value")?;
3114    serde_json::from_str::<Value>(&value)
3115        .map_err(|error| format!("failed to parse Firefox script result JSON: {error}"))
3116}
3117
3118async fn firefox_evaluate_void(
3119    bidi: &mut BidiConnection,
3120    browsing_context_id: &str,
3121    expression: &str,
3122) -> Result<(), String> {
3123    firefox_evaluate_json(
3124        bidi,
3125        browsing_context_id,
3126        &format!("JSON.stringify((() => {{ {expression}; return null; }})())"),
3127    )
3128    .await
3129    .map(|_| ())
3130}
3131
3132async fn firefox_evaluate_u32(
3133    bidi: &mut BidiConnection,
3134    browsing_context_id: &str,
3135    expression: &str,
3136) -> Result<u32, String> {
3137    let value = firefox_evaluate_json(
3138        bidi,
3139        browsing_context_id,
3140        &format!("JSON.stringify({expression})"),
3141    )
3142    .await?;
3143    if let Some(number) = value.as_u64() {
3144        return u32::try_from(number)
3145            .map_err(|_| format!("Firefox script result exceeds u32: {number}"));
3146    }
3147    if let Some(number) = value.as_i64() {
3148        return u32::try_from(number)
3149            .map_err(|_| format!("Firefox script result is out of range for u32: {number}"));
3150    }
3151    Err(format!(
3152        "expected numeric Firefox script result, found {value}"
3153    ))
3154}
3155
3156async fn firefox_evaluate_bool(
3157    bidi: &mut BidiConnection,
3158    browsing_context_id: &str,
3159    expression: &str,
3160) -> Result<bool, String> {
3161    let value = firefox_evaluate_json(
3162        bidi,
3163        browsing_context_id,
3164        &format!("JSON.stringify({expression})"),
3165    )
3166    .await?;
3167    value
3168        .as_bool()
3169        .ok_or_else(|| format!("expected boolean Firefox script result, found {value}"))
3170}
3171
3172async fn firefox_get_text(
3173    connection_id: &str,
3174    browsing_context_id: &str,
3175    css_selector: &str,
3176    property: &str,
3177) -> Result<TextInfo, String> {
3178    let parsed = parse_selector("get_text", css_selector)?;
3179    let mut sessions = firefox_session_guard(connection_id).await?;
3180    let bidi = &mut sessions
3181        .get_mut(connection_id)
3182        .expect("Firefox session guard validated connection id")
3183        .connection;
3184    let query_first_js = selector_query_first_js(parsed.kind);
3185    let value = firefox_evaluate_json(
3186        bidi,
3187        browsing_context_id,
3188        &format!(
3189            "JSON.stringify((() => {{
3190                const selector = {};
3191                const property = {};
3192                const element = {query_first_js};
3193                if (!element) {{
3194                    throw new Error(`No element matches {}: ${{selector}}`);
3195                }}
3196                const value = element[property];
3197                return typeof value === 'string' ? value : '';
3198            }})())",
3199            json_string_literal(&parsed.value, "text selector")?,
3200            json_string_literal(property, "text property")?,
3201            selector_kind_label(parsed.kind),
3202        ),
3203    )
3204    .await?;
3205
3206    Ok(TextInfo {
3207        css_selector: css_selector.to_string(),
3208        text: value.as_str().map(str::to_string).unwrap_or_default(),
3209        note: format!(
3210            "resolved {property} for {} {}",
3211            selector_kind_label(parsed.kind),
3212            parsed.value
3213        ),
3214    })
3215}
3216
3217pub async fn boot() -> String {
3218    sleep(Duration::from_millis(15)).await;
3219    "web ready".to_string()
3220}
3221
3222#[cfg(unix)]
3223fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
3224    let status = Command::new("kill")
3225        .args(["-TERM", &process_id.to_string()])
3226        .status()
3227        .map_err(|error| format!("failed to send TERM to process {process_id}: {error}"))?;
3228
3229    if status.success() {
3230        Ok(())
3231    } else {
3232        Err(format!(
3233            "kill -TERM returned non-zero exit status for process {process_id}: {status}"
3234        ))
3235    }
3236}
3237
3238#[cfg(windows)]
3239fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
3240    let status = Command::new("taskkill")
3241        .args(["/PID", &process_id.to_string(), "/T", "/F"])
3242        .status()
3243        .map_err(|error| format!("failed to terminate process {process_id}: {error}"))?;
3244
3245    if status.success() {
3246        Ok(())
3247    } else {
3248        Err(format!(
3249            "taskkill returned non-zero exit status for process {process_id}: {status}"
3250        ))
3251    }
3252}
3253
3254#[cfg(not(any(unix, windows)))]
3255fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
3256    Err(format!(
3257        "browser process termination is not implemented for process {process_id} on this platform"
3258    ))
3259}
3260
3261type CdpSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
3262type BidiSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
3263
3264struct MapperConnectionInfo {
3265    package_version: String,
3266    mapper_target_id: String,
3267    mapper_session_id: String,
3268}
3269
3270struct CdpConnection {
3271    socket: CdpSocket,
3272    next_id: u64,
3273}
3274
3275impl CdpConnection {
3276    async fn connect(cdp_websocket_url: &str) -> Result<Self, String> {
3277        let (socket, _) = connect_async(cdp_websocket_url).await.map_err(|error| {
3278            format!("failed to connect to CDP websocket {cdp_websocket_url}: {error}")
3279        })?;
3280        Ok(Self { socket, next_id: 1 })
3281    }
3282
3283    async fn send_command(
3284        &mut self,
3285        method: &str,
3286        params: Value,
3287        session_id: Option<&str>,
3288    ) -> Result<Value, String> {
3289        let id = self.next_id;
3290        self.next_id += 1;
3291
3292        let mut payload = json!({
3293            "id": id,
3294            "method": method,
3295            "params": params,
3296        });
3297        if let Some(session_id) = session_id {
3298            payload["sessionId"] = Value::String(session_id.to_string());
3299        }
3300
3301        self.socket
3302            .send(Message::Text(payload.to_string().into()))
3303            .await
3304            .map_err(|error| format!("failed to send CDP command {method}: {error}"))?;
3305
3306        loop {
3307            let message = self.next_json_message().await?;
3308            if message.get("id").and_then(Value::as_u64) != Some(id) {
3309                continue;
3310            }
3311
3312            if let Some(error) = message.get("error") {
3313                return Err(format!("CDP command {method} failed: {error}"));
3314            }
3315
3316            return Ok(message.get("result").cloned().unwrap_or(Value::Null));
3317        }
3318    }
3319
3320    async fn evaluate_expression(
3321        &mut self,
3322        session_id: &str,
3323        expression: &str,
3324        await_promise: bool,
3325    ) -> Result<Value, String> {
3326        let result = self
3327            .send_command(
3328                "Runtime.evaluate",
3329                json!({
3330                    "expression": expression,
3331                    "awaitPromise": await_promise,
3332                    "returnByValue": true,
3333                }),
3334                Some(session_id),
3335            )
3336            .await?;
3337        if let Some(exception_details) = result.get("exceptionDetails") {
3338            return Err(format_runtime_exception_message(exception_details));
3339        }
3340        Ok(result)
3341    }
3342
3343    async fn attach_to_target(&mut self, target_id: &str) -> Result<String, String> {
3344        let attached = self
3345            .send_command(
3346                "Target.attachToTarget",
3347                json!({
3348                    "targetId": target_id,
3349                    "flatten": true,
3350                }),
3351                None,
3352            )
3353            .await?;
3354        required_string(&attached, "/sessionId")
3355    }
3356
3357    async fn prepare_page_target_session(&mut self, target_id: &str) -> Result<String, String> {
3358        let session_id = self.attach_to_target(target_id).await?;
3359        self.send_command("Runtime.enable", json!({}), Some(&session_id))
3360            .await?;
3361        self.send_command("Page.enable", json!({}), Some(&session_id))
3362            .await?;
3363        Ok(session_id)
3364    }
3365
3366    async fn detach_from_target(&mut self, session_id: &str) -> Result<(), String> {
3367        self.send_command(
3368            "Target.detachFromTarget",
3369            json!({
3370                "sessionId": session_id,
3371            }),
3372            None,
3373        )
3374        .await?;
3375        Ok(())
3376    }
3377
3378    async fn dispatch_mouse_click(
3379        &mut self,
3380        session_id: &str,
3381        x: f64,
3382        y: f64,
3383    ) -> Result<(), String> {
3384        self.dispatch_mouse_move(session_id, x, y).await?;
3385        self.send_command(
3386            "Input.dispatchMouseEvent",
3387            json!({
3388                "type": "mousePressed",
3389                "x": x,
3390                "y": y,
3391                "button": "left",
3392                "buttons": 1,
3393                "clickCount": 1,
3394            }),
3395            Some(session_id),
3396        )
3397        .await?;
3398        self.send_command(
3399            "Input.dispatchMouseEvent",
3400            json!({
3401                "type": "mouseReleased",
3402                "x": x,
3403                "y": y,
3404                "button": "left",
3405                "buttons": 0,
3406                "clickCount": 1,
3407            }),
3408            Some(session_id),
3409        )
3410        .await?;
3411        Ok(())
3412    }
3413
3414    async fn dispatch_mouse_move(
3415        &mut self,
3416        session_id: &str,
3417        x: f64,
3418        y: f64,
3419    ) -> Result<(), String> {
3420        self.send_command(
3421            "Input.dispatchMouseEvent",
3422            json!({
3423                "type": "mouseMoved",
3424                "x": x,
3425                "y": y,
3426                "button": "none",
3427                "buttons": 0,
3428            }),
3429            Some(session_id),
3430        )
3431        .await?;
3432        Ok(())
3433    }
3434
3435    async fn dispatch_key_press(
3436        &mut self,
3437        session_id: &str,
3438        key: &str,
3439        text: Option<&str>,
3440    ) -> Result<(), String> {
3441        self.send_command(
3442            "Input.dispatchKeyEvent",
3443            json!({
3444                "type": "keyDown",
3445                "key": key,
3446                "text": text.unwrap_or(""),
3447            }),
3448            Some(session_id),
3449        )
3450        .await?;
3451        self.send_command(
3452            "Input.dispatchKeyEvent",
3453            json!({
3454                "type": "keyUp",
3455                "key": key,
3456                "text": text.unwrap_or(""),
3457            }),
3458            Some(session_id),
3459        )
3460        .await?;
3461        Ok(())
3462    }
3463
3464    async fn ensure_chromium_bidi_mapper(
3465        &mut self,
3466        existing_mapper_target_id: Option<&str>,
3467    ) -> Result<MapperConnectionInfo, String> {
3468        let (mut mapper_target_id, mut created_target) = match existing_mapper_target_id {
3469            Some(existing_mapper_target_id) if !existing_mapper_target_id.trim().is_empty() => {
3470                (existing_mapper_target_id.to_string(), false)
3471            }
3472            _ => (self.create_bidi_mapper_target().await?, true),
3473        };
3474
3475        let attached = match self
3476            .send_command(
3477                "Target.attachToTarget",
3478                json!({
3479                    "targetId": mapper_target_id,
3480                    "flatten": true,
3481                }),
3482                None,
3483            )
3484            .await
3485        {
3486            Ok(attached) => attached,
3487            Err(error)
3488                if !created_target
3489                    && error.contains("Target.attachToTarget failed")
3490                    && error.contains("No target with given id found") =>
3491            {
3492                mapper_target_id = self.create_bidi_mapper_target().await?;
3493                created_target = true;
3494                self.send_command(
3495                    "Target.attachToTarget",
3496                    json!({
3497                        "targetId": mapper_target_id,
3498                        "flatten": true,
3499                    }),
3500                    None,
3501                )
3502                .await?
3503            }
3504            Err(error) => return Err(error),
3505        };
3506        let mapper_session_id = required_string(&attached, "/sessionId")?;
3507
3508        self.send_command("Runtime.enable", json!({}), Some(&mapper_session_id))
3509            .await?;
3510        self.send_command(
3511            "Target.exposeDevToolsProtocol",
3512            json!({
3513                "bindingName": "cdp",
3514                "targetId": mapper_target_id,
3515                "inheritPermissions": true,
3516            }),
3517            None,
3518        )
3519        .await?;
3520        self.ensure_runtime_binding(&mapper_session_id, "sendBidiResponse")
3521            .await?;
3522
3523        if created_target
3524            || !self
3525                .mapper_runtime_is_initialized(&mapper_session_id)
3526                .await?
3527        {
3528            self.evaluate_expression(&mapper_session_id, CHROMIUM_BIDI_MAPPER_BUNDLE, false)
3529                .await?;
3530            self.evaluate_expression(
3531                &mapper_session_id,
3532                &format!(
3533                    "window.runMapperInstance('{}')",
3534                    js_single_quote(&mapper_target_id)
3535                ),
3536                true,
3537            )
3538            .await?;
3539            self.send_bidi_command(
3540                &mapper_session_id,
3541                &json!({
3542                    "id": 1,
3543                    "method": "session.new",
3544                    "params": {
3545                        "capabilities": {}
3546                    }
3547                }),
3548            )
3549            .await?;
3550        }
3551
3552        Ok(MapperConnectionInfo {
3553            package_version: CHROMIUM_BIDI_NPM_VERSION.to_string(),
3554            mapper_target_id,
3555            mapper_session_id,
3556        })
3557    }
3558
3559    async fn create_bidi_mapper_target(&mut self) -> Result<String, String> {
3560        let created = self
3561            .send_command(
3562                "Target.createTarget",
3563                json!({
3564                    "url": "about:blank#MAPPER_TARGET",
3565                    "hidden": true,
3566                    "background": true,
3567                }),
3568                None,
3569            )
3570            .await?;
3571        required_string(&created, "/targetId")
3572    }
3573
3574    async fn ensure_runtime_binding(&mut self, session_id: &str, name: &str) -> Result<(), String> {
3575        match self
3576            .send_command(
3577                "Runtime.addBinding",
3578                json!({
3579                    "name": name,
3580                }),
3581                Some(session_id),
3582            )
3583            .await
3584        {
3585            Ok(_) => Ok(()),
3586            Err(error)
3587                if error.contains("already exists") || error.contains("Cannot add binding") =>
3588            {
3589                Ok(())
3590            }
3591            Err(error) => Err(error),
3592        }
3593    }
3594
3595    async fn mapper_runtime_is_initialized(&mut self, session_id: &str) -> Result<bool, String> {
3596        let result = self
3597            .evaluate_expression(
3598                session_id,
3599                "typeof window.runMapperInstance === 'function' && typeof window.onBidiMessage === 'function'",
3600                true,
3601            )
3602            .await?;
3603        Ok(result
3604            .pointer("/result/value")
3605            .and_then(Value::as_bool)
3606            .unwrap_or(false))
3607    }
3608
3609    async fn send_bidi_command(
3610        &mut self,
3611        mapper_session_id: &str,
3612        command: &Value,
3613    ) -> Result<Value, String> {
3614        let command_id = command
3615            .get("id")
3616            .and_then(Value::as_u64)
3617            .ok_or_else(|| "BiDi command is missing numeric id".to_string())?;
3618        let eval_id = self.next_id;
3619        self.next_id += 1;
3620        let command_json = command.to_string();
3621        let expression = format!(
3622            "onBidiMessage({})",
3623            serde_json::to_string(&command_json)
3624                .map_err(|error| format!("failed to encode BiDi command payload: {error}"))?
3625        );
3626
3627        let payload = json!({
3628            "id": eval_id,
3629            "method": "Runtime.evaluate",
3630            "params": {
3631                "expression": expression,
3632                "awaitPromise": true,
3633            },
3634            "sessionId": mapper_session_id,
3635        });
3636        self.socket
3637            .send(Message::Text(payload.to_string().into()))
3638            .await
3639            .map_err(|error| format!("failed to send mapper Runtime.evaluate call: {error}"))?;
3640
3641        loop {
3642            let message = self.next_json_message().await?;
3643
3644            if message.get("id").and_then(Value::as_u64) == Some(eval_id) {
3645                if let Some(error) = message.get("error") {
3646                    return Err(format!("mapper Runtime.evaluate failed: {error}"));
3647                }
3648                if let Some(exception_details) = message.pointer("/result/exceptionDetails") {
3649                    return Err(format_mapper_runtime_exception_message(exception_details));
3650                }
3651                continue;
3652            }
3653
3654            if message.get("sessionId").and_then(Value::as_str) != Some(mapper_session_id) {
3655                continue;
3656            }
3657
3658            if message.get("method").and_then(Value::as_str) != Some("Runtime.bindingCalled") {
3659                continue;
3660            }
3661
3662            let params = message
3663                .get("params")
3664                .ok_or_else(|| "Runtime.bindingCalled message is missing params".to_string())?;
3665            if params.get("name").and_then(Value::as_str) != Some("sendBidiResponse") {
3666                continue;
3667            }
3668
3669            let payload = params
3670                .get("payload")
3671                .and_then(Value::as_str)
3672                .ok_or_else(|| "Runtime.bindingCalled payload is missing".to_string())?;
3673            let response = serde_json::from_str::<Value>(payload)
3674                .map_err(|error| format!("failed to parse BiDi mapper response JSON: {error}"))?;
3675            if response.get("id").and_then(Value::as_u64) != Some(command_id) {
3676                continue;
3677            }
3678
3679            match response.get("type").and_then(Value::as_str) {
3680                Some("success") => return Ok(response),
3681                Some("error") => {
3682                    let message = response
3683                        .get("message")
3684                        .and_then(Value::as_str)
3685                        .unwrap_or("unknown BiDi mapper error");
3686                    let error = response
3687                        .get("error")
3688                        .and_then(Value::as_str)
3689                        .unwrap_or("unknown error");
3690                    return Err(format!("BiDi mapper returned {error}: {message}"));
3691                }
3692                Some(other) => {
3693                    return Err(format!("unexpected BiDi response type {other}: {response}"));
3694                }
3695                None => {
3696                    return Err(format!("BiDi mapper response is missing type: {response}"));
3697                }
3698            }
3699        }
3700    }
3701
3702    async fn resolve_bidi_context_id(
3703        &mut self,
3704        mapper_session_id: &str,
3705        existing_context_id: Option<&str>,
3706        current_url: Option<&str>,
3707    ) -> Result<String, String> {
3708        if let Some(existing_context_id) = existing_context_id {
3709            if !existing_context_id.trim().is_empty() {
3710                let probe = json!({
3711                    "id": 1,
3712                    "method": "browsingContext.getTree",
3713                    "params": {
3714                        "root": existing_context_id,
3715                        "maxDepth": 0,
3716                    }
3717                });
3718                match self.send_bidi_command(mapper_session_id, &probe).await {
3719                    Ok(_) => return Ok(existing_context_id.to_string()),
3720                    Err(error)
3721                        if error.contains("no such frame")
3722                            || error.contains("Context ")
3723                            || error.contains("invalid argument") => {}
3724                    Err(error) => return Err(error),
3725                }
3726            }
3727        }
3728
3729        let target_url = current_url
3730            .map(str::trim)
3731            .filter(|value| !value.is_empty())
3732            .map(str::to_string);
3733
3734        for attempt in 0..20 {
3735            let tree = self
3736                .send_bidi_command(
3737                    mapper_session_id,
3738                    &json!({
3739                        "id": 2,
3740                        "method": "browsingContext.getTree",
3741                        "params": {
3742                            "maxDepth": 0,
3743                        }
3744                    }),
3745                )
3746                .await?;
3747            let contexts = tree
3748                .pointer("/result/contexts")
3749                .and_then(Value::as_array)
3750                .ok_or_else(|| {
3751                    format!("BiDi browsingContext.getTree returned no contexts: {tree}")
3752                })?;
3753
3754            if let Some(target_url) = target_url.as_deref() {
3755                if let Some(context_id) = contexts.iter().find_map(|context| {
3756                    let url = context.get("url").and_then(Value::as_str)?;
3757                    if url == target_url {
3758                        context
3759                            .get("context")
3760                            .and_then(Value::as_str)
3761                            .map(str::to_string)
3762                    } else {
3763                        None
3764                    }
3765                }) {
3766                    return Ok(context_id);
3767                }
3768            }
3769
3770            if let Some(context_id) = contexts
3771                .first()
3772                .and_then(|context| context.get("context"))
3773                .and_then(Value::as_str)
3774                .filter(|value| !value.trim().is_empty())
3775                .map(str::to_string)
3776            {
3777                return Ok(context_id);
3778            }
3779
3780            if attempt < 19 {
3781                sleep(Duration::from_millis(150)).await;
3782            } else {
3783                return Err(format!(
3784                    "BiDi browsingContext.getTree returned no usable context ids after retries: {tree}"
3785                ));
3786            }
3787        }
3788
3789        Err("BiDi browsing context resolution exhausted retries unexpectedly".to_string())
3790    }
3791
3792    async fn navigate_and_wait_for_load(
3793        &mut self,
3794        session_id: &str,
3795        url: &str,
3796    ) -> Result<(), String> {
3797        let id = self.next_id;
3798        self.next_id += 1;
3799
3800        let payload = json!({
3801            "id": id,
3802            "method": "Page.navigate",
3803            "params": {
3804                "url": url,
3805            },
3806            "sessionId": session_id,
3807        });
3808        self.socket
3809            .send(Message::Text(payload.to_string().into()))
3810            .await
3811            .map_err(|error| format!("failed to send CDP command Page.navigate: {error}"))?;
3812
3813        let mut navigate_confirmed = false;
3814        let mut load_seen = false;
3815
3816        loop {
3817            let message = self.next_json_message().await?;
3818
3819            if message.get("id").and_then(Value::as_u64) == Some(id) {
3820                if let Some(error) = message.get("error") {
3821                    return Err(format!("CDP command Page.navigate failed: {error}"));
3822                }
3823                navigate_confirmed = true;
3824            } else if message.get("sessionId").and_then(Value::as_str) == Some(session_id)
3825                && message.get("method").and_then(Value::as_str) == Some("Page.loadEventFired")
3826            {
3827                load_seen = true;
3828            }
3829
3830            if navigate_confirmed && load_seen {
3831                return Ok(());
3832            }
3833        }
3834    }
3835
3836    async fn next_json_message(&mut self) -> Result<Value, String> {
3837        loop {
3838            let message = self
3839                .socket
3840                .next()
3841                .await
3842                .ok_or_else(|| "CDP websocket closed unexpectedly".to_string())?
3843                .map_err(|error| format!("failed to read CDP websocket message: {error}"))?;
3844
3845            match message {
3846                Message::Text(text) => {
3847                    let value = serde_json::from_str::<Value>(&text)
3848                        .map_err(|error| format!("failed to parse CDP JSON message: {error}"))?;
3849                    return Ok(value);
3850                }
3851                Message::Binary(bytes) => {
3852                    let value = serde_json::from_slice::<Value>(&bytes)
3853                        .map_err(|error| format!("failed to parse CDP JSON message: {error}"))?;
3854                    return Ok(value);
3855                }
3856                Message::Ping(payload) => {
3857                    self.socket
3858                        .send(Message::Pong(payload))
3859                        .await
3860                        .map_err(|error| format!("failed to reply to CDP ping: {error}"))?;
3861                }
3862                Message::Pong(_) => {}
3863                Message::Frame(_) => {}
3864                Message::Close(frame) => {
3865                    return Err(format!("CDP websocket closed: {frame:?}"));
3866                }
3867            }
3868        }
3869    }
3870}
3871
3872struct BidiConnection {
3873    socket: BidiSocket,
3874    next_id: u64,
3875}
3876
3877impl BidiConnection {
3878    async fn connect(bidi_websocket_url: &str) -> Result<Self, String> {
3879        let (socket, _) = connect_async(bidi_websocket_url).await.map_err(|error| {
3880            format!("failed to connect to WebDriver BiDi websocket {bidi_websocket_url}: {error}")
3881        })?;
3882        Ok(Self { socket, next_id: 1 })
3883    }
3884
3885    async fn send_command(&mut self, method: &str, params: Value) -> Result<Value, String> {
3886        let id = self.next_id;
3887        self.next_id += 1;
3888        let payload = json!({
3889            "id": id,
3890            "method": method,
3891            "params": params,
3892        });
3893        self.socket
3894            .send(Message::Text(payload.to_string().into()))
3895            .await
3896            .map_err(|error| format!("failed to send WebDriver BiDi command {method}: {error}"))?;
3897
3898        loop {
3899            let message = self.next_json_message().await?;
3900            if message.get("id").and_then(Value::as_u64) != Some(id) {
3901                continue;
3902            }
3903
3904            if let Some(error) = message.get("error").and_then(Value::as_str) {
3905                let message_text = message
3906                    .get("message")
3907                    .and_then(Value::as_str)
3908                    .unwrap_or("unknown WebDriver BiDi error");
3909                return Err(format!(
3910                    "WebDriver BiDi command {method} returned {error}: {message_text}"
3911                ));
3912            }
3913
3914            return Ok(message);
3915        }
3916    }
3917
3918    async fn wait_for_top_level_context(&mut self) -> Result<String, String> {
3919        for _ in 0..100 {
3920            let tree = self
3921                .send_command(
3922                    "browsingContext.getTree",
3923                    json!({
3924                        "maxDepth": 0,
3925                    }),
3926                )
3927                .await?;
3928            if let Some(context_id) = tree
3929                .pointer("/result/contexts")
3930                .and_then(Value::as_array)
3931                .and_then(|contexts| contexts.first())
3932                .and_then(|context| context.get("context"))
3933                .and_then(Value::as_str)
3934                .filter(|value| !value.trim().is_empty())
3935            {
3936                return Ok(context_id.to_string());
3937            }
3938            sleep(Duration::from_millis(50)).await;
3939        }
3940
3941        Err("timed out waiting for an initial Firefox browsing context".to_string())
3942    }
3943
3944    async fn next_json_message(&mut self) -> Result<Value, String> {
3945        loop {
3946            let message = self
3947                .socket
3948                .next()
3949                .await
3950                .ok_or_else(|| "WebDriver BiDi websocket closed unexpectedly".to_string())?
3951                .map_err(|error| {
3952                    format!("failed to read WebDriver BiDi websocket message: {error}")
3953                })?;
3954
3955            match message {
3956                Message::Text(text) => {
3957                    let value = serde_json::from_str::<Value>(&text).map_err(|error| {
3958                        format!("failed to parse WebDriver BiDi JSON message: {error}")
3959                    })?;
3960                    return Ok(value);
3961                }
3962                Message::Binary(bytes) => {
3963                    let value = serde_json::from_slice::<Value>(&bytes).map_err(|error| {
3964                        format!("failed to parse WebDriver BiDi JSON message: {error}")
3965                    })?;
3966                    return Ok(value);
3967                }
3968                Message::Ping(payload) => {
3969                    self.socket
3970                        .send(Message::Pong(payload))
3971                        .await
3972                        .map_err(|error| {
3973                            format!("failed to reply to WebDriver BiDi ping: {error}")
3974                        })?;
3975                }
3976                Message::Pong(_) => {}
3977                Message::Frame(_) => {}
3978                Message::Close(frame) => {
3979                    return Err(format!("WebDriver BiDi websocket closed: {frame:?}"));
3980                }
3981            }
3982        }
3983    }
3984}
3985
3986fn required_string(value: &Value, pointer: &str) -> Result<String, String> {
3987    value
3988        .pointer(pointer)
3989        .and_then(Value::as_str)
3990        .map(str::to_string)
3991        .filter(|value| !value.is_empty())
3992        .ok_or_else(|| format!("missing string field at JSON pointer {pointer}"))
3993}
3994
3995fn js_single_quote(value: &str) -> String {
3996    value.replace('\\', "\\\\").replace('\'', "\\'")
3997}
3998
3999fn json_u32(value: &Value, pointer: &str) -> Result<u32, String> {
4000    let raw = value
4001        .pointer(pointer)
4002        .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
4003
4004    if let Some(number) = raw.as_u64() {
4005        return u32::try_from(number)
4006            .map_err(|_| format!("numeric JSON value at {pointer} exceeds u32: {number}"));
4007    }
4008
4009    if let Some(number) = raw.as_i64() {
4010        return u32::try_from(number).map_err(|_| {
4011            format!("numeric JSON value at {pointer} is out of range for u32: {number}")
4012        });
4013    }
4014
4015    Err(format!(
4016        "expected numeric JSON value at {pointer}, found {raw}"
4017    ))
4018}
4019
4020fn json_f64(value: &Value, pointer: &str) -> Result<f64, String> {
4021    let raw = value
4022        .pointer(pointer)
4023        .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
4024
4025    raw.as_f64()
4026        .ok_or_else(|| format!("expected numeric JSON value at {pointer}, found {raw}"))
4027}
4028
4029fn json_string(value: &Value, pointer: &str) -> Result<String, String> {
4030    value
4031        .pointer(pointer)
4032        .and_then(Value::as_str)
4033        .map(str::to_string)
4034        .ok_or_else(|| format!("expected string JSON value at {pointer}"))
4035}
4036
4037fn json_string_literal(value: &str, context: &str) -> Result<String, String> {
4038    serde_json::to_string(value).map_err(|error| format!("failed to serialize {context}: {error}"))
4039}
4040
4041fn format_runtime_exception_message(exception_details: &Value) -> String {
4042    format_exception_message(
4043        "Runtime.evaluate failed",
4044        exception_details,
4045        "Runtime.evaluate failed with exception details",
4046    )
4047}
4048
4049fn format_mapper_runtime_exception_message(exception_details: &Value) -> String {
4050    format_exception_message(
4051        "mapper Runtime.evaluate failed",
4052        exception_details,
4053        "mapper Runtime.evaluate raised exception details",
4054    )
4055}
4056
4057fn format_exception_message(
4058    prefix: &str,
4059    exception_details: &Value,
4060    fallback_prefix: &str,
4061) -> String {
4062    let message = exception_details
4063        .pointer("/exception/preview/properties")
4064        .and_then(Value::as_array)
4065        .and_then(|properties| {
4066            properties.iter().find_map(|property| {
4067                let name = property.get("name").and_then(Value::as_str)?;
4068                let value = property.get("value").and_then(Value::as_str)?;
4069                (name == "message").then_some(value)
4070            })
4071        })
4072        .or_else(|| {
4073            exception_details
4074                .pointer("/exception/message")
4075                .and_then(Value::as_str)
4076        })
4077        .or_else(|| {
4078            exception_details
4079                .pointer("/exception/description")
4080                .and_then(Value::as_str)
4081        })
4082        .unwrap_or_default();
4083
4084    let compact = message.split_whitespace().collect::<Vec<_>>().join(" ");
4085    if let Some(selector) = extract_invalid_selector(&compact) {
4086        return format!("{prefix}: invalid selector {selector}");
4087    }
4088    if !compact.is_empty() {
4089        return format!(
4090            "{prefix}: {}",
4091            compact
4092                .trim_start_matches("SyntaxError: ")
4093                .trim_start_matches("DOMException: ")
4094        );
4095    }
4096    format!("{fallback_prefix}: {exception_details}")
4097}
4098
4099fn extract_invalid_selector(message: &str) -> Option<String> {
4100    let prefix = "Failed to execute 'querySelectorAll' on 'Document': '";
4101    let suffix = "' is not a valid selector.";
4102    let start = message.find(prefix)? + prefix.len();
4103    let end = message[start..].find(suffix)? + start;
4104    Some(message[start..end].to_string())
4105}
4106
4107fn block_on_plugin_future<T, F>(future: F) -> Result<T, String>
4108where
4109    F: Future<Output = Result<T, String>>,
4110{
4111    let runtime = plugin_runtime()?;
4112    runtime.block_on(future)
4113}
4114
4115fn plugin_runtime() -> Result<&'static tokio::runtime::Runtime, String> {
4116    if let Some(runtime) = PLUGIN_RUNTIME.get() {
4117        return Ok(runtime);
4118    }
4119
4120    let runtime = tokio::runtime::Builder::new_multi_thread()
4121        .enable_all()
4122        .worker_threads(2)
4123        .thread_name("allwright-web-plugin")
4124        .build()
4125        .map_err(|error| format!("failed to create plugin runtime: {error}"))?;
4126    let _ = PLUGIN_RUNTIME.set(runtime);
4127    PLUGIN_RUNTIME
4128        .get()
4129        .ok_or_else(|| "failed to initialize plugin runtime".to_string())
4130}
4131
4132fn plugin_response(result: Result<PluginResult, String>) -> *mut c_char {
4133    let envelope = match result {
4134        Ok(result) => PluginEnvelope {
4135            ok: true,
4136            result: Some(result),
4137            error: None,
4138        },
4139        Err(error) => PluginEnvelope {
4140            ok: false,
4141            result: None,
4142            error: Some(error),
4143        },
4144    };
4145
4146    let json = match serde_json::to_string(&envelope) {
4147        Ok(json) => json,
4148        Err(error) => {
4149            let fallback = PluginEnvelope {
4150                ok: false,
4151                result: None,
4152                error: Some(format!("failed to serialize plugin response: {error}")),
4153            };
4154            serde_json::to_string(&fallback).unwrap_or_else(|_| {
4155                "{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response\"}"
4156                    .to_string()
4157            })
4158        }
4159    };
4160
4161    CString::new(json).unwrap().into_raw()
4162}
4163
4164fn handle_plugin_command(command: PluginCommand) -> Result<PluginResult, String> {
4165    match command {
4166        PluginCommand::LaunchBrowser {
4167            browser_kind,
4168            browser_binary,
4169        } => {
4170            launch_browser(browser_kind, browser_binary.as_deref()).map(PluginResult::LaunchBrowser)
4171        }
4172        PluginCommand::OpenPage { browser_session } => block_on_plugin_future(async move {
4173            open_page(&browser_session)
4174                .await
4175                .map(PluginResult::OpenPage)
4176        }),
4177        PluginCommand::ClosePage {
4178            browser_session,
4179            page_session,
4180        } => block_on_plugin_future(async move {
4181            close_page(&browser_session, &page_session).await?;
4182            Ok(PluginResult::ClosePage)
4183        }),
4184        PluginCommand::NavigatePage {
4185            browser_session,
4186            page_session,
4187            url,
4188        } => block_on_plugin_future(async move {
4189            navigate_page(&browser_session, &page_session, &url)
4190                .await
4191                .map(PluginResult::NavigatePage)
4192        }),
4193        PluginCommand::ClickElement {
4194            browser_session,
4195            page_session,
4196            css_selector,
4197        } => block_on_plugin_future(async move {
4198            click_element(&browser_session, &page_session, &css_selector)
4199                .await
4200                .map(PluginResult::ClickElement)
4201        }),
4202        PluginCommand::CountElements {
4203            browser_session,
4204            page_session,
4205            css_selector,
4206        } => block_on_plugin_future(async move {
4207            count_elements(&browser_session, &page_session, &css_selector)
4208                .await
4209                .map(PluginResult::CountElements)
4210        }),
4211        PluginCommand::HighlightElements {
4212            browser_session,
4213            page_session,
4214            css_selector,
4215            duration_ms,
4216        } => block_on_plugin_future(async move {
4217            highlight_elements(&browser_session, &page_session, &css_selector, duration_ms)
4218                .await
4219                .map(PluginResult::HighlightElements)
4220        }),
4221        PluginCommand::FocusElement {
4222            browser_session,
4223            page_session,
4224            css_selector,
4225        } => block_on_plugin_future(async move {
4226            focus_element(&browser_session, &page_session, &css_selector)
4227                .await
4228                .map(PluginResult::FocusElement)
4229        }),
4230        PluginCommand::FillElement {
4231            browser_session,
4232            page_session,
4233            css_selector,
4234            value,
4235        } => block_on_plugin_future(async move {
4236            fill_element(&browser_session, &page_session, &css_selector, &value)
4237                .await
4238                .map(PluginResult::FillElement)
4239        }),
4240        PluginCommand::HoverElement {
4241            browser_session,
4242            page_session,
4243            css_selector,
4244        } => block_on_plugin_future(async move {
4245            hover_element(&browser_session, &page_session, &css_selector)
4246                .await
4247                .map(PluginResult::HoverElement)
4248        }),
4249        PluginCommand::PressKey {
4250            browser_session,
4251            page_session,
4252            css_selector,
4253            key,
4254            text,
4255        } => block_on_plugin_future(async move {
4256            press_key(
4257                &browser_session,
4258                &page_session,
4259                &css_selector,
4260                &key,
4261                text.as_deref(),
4262            )
4263            .await
4264            .map(PluginResult::PressKey)
4265        }),
4266        PluginCommand::GetTextContent {
4267            browser_session,
4268            page_session,
4269            css_selector,
4270        } => block_on_plugin_future(async move {
4271            get_text_content(&browser_session, &page_session, &css_selector)
4272                .await
4273                .map(PluginResult::GetTextContent)
4274        }),
4275        PluginCommand::GetInnerText {
4276            browser_session,
4277            page_session,
4278            css_selector,
4279        } => block_on_plugin_future(async move {
4280            get_inner_text(&browser_session, &page_session, &css_selector)
4281                .await
4282                .map(PluginResult::GetInnerText)
4283        }),
4284        PluginCommand::WaitForSelector {
4285            browser_session,
4286            page_session,
4287            css_selector,
4288            visible,
4289        } => block_on_plugin_future(async move {
4290            wait_for_selector(&browser_session, &page_session, &css_selector, visible)
4291                .await
4292                .map(PluginResult::WaitForSelector)
4293        }),
4294        PluginCommand::Screenshot {
4295            browser_session,
4296            page_session,
4297            full_page,
4298        } => block_on_plugin_future(async move {
4299            screenshot(&browser_session, &page_session, full_page)
4300                .await
4301                .map(PluginResult::Screenshot)
4302        }),
4303        PluginCommand::OpenChromeWindow { chrome_binary } => {
4304            open_chrome_window(chrome_binary.as_deref()).map(PluginResult::OpenChromeWindow)
4305        }
4306        PluginCommand::DiscoverInitialTab { cdp_websocket_url } => {
4307            block_on_plugin_future(async move {
4308                let result = discover_initial_tab(&cdp_websocket_url).await?;
4309                Ok(PluginResult::DiscoverInitialTab(result))
4310            })
4311        }
4312        PluginCommand::OpenChromeTab { cdp_websocket_url } => block_on_plugin_future(async move {
4313            let result = open_chrome_tab(&cdp_websocket_url).await?;
4314            Ok(PluginResult::OpenChromeTab(result))
4315        }),
4316        PluginCommand::CloseBrowserProcess { process_id } => {
4317            close_browser_process(process_id)?;
4318            Ok(PluginResult::CloseBrowserProcess)
4319        }
4320        PluginCommand::CloseChromeTab {
4321            cdp_websocket_url,
4322            target_id,
4323        } => block_on_plugin_future(async move {
4324            close_chrome_tab(&cdp_websocket_url, &target_id).await?;
4325            Ok(PluginResult::CloseChromeTab)
4326        }),
4327        PluginCommand::NavigateChromeTab {
4328            cdp_websocket_url,
4329            target_id,
4330            url,
4331        } => block_on_plugin_future(async move {
4332            let result = navigate_chrome_tab(&cdp_websocket_url, &target_id, &url).await?;
4333            Ok(PluginResult::NavigateChromeTab(result))
4334        }),
4335        PluginCommand::InjectChromiumBidiMapper { cdp_websocket_url } => {
4336            block_on_plugin_future(async move {
4337                let result = inject_chromium_bidi_mapper(&cdp_websocket_url).await?;
4338                Ok(PluginResult::InjectChromiumBidiMapper(result))
4339            })
4340        }
4341        PluginCommand::ResolveBidiContextForTab {
4342            cdp_websocket_url,
4343            mapper_target_id,
4344            browsing_context_id,
4345            url,
4346        } => block_on_plugin_future(async move {
4347            let (resolved_browsing_context_id, mapper) = resolve_bidi_context_for_tab(
4348                &cdp_websocket_url,
4349                mapper_target_id.as_deref(),
4350                browsing_context_id.as_deref(),
4351                url.as_deref(),
4352            )
4353            .await?;
4354            Ok(PluginResult::ResolveBidiContextForTab {
4355                browsing_context_id: resolved_browsing_context_id,
4356                mapper,
4357            })
4358        }),
4359        PluginCommand::ClickElementViaCdp {
4360            cdp_websocket_url,
4361            target_id,
4362            css_selector,
4363        } => block_on_plugin_future(async move {
4364            let result =
4365                click_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
4366            Ok(PluginResult::ClickElementViaCdp(result))
4367        }),
4368        PluginCommand::CountElementsViaCdp {
4369            cdp_websocket_url,
4370            target_id,
4371            css_selector,
4372        } => block_on_plugin_future(async move {
4373            let result =
4374                count_elements_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
4375            Ok(PluginResult::CountElementsViaCdp(result))
4376        }),
4377        PluginCommand::HighlightElementsViaCdp {
4378            cdp_websocket_url,
4379            target_id,
4380            css_selector,
4381            duration_ms,
4382        } => block_on_plugin_future(async move {
4383            let duration_ms = u32::try_from(duration_ms)
4384                .map_err(|_| format!("highlight duration {duration_ms} exceeds u32"))?;
4385            let result = highlight_elements_via_cdp(
4386                &cdp_websocket_url,
4387                &target_id,
4388                &css_selector,
4389                duration_ms,
4390            )
4391            .await?;
4392            Ok(PluginResult::HighlightElementsViaCdp(result))
4393        }),
4394        PluginCommand::FocusElementViaCdp {
4395            cdp_websocket_url,
4396            target_id,
4397            css_selector,
4398        } => block_on_plugin_future(async move {
4399            let result =
4400                focus_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
4401            Ok(PluginResult::FocusElementViaCdp(result))
4402        }),
4403        PluginCommand::FillElementViaCdp {
4404            cdp_websocket_url,
4405            target_id,
4406            css_selector,
4407            value,
4408        } => block_on_plugin_future(async move {
4409            let result =
4410                fill_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector, &value).await?;
4411            Ok(PluginResult::FillElementViaCdp(result))
4412        }),
4413        PluginCommand::HoverElementViaCdp {
4414            cdp_websocket_url,
4415            target_id,
4416            css_selector,
4417        } => block_on_plugin_future(async move {
4418            let result =
4419                hover_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
4420            Ok(PluginResult::HoverElementViaCdp(result))
4421        }),
4422        PluginCommand::PressKeyViaCdp {
4423            cdp_websocket_url,
4424            target_id,
4425            css_selector,
4426            key,
4427            text,
4428        } => block_on_plugin_future(async move {
4429            let result = press_key_via_cdp(
4430                &cdp_websocket_url,
4431                &target_id,
4432                &css_selector,
4433                &key,
4434                text.as_deref(),
4435            )
4436            .await?;
4437            Ok(PluginResult::PressKeyViaCdp(result))
4438        }),
4439        PluginCommand::GetTextContentViaCdp {
4440            cdp_websocket_url,
4441            target_id,
4442            css_selector,
4443        } => block_on_plugin_future(async move {
4444            let result =
4445                get_text_content_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
4446            Ok(PluginResult::GetTextContentViaCdp(result))
4447        }),
4448        PluginCommand::GetInnerTextViaCdp {
4449            cdp_websocket_url,
4450            target_id,
4451            css_selector,
4452        } => block_on_plugin_future(async move {
4453            let result =
4454                get_inner_text_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
4455            Ok(PluginResult::GetInnerTextViaCdp(result))
4456        }),
4457        PluginCommand::WaitForSelectorViaCdp {
4458            cdp_websocket_url,
4459            target_id,
4460            css_selector,
4461            visible,
4462        } => block_on_plugin_future(async move {
4463            let result =
4464                wait_for_selector_via_cdp(&cdp_websocket_url, &target_id, &css_selector, visible)
4465                    .await?;
4466            Ok(PluginResult::WaitForSelectorViaCdp(result))
4467        }),
4468        PluginCommand::ScreenshotViaCdp {
4469            cdp_websocket_url,
4470            target_id,
4471        } => block_on_plugin_future(async move {
4472            let result = screenshot_via_cdp(&cdp_websocket_url, &target_id, false).await?;
4473            Ok(PluginResult::ScreenshotViaCdp(result))
4474        }),
4475    }
4476}
4477
4478#[unsafe(no_mangle)]
4479pub extern "C" fn allwright_plugin_api_version() -> u32 {
4480    ALLWRIGHT_PLUGIN_API_VERSION
4481}
4482
4483#[unsafe(no_mangle)]
4484pub extern "C" fn allwright_plugin_id() -> *const c_char {
4485    c"web".as_ptr()
4486}
4487
4488#[unsafe(no_mangle)]
4489pub unsafe extern "C" fn allwright_plugin_invoke(request_json: *const c_char) -> *mut c_char {
4490    if request_json.is_null() {
4491        return plugin_response(Err("plugin request pointer is null".to_string()));
4492    }
4493
4494    let request = match unsafe { CStr::from_ptr(request_json) }.to_str() {
4495        Ok(request) => request,
4496        Err(error) => {
4497            return plugin_response(Err(format!("plugin request is not valid UTF-8: {error}")));
4498        }
4499    };
4500
4501    let command: PluginCommand = match serde_json::from_str(request) {
4502        Ok(command) => command,
4503        Err(error) => {
4504            return plugin_response(Err(format!("failed to parse plugin request JSON: {error}")));
4505        }
4506    };
4507
4508    plugin_response(handle_plugin_command(command))
4509}
4510
4511#[unsafe(no_mangle)]
4512pub unsafe extern "C" fn allwright_plugin_free_string(value: *mut c_char) {
4513    if !value.is_null() {
4514        unsafe {
4515            let _ = CString::from_raw(value);
4516        }
4517    }
4518}
4519
4520#[cfg(test)]
4521mod tests {
4522    use super::{SelectorKind, parse_selector};
4523
4524    #[test]
4525    fn parse_selector_detects_explicit_xpath_prefix() {
4526        let parsed = parse_selector("click_element", "xpath=//button[normalize-space()='Save']")
4527            .expect("selector should parse");
4528        assert_eq!(parsed.kind, SelectorKind::XPath);
4529        assert_eq!(parsed.value, "//button[normalize-space()='Save']");
4530    }
4531
4532    #[test]
4533    fn parse_selector_detects_xpath_heuristically() {
4534        let parsed = parse_selector("click_element", "//div[@role='dialog']")
4535            .expect("selector should parse");
4536        assert_eq!(parsed.kind, SelectorKind::XPath);
4537    }
4538
4539    #[test]
4540    fn parse_selector_preserves_css_by_default() {
4541        let parsed = parse_selector("click_element", "button[data-testid='save']")
4542            .expect("selector should parse");
4543        assert_eq!(parsed.kind, SelectorKind::Css);
4544        assert_eq!(parsed.value, "button[data-testid='save']");
4545    }
4546
4547    #[test]
4548    fn parse_selector_decodes_json_quoted_xpath_payload() {
4549        let parsed = parse_selector(
4550            "click_element",
4551            r#"xpath="//button[normalize-space()='Save Changes']""#,
4552        )
4553        .expect("selector should parse");
4554        assert_eq!(parsed.kind, SelectorKind::XPath);
4555        assert_eq!(parsed.value, "//button[normalize-space()='Save Changes']");
4556    }
4557
4558    #[test]
4559    fn parse_selector_chain_handles_quoted_hybrid_segments() {
4560        let segments = super::parse_selector_chain(
4561            "click_element",
4562            r#"xpath="//div[@role='dialog']" css="button.primary""#,
4563        )
4564        .expect("selector chain should parse");
4565        assert_eq!(segments.len(), 2);
4566        assert_eq!(segments[0].kind, SelectorKind::XPath);
4567        assert_eq!(segments[0].value, "//div[@role='dialog']");
4568        assert_eq!(segments[1].kind, SelectorKind::Css);
4569        assert_eq!(segments[1].value, "button.primary");
4570    }
4571}