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!(
2464                "Runtime.evaluate failed with exception details: {exception_details}"
2465            ));
2466        }
2467        Ok(result)
2468    }
2469
2470    async fn attach_to_target(&mut self, target_id: &str) -> Result<String, String> {
2471        let attached = self
2472            .send_command(
2473                "Target.attachToTarget",
2474                json!({
2475                    "targetId": target_id,
2476                    "flatten": true,
2477                }),
2478                None,
2479            )
2480            .await?;
2481        required_string(&attached, "/sessionId")
2482    }
2483
2484    async fn prepare_page_target_session(&mut self, target_id: &str) -> Result<String, String> {
2485        let session_id = self.attach_to_target(target_id).await?;
2486        self.send_command("Runtime.enable", json!({}), Some(&session_id))
2487            .await?;
2488        self.send_command("Page.enable", json!({}), Some(&session_id))
2489            .await?;
2490        Ok(session_id)
2491    }
2492
2493    async fn detach_from_target(&mut self, session_id: &str) -> Result<(), String> {
2494        self.send_command(
2495            "Target.detachFromTarget",
2496            json!({
2497                "sessionId": session_id,
2498            }),
2499            None,
2500        )
2501        .await?;
2502        Ok(())
2503    }
2504
2505    async fn dispatch_mouse_click(
2506        &mut self,
2507        session_id: &str,
2508        x: f64,
2509        y: f64,
2510    ) -> Result<(), String> {
2511        self.dispatch_mouse_move(session_id, x, y).await?;
2512        self.send_command(
2513            "Input.dispatchMouseEvent",
2514            json!({
2515                "type": "mousePressed",
2516                "x": x,
2517                "y": y,
2518                "button": "left",
2519                "buttons": 1,
2520                "clickCount": 1,
2521            }),
2522            Some(session_id),
2523        )
2524        .await?;
2525        self.send_command(
2526            "Input.dispatchMouseEvent",
2527            json!({
2528                "type": "mouseReleased",
2529                "x": x,
2530                "y": y,
2531                "button": "left",
2532                "buttons": 0,
2533                "clickCount": 1,
2534            }),
2535            Some(session_id),
2536        )
2537        .await?;
2538        Ok(())
2539    }
2540
2541    async fn dispatch_mouse_move(
2542        &mut self,
2543        session_id: &str,
2544        x: f64,
2545        y: f64,
2546    ) -> Result<(), String> {
2547        self.send_command(
2548            "Input.dispatchMouseEvent",
2549            json!({
2550                "type": "mouseMoved",
2551                "x": x,
2552                "y": y,
2553                "button": "none",
2554                "buttons": 0,
2555            }),
2556            Some(session_id),
2557        )
2558        .await?;
2559        Ok(())
2560    }
2561
2562    async fn dispatch_key_press(
2563        &mut self,
2564        session_id: &str,
2565        key: &str,
2566        text: Option<&str>,
2567    ) -> Result<(), String> {
2568        self.send_command(
2569            "Input.dispatchKeyEvent",
2570            json!({
2571                "type": "keyDown",
2572                "key": key,
2573                "text": text.unwrap_or(""),
2574            }),
2575            Some(session_id),
2576        )
2577        .await?;
2578        self.send_command(
2579            "Input.dispatchKeyEvent",
2580            json!({
2581                "type": "keyUp",
2582                "key": key,
2583                "text": text.unwrap_or(""),
2584            }),
2585            Some(session_id),
2586        )
2587        .await?;
2588        Ok(())
2589    }
2590
2591    async fn ensure_chromium_bidi_mapper(
2592        &mut self,
2593        existing_mapper_target_id: Option<&str>,
2594    ) -> Result<MapperConnectionInfo, String> {
2595        let (mut mapper_target_id, mut created_target) = match existing_mapper_target_id {
2596            Some(existing_mapper_target_id) if !existing_mapper_target_id.trim().is_empty() => {
2597                (existing_mapper_target_id.to_string(), false)
2598            }
2599            _ => (self.create_bidi_mapper_target().await?, true),
2600        };
2601
2602        let attached = match self
2603            .send_command(
2604                "Target.attachToTarget",
2605                json!({
2606                    "targetId": mapper_target_id,
2607                    "flatten": true,
2608                }),
2609                None,
2610            )
2611            .await
2612        {
2613            Ok(attached) => attached,
2614            Err(error)
2615                if !created_target
2616                    && error.contains("Target.attachToTarget failed")
2617                    && error.contains("No target with given id found") =>
2618            {
2619                mapper_target_id = self.create_bidi_mapper_target().await?;
2620                created_target = true;
2621                self.send_command(
2622                    "Target.attachToTarget",
2623                    json!({
2624                        "targetId": mapper_target_id,
2625                        "flatten": true,
2626                    }),
2627                    None,
2628                )
2629                .await?
2630            }
2631            Err(error) => return Err(error),
2632        };
2633        let mapper_session_id = required_string(&attached, "/sessionId")?;
2634
2635        self.send_command("Runtime.enable", json!({}), Some(&mapper_session_id))
2636            .await?;
2637        self.send_command(
2638            "Target.exposeDevToolsProtocol",
2639            json!({
2640                "bindingName": "cdp",
2641                "targetId": mapper_target_id,
2642                "inheritPermissions": true,
2643            }),
2644            None,
2645        )
2646        .await?;
2647        self.ensure_runtime_binding(&mapper_session_id, "sendBidiResponse")
2648            .await?;
2649
2650        if created_target
2651            || !self
2652                .mapper_runtime_is_initialized(&mapper_session_id)
2653                .await?
2654        {
2655            self.evaluate_expression(&mapper_session_id, CHROMIUM_BIDI_MAPPER_BUNDLE, false)
2656                .await?;
2657            self.evaluate_expression(
2658                &mapper_session_id,
2659                &format!(
2660                    "window.runMapperInstance('{}')",
2661                    js_single_quote(&mapper_target_id)
2662                ),
2663                true,
2664            )
2665            .await?;
2666            self.send_bidi_command(
2667                &mapper_session_id,
2668                &json!({
2669                    "id": 1,
2670                    "method": "session.new",
2671                    "params": {
2672                        "capabilities": {}
2673                    }
2674                }),
2675            )
2676            .await?;
2677        }
2678
2679        Ok(MapperConnectionInfo {
2680            package_version: CHROMIUM_BIDI_NPM_VERSION.to_string(),
2681            mapper_target_id,
2682            mapper_session_id,
2683        })
2684    }
2685
2686    async fn create_bidi_mapper_target(&mut self) -> Result<String, String> {
2687        let created = self
2688            .send_command(
2689                "Target.createTarget",
2690                json!({
2691                    "url": "about:blank#MAPPER_TARGET",
2692                    "hidden": true,
2693                    "background": true,
2694                }),
2695                None,
2696            )
2697            .await?;
2698        required_string(&created, "/targetId")
2699    }
2700
2701    async fn ensure_runtime_binding(&mut self, session_id: &str, name: &str) -> Result<(), String> {
2702        match self
2703            .send_command(
2704                "Runtime.addBinding",
2705                json!({
2706                    "name": name,
2707                }),
2708                Some(session_id),
2709            )
2710            .await
2711        {
2712            Ok(_) => Ok(()),
2713            Err(error)
2714                if error.contains("already exists") || error.contains("Cannot add binding") =>
2715            {
2716                Ok(())
2717            }
2718            Err(error) => Err(error),
2719        }
2720    }
2721
2722    async fn mapper_runtime_is_initialized(&mut self, session_id: &str) -> Result<bool, String> {
2723        let result = self
2724            .evaluate_expression(
2725                session_id,
2726                "typeof window.runMapperInstance === 'function' && typeof window.onBidiMessage === 'function'",
2727                true,
2728            )
2729            .await?;
2730        Ok(result
2731            .pointer("/result/value")
2732            .and_then(Value::as_bool)
2733            .unwrap_or(false))
2734    }
2735
2736    async fn send_bidi_command(
2737        &mut self,
2738        mapper_session_id: &str,
2739        command: &Value,
2740    ) -> Result<Value, String> {
2741        let command_id = command
2742            .get("id")
2743            .and_then(Value::as_u64)
2744            .ok_or_else(|| "BiDi command is missing numeric id".to_string())?;
2745        let eval_id = self.next_id;
2746        self.next_id += 1;
2747        let command_json = command.to_string();
2748        let expression = format!(
2749            "onBidiMessage({})",
2750            serde_json::to_string(&command_json)
2751                .map_err(|error| format!("failed to encode BiDi command payload: {error}"))?
2752        );
2753
2754        let payload = json!({
2755            "id": eval_id,
2756            "method": "Runtime.evaluate",
2757            "params": {
2758                "expression": expression,
2759                "awaitPromise": true,
2760            },
2761            "sessionId": mapper_session_id,
2762        });
2763        self.socket
2764            .send(Message::Text(payload.to_string().into()))
2765            .await
2766            .map_err(|error| format!("failed to send mapper Runtime.evaluate call: {error}"))?;
2767
2768        loop {
2769            let message = self.next_json_message().await?;
2770
2771            if message.get("id").and_then(Value::as_u64) == Some(eval_id) {
2772                if let Some(error) = message.get("error") {
2773                    return Err(format!("mapper Runtime.evaluate failed: {error}"));
2774                }
2775                if let Some(exception_details) = message.pointer("/result/exceptionDetails") {
2776                    return Err(format!(
2777                        "mapper Runtime.evaluate raised exception details: {exception_details}"
2778                    ));
2779                }
2780                continue;
2781            }
2782
2783            if message.get("sessionId").and_then(Value::as_str) != Some(mapper_session_id) {
2784                continue;
2785            }
2786
2787            if message.get("method").and_then(Value::as_str) != Some("Runtime.bindingCalled") {
2788                continue;
2789            }
2790
2791            let params = message
2792                .get("params")
2793                .ok_or_else(|| "Runtime.bindingCalled message is missing params".to_string())?;
2794            if params.get("name").and_then(Value::as_str) != Some("sendBidiResponse") {
2795                continue;
2796            }
2797
2798            let payload = params
2799                .get("payload")
2800                .and_then(Value::as_str)
2801                .ok_or_else(|| "Runtime.bindingCalled payload is missing".to_string())?;
2802            let response = serde_json::from_str::<Value>(payload)
2803                .map_err(|error| format!("failed to parse BiDi mapper response JSON: {error}"))?;
2804            if response.get("id").and_then(Value::as_u64) != Some(command_id) {
2805                continue;
2806            }
2807
2808            match response.get("type").and_then(Value::as_str) {
2809                Some("success") => return Ok(response),
2810                Some("error") => {
2811                    let message = response
2812                        .get("message")
2813                        .and_then(Value::as_str)
2814                        .unwrap_or("unknown BiDi mapper error");
2815                    let error = response
2816                        .get("error")
2817                        .and_then(Value::as_str)
2818                        .unwrap_or("unknown error");
2819                    return Err(format!("BiDi mapper returned {error}: {message}"));
2820                }
2821                Some(other) => {
2822                    return Err(format!("unexpected BiDi response type {other}: {response}"));
2823                }
2824                None => {
2825                    return Err(format!("BiDi mapper response is missing type: {response}"));
2826                }
2827            }
2828        }
2829    }
2830
2831    async fn resolve_bidi_context_id(
2832        &mut self,
2833        mapper_session_id: &str,
2834        existing_context_id: Option<&str>,
2835        current_url: Option<&str>,
2836    ) -> Result<String, String> {
2837        if let Some(existing_context_id) = existing_context_id {
2838            if !existing_context_id.trim().is_empty() {
2839                let probe = json!({
2840                    "id": 1,
2841                    "method": "browsingContext.getTree",
2842                    "params": {
2843                        "root": existing_context_id,
2844                        "maxDepth": 0,
2845                    }
2846                });
2847                match self.send_bidi_command(mapper_session_id, &probe).await {
2848                    Ok(_) => return Ok(existing_context_id.to_string()),
2849                    Err(error)
2850                        if error.contains("no such frame")
2851                            || error.contains("Context ")
2852                            || error.contains("invalid argument") => {}
2853                    Err(error) => return Err(error),
2854                }
2855            }
2856        }
2857
2858        let target_url = current_url
2859            .map(str::trim)
2860            .filter(|value| !value.is_empty())
2861            .map(str::to_string);
2862
2863        for attempt in 0..20 {
2864            let tree = self
2865                .send_bidi_command(
2866                    mapper_session_id,
2867                    &json!({
2868                        "id": 2,
2869                        "method": "browsingContext.getTree",
2870                        "params": {
2871                            "maxDepth": 0,
2872                        }
2873                    }),
2874                )
2875                .await?;
2876            let contexts = tree
2877                .pointer("/result/contexts")
2878                .and_then(Value::as_array)
2879                .ok_or_else(|| {
2880                    format!("BiDi browsingContext.getTree returned no contexts: {tree}")
2881                })?;
2882
2883            if let Some(target_url) = target_url.as_deref() {
2884                if let Some(context_id) = contexts.iter().find_map(|context| {
2885                    let url = context.get("url").and_then(Value::as_str)?;
2886                    if url == target_url {
2887                        context
2888                            .get("context")
2889                            .and_then(Value::as_str)
2890                            .map(str::to_string)
2891                    } else {
2892                        None
2893                    }
2894                }) {
2895                    return Ok(context_id);
2896                }
2897            }
2898
2899            if let Some(context_id) = contexts
2900                .first()
2901                .and_then(|context| context.get("context"))
2902                .and_then(Value::as_str)
2903                .filter(|value| !value.trim().is_empty())
2904                .map(str::to_string)
2905            {
2906                return Ok(context_id);
2907            }
2908
2909            if attempt < 19 {
2910                sleep(Duration::from_millis(150)).await;
2911            } else {
2912                return Err(format!(
2913                    "BiDi browsingContext.getTree returned no usable context ids after retries: {tree}"
2914                ));
2915            }
2916        }
2917
2918        Err("BiDi browsing context resolution exhausted retries unexpectedly".to_string())
2919    }
2920
2921    async fn navigate_and_wait_for_load(
2922        &mut self,
2923        session_id: &str,
2924        url: &str,
2925    ) -> Result<(), String> {
2926        let id = self.next_id;
2927        self.next_id += 1;
2928
2929        let payload = json!({
2930            "id": id,
2931            "method": "Page.navigate",
2932            "params": {
2933                "url": url,
2934            },
2935            "sessionId": session_id,
2936        });
2937        self.socket
2938            .send(Message::Text(payload.to_string().into()))
2939            .await
2940            .map_err(|error| format!("failed to send CDP command Page.navigate: {error}"))?;
2941
2942        let mut navigate_confirmed = false;
2943        let mut load_seen = false;
2944
2945        loop {
2946            let message = self.next_json_message().await?;
2947
2948            if message.get("id").and_then(Value::as_u64) == Some(id) {
2949                if let Some(error) = message.get("error") {
2950                    return Err(format!("CDP command Page.navigate failed: {error}"));
2951                }
2952                navigate_confirmed = true;
2953            } else if message.get("sessionId").and_then(Value::as_str) == Some(session_id)
2954                && message.get("method").and_then(Value::as_str) == Some("Page.loadEventFired")
2955            {
2956                load_seen = true;
2957            }
2958
2959            if navigate_confirmed && load_seen {
2960                return Ok(());
2961            }
2962        }
2963    }
2964
2965    async fn next_json_message(&mut self) -> Result<Value, String> {
2966        loop {
2967            let message = self
2968                .socket
2969                .next()
2970                .await
2971                .ok_or_else(|| "CDP websocket closed unexpectedly".to_string())?
2972                .map_err(|error| format!("failed to read CDP websocket message: {error}"))?;
2973
2974            match message {
2975                Message::Text(text) => {
2976                    let value = serde_json::from_str::<Value>(&text)
2977                        .map_err(|error| format!("failed to parse CDP JSON message: {error}"))?;
2978                    return Ok(value);
2979                }
2980                Message::Binary(bytes) => {
2981                    let value = serde_json::from_slice::<Value>(&bytes)
2982                        .map_err(|error| format!("failed to parse CDP JSON message: {error}"))?;
2983                    return Ok(value);
2984                }
2985                Message::Ping(payload) => {
2986                    self.socket
2987                        .send(Message::Pong(payload))
2988                        .await
2989                        .map_err(|error| format!("failed to reply to CDP ping: {error}"))?;
2990                }
2991                Message::Pong(_) => {}
2992                Message::Frame(_) => {}
2993                Message::Close(frame) => {
2994                    return Err(format!("CDP websocket closed: {frame:?}"));
2995                }
2996            }
2997        }
2998    }
2999}
3000
3001struct BidiConnection {
3002    socket: BidiSocket,
3003    next_id: u64,
3004}
3005
3006impl BidiConnection {
3007    async fn connect(bidi_websocket_url: &str) -> Result<Self, String> {
3008        let (socket, _) = connect_async(bidi_websocket_url).await.map_err(|error| {
3009            format!("failed to connect to WebDriver BiDi websocket {bidi_websocket_url}: {error}")
3010        })?;
3011        Ok(Self { socket, next_id: 1 })
3012    }
3013
3014    async fn send_command(&mut self, method: &str, params: Value) -> Result<Value, String> {
3015        let id = self.next_id;
3016        self.next_id += 1;
3017        let payload = json!({
3018            "id": id,
3019            "method": method,
3020            "params": params,
3021        });
3022        self.socket
3023            .send(Message::Text(payload.to_string().into()))
3024            .await
3025            .map_err(|error| format!("failed to send WebDriver BiDi command {method}: {error}"))?;
3026
3027        loop {
3028            let message = self.next_json_message().await?;
3029            if message.get("id").and_then(Value::as_u64) != Some(id) {
3030                continue;
3031            }
3032
3033            if let Some(error) = message.get("error").and_then(Value::as_str) {
3034                let message_text = message
3035                    .get("message")
3036                    .and_then(Value::as_str)
3037                    .unwrap_or("unknown WebDriver BiDi error");
3038                return Err(format!(
3039                    "WebDriver BiDi command {method} returned {error}: {message_text}"
3040                ));
3041            }
3042
3043            return Ok(message);
3044        }
3045    }
3046
3047    async fn wait_for_top_level_context(&mut self) -> Result<String, String> {
3048        for _ in 0..100 {
3049            let tree = self
3050                .send_command(
3051                    "browsingContext.getTree",
3052                    json!({
3053                        "maxDepth": 0,
3054                    }),
3055                )
3056                .await?;
3057            if let Some(context_id) = tree
3058                .pointer("/result/contexts")
3059                .and_then(Value::as_array)
3060                .and_then(|contexts| contexts.first())
3061                .and_then(|context| context.get("context"))
3062                .and_then(Value::as_str)
3063                .filter(|value| !value.trim().is_empty())
3064            {
3065                return Ok(context_id.to_string());
3066            }
3067            sleep(Duration::from_millis(50)).await;
3068        }
3069
3070        Err("timed out waiting for an initial Firefox browsing context".to_string())
3071    }
3072
3073    async fn next_json_message(&mut self) -> Result<Value, String> {
3074        loop {
3075            let message = self
3076                .socket
3077                .next()
3078                .await
3079                .ok_or_else(|| "WebDriver BiDi websocket closed unexpectedly".to_string())?
3080                .map_err(|error| {
3081                    format!("failed to read WebDriver BiDi websocket message: {error}")
3082                })?;
3083
3084            match message {
3085                Message::Text(text) => {
3086                    let value = serde_json::from_str::<Value>(&text).map_err(|error| {
3087                        format!("failed to parse WebDriver BiDi JSON message: {error}")
3088                    })?;
3089                    return Ok(value);
3090                }
3091                Message::Binary(bytes) => {
3092                    let value = serde_json::from_slice::<Value>(&bytes).map_err(|error| {
3093                        format!("failed to parse WebDriver BiDi JSON message: {error}")
3094                    })?;
3095                    return Ok(value);
3096                }
3097                Message::Ping(payload) => {
3098                    self.socket
3099                        .send(Message::Pong(payload))
3100                        .await
3101                        .map_err(|error| {
3102                            format!("failed to reply to WebDriver BiDi ping: {error}")
3103                        })?;
3104                }
3105                Message::Pong(_) => {}
3106                Message::Frame(_) => {}
3107                Message::Close(frame) => {
3108                    return Err(format!("WebDriver BiDi websocket closed: {frame:?}"));
3109                }
3110            }
3111        }
3112    }
3113}
3114
3115fn required_string(value: &Value, pointer: &str) -> Result<String, String> {
3116    value
3117        .pointer(pointer)
3118        .and_then(Value::as_str)
3119        .map(str::to_string)
3120        .filter(|value| !value.is_empty())
3121        .ok_or_else(|| format!("missing string field at JSON pointer {pointer}"))
3122}
3123
3124fn js_single_quote(value: &str) -> String {
3125    value.replace('\\', "\\\\").replace('\'', "\\'")
3126}
3127
3128fn json_u32(value: &Value, pointer: &str) -> Result<u32, String> {
3129    let raw = value
3130        .pointer(pointer)
3131        .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
3132
3133    if let Some(number) = raw.as_u64() {
3134        return u32::try_from(number)
3135            .map_err(|_| format!("numeric JSON value at {pointer} exceeds u32: {number}"));
3136    }
3137
3138    if let Some(number) = raw.as_i64() {
3139        return u32::try_from(number).map_err(|_| {
3140            format!("numeric JSON value at {pointer} is out of range for u32: {number}")
3141        });
3142    }
3143
3144    Err(format!(
3145        "expected numeric JSON value at {pointer}, found {raw}"
3146    ))
3147}
3148
3149fn json_f64(value: &Value, pointer: &str) -> Result<f64, String> {
3150    let raw = value
3151        .pointer(pointer)
3152        .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
3153
3154    raw.as_f64()
3155        .ok_or_else(|| format!("expected numeric JSON value at {pointer}, found {raw}"))
3156}
3157
3158fn json_string(value: &Value, pointer: &str) -> Result<String, String> {
3159    value
3160        .pointer(pointer)
3161        .and_then(Value::as_str)
3162        .map(str::to_string)
3163        .ok_or_else(|| format!("expected string JSON value at {pointer}"))
3164}
3165
3166fn json_string_literal(value: &str, context: &str) -> Result<String, String> {
3167    serde_json::to_string(value).map_err(|error| format!("failed to serialize {context}: {error}"))
3168}
3169
3170fn block_on_plugin_future<T, F>(future: F) -> Result<T, String>
3171where
3172    F: Future<Output = Result<T, String>>,
3173{
3174    let runtime = plugin_runtime()?;
3175    runtime.block_on(future)
3176}
3177
3178fn plugin_runtime() -> Result<&'static tokio::runtime::Runtime, String> {
3179    if let Some(runtime) = PLUGIN_RUNTIME.get() {
3180        return Ok(runtime);
3181    }
3182
3183    let runtime = tokio::runtime::Builder::new_multi_thread()
3184        .enable_all()
3185        .worker_threads(2)
3186        .thread_name("allwright-web-plugin")
3187        .build()
3188        .map_err(|error| format!("failed to create plugin runtime: {error}"))?;
3189    let _ = PLUGIN_RUNTIME.set(runtime);
3190    PLUGIN_RUNTIME
3191        .get()
3192        .ok_or_else(|| "failed to initialize plugin runtime".to_string())
3193}
3194
3195fn plugin_response(result: Result<PluginResult, String>) -> *mut c_char {
3196    let envelope = match result {
3197        Ok(result) => PluginEnvelope {
3198            ok: true,
3199            result: Some(result),
3200            error: None,
3201        },
3202        Err(error) => PluginEnvelope {
3203            ok: false,
3204            result: None,
3205            error: Some(error),
3206        },
3207    };
3208
3209    let json = match serde_json::to_string(&envelope) {
3210        Ok(json) => json,
3211        Err(error) => {
3212            let fallback = PluginEnvelope {
3213                ok: false,
3214                result: None,
3215                error: Some(format!("failed to serialize plugin response: {error}")),
3216            };
3217            serde_json::to_string(&fallback).unwrap_or_else(|_| {
3218                "{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response\"}"
3219                    .to_string()
3220            })
3221        }
3222    };
3223
3224    CString::new(json).unwrap().into_raw()
3225}
3226
3227fn handle_plugin_command(command: PluginCommand) -> Result<PluginResult, String> {
3228    match command {
3229        PluginCommand::LaunchBrowser {
3230            browser_kind,
3231            browser_binary,
3232        } => {
3233            launch_browser(browser_kind, browser_binary.as_deref()).map(PluginResult::LaunchBrowser)
3234        }
3235        PluginCommand::OpenPage { browser_session } => block_on_plugin_future(async move {
3236            open_page(&browser_session)
3237                .await
3238                .map(PluginResult::OpenPage)
3239        }),
3240        PluginCommand::ClosePage {
3241            browser_session,
3242            page_session,
3243        } => block_on_plugin_future(async move {
3244            close_page(&browser_session, &page_session).await?;
3245            Ok(PluginResult::ClosePage)
3246        }),
3247        PluginCommand::NavigatePage {
3248            browser_session,
3249            page_session,
3250            url,
3251        } => block_on_plugin_future(async move {
3252            navigate_page(&browser_session, &page_session, &url)
3253                .await
3254                .map(PluginResult::NavigatePage)
3255        }),
3256        PluginCommand::ClickElement {
3257            browser_session,
3258            page_session,
3259            css_selector,
3260        } => block_on_plugin_future(async move {
3261            click_element(&browser_session, &page_session, &css_selector)
3262                .await
3263                .map(PluginResult::ClickElement)
3264        }),
3265        PluginCommand::CountElements {
3266            browser_session,
3267            page_session,
3268            css_selector,
3269        } => block_on_plugin_future(async move {
3270            count_elements(&browser_session, &page_session, &css_selector)
3271                .await
3272                .map(PluginResult::CountElements)
3273        }),
3274        PluginCommand::HighlightElements {
3275            browser_session,
3276            page_session,
3277            css_selector,
3278            duration_ms,
3279        } => block_on_plugin_future(async move {
3280            highlight_elements(&browser_session, &page_session, &css_selector, duration_ms)
3281                .await
3282                .map(PluginResult::HighlightElements)
3283        }),
3284        PluginCommand::FocusElement {
3285            browser_session,
3286            page_session,
3287            css_selector,
3288        } => block_on_plugin_future(async move {
3289            focus_element(&browser_session, &page_session, &css_selector)
3290                .await
3291                .map(PluginResult::FocusElement)
3292        }),
3293        PluginCommand::FillElement {
3294            browser_session,
3295            page_session,
3296            css_selector,
3297            value,
3298        } => block_on_plugin_future(async move {
3299            fill_element(&browser_session, &page_session, &css_selector, &value)
3300                .await
3301                .map(PluginResult::FillElement)
3302        }),
3303        PluginCommand::HoverElement {
3304            browser_session,
3305            page_session,
3306            css_selector,
3307        } => block_on_plugin_future(async move {
3308            hover_element(&browser_session, &page_session, &css_selector)
3309                .await
3310                .map(PluginResult::HoverElement)
3311        }),
3312        PluginCommand::PressKey {
3313            browser_session,
3314            page_session,
3315            css_selector,
3316            key,
3317            text,
3318        } => block_on_plugin_future(async move {
3319            press_key(
3320                &browser_session,
3321                &page_session,
3322                &css_selector,
3323                &key,
3324                text.as_deref(),
3325            )
3326            .await
3327            .map(PluginResult::PressKey)
3328        }),
3329        PluginCommand::GetTextContent {
3330            browser_session,
3331            page_session,
3332            css_selector,
3333        } => block_on_plugin_future(async move {
3334            get_text_content(&browser_session, &page_session, &css_selector)
3335                .await
3336                .map(PluginResult::GetTextContent)
3337        }),
3338        PluginCommand::GetInnerText {
3339            browser_session,
3340            page_session,
3341            css_selector,
3342        } => block_on_plugin_future(async move {
3343            get_inner_text(&browser_session, &page_session, &css_selector)
3344                .await
3345                .map(PluginResult::GetInnerText)
3346        }),
3347        PluginCommand::WaitForSelector {
3348            browser_session,
3349            page_session,
3350            css_selector,
3351            visible,
3352        } => block_on_plugin_future(async move {
3353            wait_for_selector(&browser_session, &page_session, &css_selector, visible)
3354                .await
3355                .map(PluginResult::WaitForSelector)
3356        }),
3357        PluginCommand::OpenChromeWindow { chrome_binary } => {
3358            open_chrome_window(chrome_binary.as_deref()).map(PluginResult::OpenChromeWindow)
3359        }
3360        PluginCommand::DiscoverInitialTab { cdp_websocket_url } => {
3361            block_on_plugin_future(async move {
3362                let result = discover_initial_tab(&cdp_websocket_url).await?;
3363                Ok(PluginResult::DiscoverInitialTab(result))
3364            })
3365        }
3366        PluginCommand::OpenChromeTab { cdp_websocket_url } => block_on_plugin_future(async move {
3367            let result = open_chrome_tab(&cdp_websocket_url).await?;
3368            Ok(PluginResult::OpenChromeTab(result))
3369        }),
3370        PluginCommand::CloseBrowserProcess { process_id } => {
3371            close_browser_process(process_id)?;
3372            Ok(PluginResult::CloseBrowserProcess)
3373        }
3374        PluginCommand::CloseChromeTab {
3375            cdp_websocket_url,
3376            target_id,
3377        } => block_on_plugin_future(async move {
3378            close_chrome_tab(&cdp_websocket_url, &target_id).await?;
3379            Ok(PluginResult::CloseChromeTab)
3380        }),
3381        PluginCommand::NavigateChromeTab {
3382            cdp_websocket_url,
3383            target_id,
3384            url,
3385        } => block_on_plugin_future(async move {
3386            let result = navigate_chrome_tab(&cdp_websocket_url, &target_id, &url).await?;
3387            Ok(PluginResult::NavigateChromeTab(result))
3388        }),
3389        PluginCommand::InjectChromiumBidiMapper { cdp_websocket_url } => {
3390            block_on_plugin_future(async move {
3391                let result = inject_chromium_bidi_mapper(&cdp_websocket_url).await?;
3392                Ok(PluginResult::InjectChromiumBidiMapper(result))
3393            })
3394        }
3395        PluginCommand::ResolveBidiContextForTab {
3396            cdp_websocket_url,
3397            mapper_target_id,
3398            browsing_context_id,
3399            url,
3400        } => block_on_plugin_future(async move {
3401            let (resolved_browsing_context_id, mapper) = resolve_bidi_context_for_tab(
3402                &cdp_websocket_url,
3403                mapper_target_id.as_deref(),
3404                browsing_context_id.as_deref(),
3405                url.as_deref(),
3406            )
3407            .await?;
3408            Ok(PluginResult::ResolveBidiContextForTab {
3409                browsing_context_id: resolved_browsing_context_id,
3410                mapper,
3411            })
3412        }),
3413        PluginCommand::ClickElementViaCdp {
3414            cdp_websocket_url,
3415            target_id,
3416            css_selector,
3417        } => block_on_plugin_future(async move {
3418            let result =
3419                click_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3420            Ok(PluginResult::ClickElementViaCdp(result))
3421        }),
3422        PluginCommand::CountElementsViaCdp {
3423            cdp_websocket_url,
3424            target_id,
3425            css_selector,
3426        } => block_on_plugin_future(async move {
3427            let result =
3428                count_elements_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3429            Ok(PluginResult::CountElementsViaCdp(result))
3430        }),
3431        PluginCommand::HighlightElementsViaCdp {
3432            cdp_websocket_url,
3433            target_id,
3434            css_selector,
3435            duration_ms,
3436        } => block_on_plugin_future(async move {
3437            let duration_ms = u32::try_from(duration_ms)
3438                .map_err(|_| format!("highlight duration {duration_ms} exceeds u32"))?;
3439            let result = highlight_elements_via_cdp(
3440                &cdp_websocket_url,
3441                &target_id,
3442                &css_selector,
3443                duration_ms,
3444            )
3445            .await?;
3446            Ok(PluginResult::HighlightElementsViaCdp(result))
3447        }),
3448        PluginCommand::FocusElementViaCdp {
3449            cdp_websocket_url,
3450            target_id,
3451            css_selector,
3452        } => block_on_plugin_future(async move {
3453            let result =
3454                focus_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3455            Ok(PluginResult::FocusElementViaCdp(result))
3456        }),
3457        PluginCommand::FillElementViaCdp {
3458            cdp_websocket_url,
3459            target_id,
3460            css_selector,
3461            value,
3462        } => block_on_plugin_future(async move {
3463            let result =
3464                fill_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector, &value).await?;
3465            Ok(PluginResult::FillElementViaCdp(result))
3466        }),
3467        PluginCommand::HoverElementViaCdp {
3468            cdp_websocket_url,
3469            target_id,
3470            css_selector,
3471        } => block_on_plugin_future(async move {
3472            let result =
3473                hover_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3474            Ok(PluginResult::HoverElementViaCdp(result))
3475        }),
3476        PluginCommand::PressKeyViaCdp {
3477            cdp_websocket_url,
3478            target_id,
3479            css_selector,
3480            key,
3481            text,
3482        } => block_on_plugin_future(async move {
3483            let result = press_key_via_cdp(
3484                &cdp_websocket_url,
3485                &target_id,
3486                &css_selector,
3487                &key,
3488                text.as_deref(),
3489            )
3490            .await?;
3491            Ok(PluginResult::PressKeyViaCdp(result))
3492        }),
3493        PluginCommand::GetTextContentViaCdp {
3494            cdp_websocket_url,
3495            target_id,
3496            css_selector,
3497        } => block_on_plugin_future(async move {
3498            let result =
3499                get_text_content_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3500            Ok(PluginResult::GetTextContentViaCdp(result))
3501        }),
3502        PluginCommand::GetInnerTextViaCdp {
3503            cdp_websocket_url,
3504            target_id,
3505            css_selector,
3506        } => block_on_plugin_future(async move {
3507            let result =
3508                get_inner_text_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3509            Ok(PluginResult::GetInnerTextViaCdp(result))
3510        }),
3511        PluginCommand::WaitForSelectorViaCdp {
3512            cdp_websocket_url,
3513            target_id,
3514            css_selector,
3515            visible,
3516        } => block_on_plugin_future(async move {
3517            let result =
3518                wait_for_selector_via_cdp(&cdp_websocket_url, &target_id, &css_selector, visible)
3519                    .await?;
3520            Ok(PluginResult::WaitForSelectorViaCdp(result))
3521        }),
3522    }
3523}
3524
3525#[unsafe(no_mangle)]
3526pub extern "C" fn allwright_plugin_api_version() -> u32 {
3527    ALLWRIGHT_PLUGIN_API_VERSION
3528}
3529
3530#[unsafe(no_mangle)]
3531pub extern "C" fn allwright_plugin_id() -> *const c_char {
3532    c"web".as_ptr()
3533}
3534
3535#[unsafe(no_mangle)]
3536pub unsafe extern "C" fn allwright_plugin_invoke(request_json: *const c_char) -> *mut c_char {
3537    if request_json.is_null() {
3538        return plugin_response(Err("plugin request pointer is null".to_string()));
3539    }
3540
3541    let request = match unsafe { CStr::from_ptr(request_json) }.to_str() {
3542        Ok(request) => request,
3543        Err(error) => {
3544            return plugin_response(Err(format!("plugin request is not valid UTF-8: {error}")));
3545        }
3546    };
3547
3548    let command: PluginCommand = match serde_json::from_str(request) {
3549        Ok(command) => command,
3550        Err(error) => {
3551            return plugin_response(Err(format!("failed to parse plugin request JSON: {error}")));
3552        }
3553    };
3554
3555    plugin_response(handle_plugin_command(command))
3556}
3557
3558#[unsafe(no_mangle)]
3559pub unsafe extern "C" fn allwright_plugin_free_string(value: *mut c_char) {
3560    if !value.is_null() {
3561        unsafe {
3562            let _ = CString::from_raw(value);
3563        }
3564    }
3565}
3566
3567#[cfg(test)]
3568mod tests {
3569    use super::{SelectorKind, parse_selector};
3570
3571    #[test]
3572    fn parse_selector_detects_explicit_xpath_prefix() {
3573        let parsed = parse_selector("click_element", "xpath=//button[normalize-space()='Save']")
3574            .expect("selector should parse");
3575        assert_eq!(parsed.kind, SelectorKind::XPath);
3576        assert_eq!(parsed.value, "//button[normalize-space()='Save']");
3577    }
3578
3579    #[test]
3580    fn parse_selector_detects_xpath_heuristically() {
3581        let parsed =
3582            parse_selector("click_element", "//div[@role='dialog']").expect("selector should parse");
3583        assert_eq!(parsed.kind, SelectorKind::XPath);
3584    }
3585
3586    #[test]
3587    fn parse_selector_preserves_css_by_default() {
3588        let parsed = parse_selector("click_element", "button[data-testid='save']")
3589            .expect("selector should parse");
3590        assert_eq!(parsed.kind, SelectorKind::Css);
3591        assert_eq!(parsed.value, "button[data-testid='save']");
3592    }
3593
3594    #[test]
3595    fn parse_selector_decodes_json_quoted_xpath_payload() {
3596        let parsed = parse_selector(
3597            "click_element",
3598            r#"xpath="//button[normalize-space()='Save Changes']""#,
3599        )
3600        .expect("selector should parse");
3601        assert_eq!(parsed.kind, SelectorKind::XPath);
3602        assert_eq!(parsed.value, "//button[normalize-space()='Save Changes']");
3603    }
3604
3605    #[test]
3606    fn parse_selector_chain_handles_quoted_hybrid_segments() {
3607        let segments = super::parse_selector_chain(
3608            "click_element",
3609            r#"xpath="//div[@role='dialog']" css="button.primary""#,
3610        )
3611        .expect("selector chain should parse");
3612        assert_eq!(segments.len(), 2);
3613        assert_eq!(segments[0].kind, SelectorKind::XPath);
3614        assert_eq!(segments[0].value, "//div[@role='dialog']");
3615        assert_eq!(segments[1].kind, SelectorKind::Css);
3616        assert_eq!(segments[1].value, "button.primary");
3617    }
3618}