Skip to main content

allwright_surface_web/
lib.rs

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