Skip to main content

allwright_surface_web/
lib.rs

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