Skip to main content

allwright_surface_web/
lib.rs

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