Skip to main content

allwright_surface_web/
lib.rs

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