Skip to main content

allwright_surface_web/
lib.rs

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