Skip to main content

allwright_surface_web/
lib.rs

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