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