Skip to main content

allwright_surface_web/
lib.rs

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