Skip to main content

allwright_surface_web/
lib.rs

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