Skip to main content

allwright_surface_web/
lib.rs

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