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    match (browser_session, page_session) {
1388        (
1389            BrowserSessionHandle::Chromium { cdp_websocket_url },
1390            PageSessionHandle::Chromium {
1391                browsing_context_id,
1392                mapper_target_id,
1393                ..
1394            },
1395        ) => {
1396            click_element_via_bidi(
1397                cdp_websocket_url,
1398                mapper_target_id.as_deref(),
1399                browsing_context_id.as_deref(),
1400                css_selector,
1401            )
1402            .await
1403        }
1404        (
1405            BrowserSessionHandle::Firefox {
1406                connection_id,
1407                bidi_session_id,
1408            },
1409            PageSessionHandle::Firefox {
1410                browsing_context_id,
1411            },
1412        ) => {
1413            let parsed = parse_selector("click_element", css_selector)?;
1414            let mut sessions = firefox_session_guard(connection_id).await?;
1415            let bidi = &mut sessions
1416                .get_mut(connection_id)
1417                .expect("Firefox session guard validated connection id")
1418                .connection;
1419            let query_first_js = selector_query_first_js(parsed.kind);
1420            let center = firefox_evaluate_json(
1421                bidi,
1422                browsing_context_id,
1423                &format!(
1424                    "JSON.stringify((() => {{
1425                        const selector = {};
1426                        const requirements = {};
1427                        {}
1428                        const element = {query_first_js};
1429                        const prepared = allwrightPrepareElement(element, selector, '{}', requirements);
1430                        return prepared.center;
1431                    }})())",
1432                    json_string_literal(&parsed.value, "click selector")?,
1433                    discovery_requirements_literal(DiscoveryRequirements {
1434                        require_match: true,
1435                        require_visible: true,
1436                        require_pointer_interactable: true,
1437                        ..DiscoveryRequirements::default()
1438                    }),
1439                    actionability_helpers_js(),
1440                    selector_kind_label(parsed.kind),
1441                ),
1442            )
1443            .await?;
1444            let x = json_f64(&center, "/x")?;
1445            let y = json_f64(&center, "/y")?;
1446            bidi.send_command(
1447                "input.performActions",
1448                json!({
1449                    "context": browsing_context_id,
1450                    "actions": [{
1451                        "type": "pointer",
1452                        "id": "allwright-mouse",
1453                        "parameters": { "pointerType": "mouse" },
1454                        "actions": [
1455                            { "type": "pointerMove", "origin": "viewport", "x": x, "y": y },
1456                            { "type": "pointerDown", "button": 0 },
1457                            { "type": "pointerUp", "button": 0 }
1458                        ]
1459                    }]
1460                }),
1461            )
1462            .await?;
1463            Ok(ClickInfo {
1464                css_selector: css_selector.to_string(),
1465                note: format!(
1466                    "clicked element via Firefox WebDriver BiDi input.performActions using {} {}",
1467                    selector_kind_label(parsed.kind),
1468                    parsed.value
1469                ),
1470                bidi_session_id: bidi_session_id.clone(),
1471            })
1472        }
1473        _ => Err("browser/page backend mismatch while clicking".to_string()),
1474    }
1475}
1476
1477pub async fn count_elements(
1478    browser_session: &BrowserSessionHandle,
1479    page_session: &PageSessionHandle,
1480    css_selector: &str,
1481) -> Result<ElementCountInfo, String> {
1482    match (browser_session, page_session) {
1483        (
1484            BrowserSessionHandle::Chromium { cdp_websocket_url },
1485            PageSessionHandle::Chromium {
1486                browsing_context_id,
1487                mapper_target_id,
1488                ..
1489            },
1490        ) => {
1491            let parsed = parse_selector("count_elements", css_selector)?;
1492            let discovered = discover_elements_via_bidi(
1493                cdp_websocket_url,
1494                mapper_target_id.as_deref(),
1495                browsing_context_id.as_deref(),
1496                css_selector,
1497                DiscoveryRequirements::default(),
1498            )
1499            .await?;
1500            Ok(ElementCountInfo {
1501                css_selector: css_selector.to_string(),
1502                count: discovered.count,
1503                note: format!(
1504                    "counted {} element(s) matching {} {}",
1505                    discovered.count,
1506                    selector_kind_label(parsed.kind),
1507                    parsed.value
1508                ),
1509            })
1510        }
1511        (
1512            BrowserSessionHandle::Firefox { connection_id, .. },
1513            PageSessionHandle::Firefox {
1514                browsing_context_id,
1515            },
1516        ) => {
1517            let parsed = parse_selector("count_elements", css_selector)?;
1518            let mut sessions = firefox_session_guard(connection_id).await?;
1519            let bidi = &mut sessions
1520                .get_mut(connection_id)
1521                .expect("Firefox session guard validated connection id")
1522                .connection;
1523            let query_all_js = selector_query_all_js(parsed.kind);
1524            let count = firefox_evaluate_u32(
1525                bidi,
1526                browsing_context_id,
1527                &format!(
1528                    "(() => {{
1529                        const selector = {};
1530                        return {query_all_js}.length;
1531                    }})()",
1532                    json_string_literal(&parsed.value, "count selector")?,
1533                ),
1534            )
1535            .await?;
1536            Ok(ElementCountInfo {
1537                css_selector: css_selector.to_string(),
1538                count,
1539                note: format!(
1540                    "counted {count} element(s) matching {} {}",
1541                    selector_kind_label(parsed.kind),
1542                    parsed.value
1543                ),
1544            })
1545        }
1546        _ => Err("browser/page backend mismatch while counting elements".to_string()),
1547    }
1548}
1549
1550pub async fn highlight_elements(
1551    browser_session: &BrowserSessionHandle,
1552    page_session: &PageSessionHandle,
1553    css_selector: &str,
1554    duration_ms: u64,
1555) -> Result<HighlightElementsInfo, String> {
1556    match (browser_session, page_session) {
1557        (
1558            BrowserSessionHandle::Chromium { cdp_websocket_url },
1559            PageSessionHandle::Chromium {
1560                browsing_context_id,
1561                mapper_target_id,
1562                ..
1563            },
1564        ) => {
1565            let parsed = parse_selector("highlight_elements", css_selector)?;
1566            let duration_ms = u32::try_from(duration_ms).unwrap_or(u32::MAX).max(1);
1567            let count = chromium_bidi_evaluate_u32(cdp_websocket_url, mapper_target_id.as_deref(), browsing_context_id.as_deref(), &format!(
1568                "(() => {{ 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; }})()",
1569                json_string_literal(&parsed.value, "highlight selector")?, selector_query_all_js(parsed.kind),
1570            )).await?;
1571            Ok(HighlightElementsInfo {
1572                css_selector: css_selector.to_string(),
1573                count,
1574                note: format!(
1575                    "highlighted {count} element(s) matching {} {} for {duration_ms}ms",
1576                    selector_kind_label(parsed.kind),
1577                    parsed.value
1578                ),
1579            })
1580        }
1581        (
1582            BrowserSessionHandle::Firefox { connection_id, .. },
1583            PageSessionHandle::Firefox {
1584                browsing_context_id,
1585            },
1586        ) => {
1587            let parsed = parse_selector("highlight_elements", css_selector)?;
1588            let duration_ms = u32::try_from(duration_ms).unwrap_or(u32::MAX).max(1);
1589            let mut sessions = firefox_session_guard(connection_id).await?;
1590            let bidi = &mut sessions
1591                .get_mut(connection_id)
1592                .expect("Firefox session guard validated connection id")
1593                .connection;
1594            let query_all_js = selector_query_all_js(parsed.kind);
1595            let count = firefox_evaluate_u32(
1596                bidi,
1597                browsing_context_id,
1598                &format!(
1599                    "(() => {{
1600                        const selector = {};
1601                        const durationMs = {duration_ms};
1602                        const elements = {query_all_js};
1603                        for (const element of elements) {{
1604                            if (!(element instanceof HTMLElement)) {{
1605                                continue;
1606                            }}
1607                            element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
1608                            const priorOutline = element.style.outline;
1609                            const priorOutlineOffset = element.style.outlineOffset;
1610                            const priorBackgroundColor = element.style.backgroundColor;
1611                            element.style.outline = '3px solid #ff5a36';
1612                            element.style.outlineOffset = '2px';
1613                            element.style.backgroundColor = 'rgba(255, 235, 59, 0.35)';
1614                            window.setTimeout(() => {{
1615                                element.style.outline = priorOutline;
1616                                element.style.outlineOffset = priorOutlineOffset;
1617                                element.style.backgroundColor = priorBackgroundColor;
1618                            }}, durationMs);
1619                        }}
1620                        return elements.length;
1621                    }})()",
1622                    json_string_literal(&parsed.value, "highlight selector")?,
1623                ),
1624            )
1625            .await?;
1626            Ok(HighlightElementsInfo {
1627                css_selector: css_selector.to_string(),
1628                count,
1629                note: format!(
1630                    "highlighted {count} element(s) matching {} {} for {duration_ms}ms",
1631                    selector_kind_label(parsed.kind),
1632                    parsed.value
1633                ),
1634            })
1635        }
1636        _ => Err("browser/page backend mismatch while highlighting elements".to_string()),
1637    }
1638}
1639
1640fn focus_element_expression(selector: &str) -> Result<(String, String), String> {
1641    let selector_literal = json_string_literal(selector, "focus selector")?;
1642    let segments = selector_segments_literal("focus_element", selector)?;
1643    let kind = selector_chain_kind_label("focus_element", selector)?;
1644    let query_all = selector_chain_query_all_js();
1645    let requirements = discovery_requirements_literal(DiscoveryRequirements {
1646        require_match: true,
1647        require_visible: true,
1648        focus_first_match: true,
1649        require_focus: true,
1650        ..DiscoveryRequirements::default()
1651    });
1652    Ok((
1653        format!(
1654            "(() => {{
1655            const selector = {selector_literal};
1656            const selectorSegments = {segments};
1657            const selectorChainQueryAll = () => {query_all};
1658            const elements = {query_all};
1659            const requirements = {requirements};
1660            {}
1661            const element = elements[0] ?? null;
1662            allwrightPrepareElement(element, selector, '{kind}', requirements);
1663        }})()",
1664            actionability_helpers_js(),
1665        ),
1666        kind,
1667    ))
1668}
1669
1670pub async fn focus_element(
1671    browser_session: &BrowserSessionHandle,
1672    page_session: &PageSessionHandle,
1673    css_selector: &str,
1674) -> Result<FocusInfo, String> {
1675    match (browser_session, page_session) {
1676        (
1677            BrowserSessionHandle::Chromium { cdp_websocket_url },
1678            PageSessionHandle::Chromium {
1679                browsing_context_id,
1680                mapper_target_id,
1681                ..
1682            },
1683        ) => {
1684            let (expression, kind) = focus_element_expression(css_selector)?;
1685            chromium_bidi_evaluate_void(
1686                cdp_websocket_url,
1687                mapper_target_id.as_deref(),
1688                browsing_context_id.as_deref(),
1689                &expression,
1690            )
1691            .await?;
1692            Ok(FocusInfo {
1693                css_selector: css_selector.to_string(),
1694                note: format!("focused element matching {kind}: {css_selector}"),
1695            })
1696        }
1697        (
1698            BrowserSessionHandle::Firefox { connection_id, .. },
1699            PageSessionHandle::Firefox {
1700                browsing_context_id,
1701            },
1702        ) => {
1703            let (expression, kind) = focus_element_expression(css_selector)?;
1704            let mut sessions = firefox_session_guard(connection_id).await?;
1705            let bidi = &mut sessions
1706                .get_mut(connection_id)
1707                .expect("Firefox session guard validated connection id")
1708                .connection;
1709            firefox_evaluate_void(bidi, browsing_context_id, &expression).await?;
1710            Ok(FocusInfo {
1711                css_selector: css_selector.to_string(),
1712                note: format!("focused element matching {kind}: {css_selector}"),
1713            })
1714        }
1715        _ => Err("browser/page backend mismatch while focusing element".to_string()),
1716    }
1717}
1718
1719pub async fn fill_element(
1720    browser_session: &BrowserSessionHandle,
1721    page_session: &PageSessionHandle,
1722    css_selector: &str,
1723    value: &str,
1724) -> Result<FillInfo, String> {
1725    match (browser_session, page_session) {
1726        (
1727            BrowserSessionHandle::Chromium { cdp_websocket_url },
1728            PageSessionHandle::Chromium {
1729                browsing_context_id,
1730                mapper_target_id,
1731                ..
1732            },
1733        ) => {
1734            let parsed = parse_selector("fill_element", css_selector)?;
1735            chromium_bidi_evaluate_void(
1736                cdp_websocket_url,
1737                mapper_target_id.as_deref(),
1738                browsing_context_id.as_deref(),
1739                &dom_set_value_and_dispatch_events_js(
1740                    parsed.kind,
1741                    &json_string_literal(&parsed.value, "fill selector")?,
1742                    &json_string_literal(value, "fill value")?,
1743                ),
1744            )
1745            .await?;
1746            Ok(FillInfo {
1747                css_selector: css_selector.to_string(),
1748                value: value.to_string(),
1749                note: format!(
1750                    "filled element matching {} {}",
1751                    selector_kind_label(parsed.kind),
1752                    parsed.value
1753                ),
1754            })
1755        }
1756        (
1757            BrowserSessionHandle::Firefox { connection_id, .. },
1758            PageSessionHandle::Firefox {
1759                browsing_context_id,
1760            },
1761        ) => {
1762            let parsed = parse_selector("fill_element", css_selector)?;
1763            let mut sessions = firefox_session_guard(connection_id).await?;
1764            let bidi = &mut sessions
1765                .get_mut(connection_id)
1766                .expect("Firefox session guard validated connection id")
1767                .connection;
1768            firefox_evaluate_void(
1769                bidi,
1770                browsing_context_id,
1771                &dom_set_value_and_dispatch_events_js(
1772                    parsed.kind,
1773                    &json_string_literal(&parsed.value, "fill selector")?,
1774                    &json_string_literal(value, "fill value")?,
1775                ),
1776            )
1777            .await?;
1778            Ok(FillInfo {
1779                css_selector: css_selector.to_string(),
1780                value: value.to_string(),
1781                note: format!(
1782                    "filled element matching {} {}",
1783                    selector_kind_label(parsed.kind),
1784                    parsed.value
1785                ),
1786            })
1787        }
1788        _ => Err("browser/page backend mismatch while filling element".to_string()),
1789    }
1790}
1791
1792pub async fn hover_element(
1793    browser_session: &BrowserSessionHandle,
1794    page_session: &PageSessionHandle,
1795    css_selector: &str,
1796) -> Result<HoverInfo, String> {
1797    match (browser_session, page_session) {
1798        (
1799            BrowserSessionHandle::Chromium { cdp_websocket_url },
1800            PageSessionHandle::Chromium {
1801                browsing_context_id,
1802                mapper_target_id,
1803                ..
1804            },
1805        ) => {
1806            let parsed = parse_selector("hover_element", css_selector)?;
1807            let center = discover_elements_via_bidi(
1808                cdp_websocket_url,
1809                mapper_target_id.as_deref(),
1810                browsing_context_id.as_deref(),
1811                css_selector,
1812                DiscoveryRequirements {
1813                    require_match: true,
1814                    require_visible: true,
1815                    require_pointer_interactable: true,
1816                    ..DiscoveryRequirements::default()
1817                },
1818            )
1819            .await?
1820            .first_center
1821            .ok_or_else(|| {
1822                format!(
1823                    "element discovery did not return a hover center for {} {}",
1824                    selector_kind_label(parsed.kind),
1825                    parsed.value
1826                )
1827            })?;
1828            let (mut cdp, mapper, context) = chromium_bidi_session(
1829                cdp_websocket_url,
1830                mapper_target_id.as_deref(),
1831                browsing_context_id.as_deref(),
1832            )
1833            .await?;
1834            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?;
1835            Ok(HoverInfo {
1836                css_selector: css_selector.to_string(),
1837                note: format!(
1838                    "hovered element matching {} {}",
1839                    selector_kind_label(parsed.kind),
1840                    parsed.value
1841                ),
1842            })
1843        }
1844        (
1845            BrowserSessionHandle::Firefox { connection_id, .. },
1846            PageSessionHandle::Firefox {
1847                browsing_context_id,
1848            },
1849        ) => {
1850            let parsed = parse_selector("hover_element", css_selector)?;
1851            let mut sessions = firefox_session_guard(connection_id).await?;
1852            let bidi = &mut sessions
1853                .get_mut(connection_id)
1854                .expect("Firefox session guard validated connection id")
1855                .connection;
1856            let query_first_js = selector_query_first_js(parsed.kind);
1857            firefox_evaluate_void(
1858                bidi,
1859                browsing_context_id,
1860                &format!(
1861                    "(() => {{
1862                        const selector = {};
1863                        const requirements = {};
1864                        {}
1865                        const element = {query_first_js};
1866                        const prepared = allwrightPrepareElement(element, selector, '{}', requirements);
1867                        for (const type of ['pointerover', 'mouseover', 'mouseenter', 'mousemove']) {{
1868                            prepared.element.dispatchEvent(new MouseEvent(type, {{ bubbles: true, cancelable: true, view: window }}));
1869                        }}
1870                    }})()",
1871                    json_string_literal(&parsed.value, "hover selector")?,
1872                    discovery_requirements_literal(DiscoveryRequirements {
1873                        require_match: true,
1874                        require_visible: true,
1875                        require_pointer_interactable: true,
1876                        ..DiscoveryRequirements::default()
1877                    }),
1878                    actionability_helpers_js(),
1879                    selector_kind_label(parsed.kind),
1880                ),
1881            )
1882            .await?;
1883            Ok(HoverInfo {
1884                css_selector: css_selector.to_string(),
1885                note: format!(
1886                    "hovered element matching {} {}",
1887                    selector_kind_label(parsed.kind),
1888                    parsed.value
1889                ),
1890            })
1891        }
1892        _ => Err("browser/page backend mismatch while hovering element".to_string()),
1893    }
1894}
1895
1896pub async fn press_key(
1897    browser_session: &BrowserSessionHandle,
1898    page_session: &PageSessionHandle,
1899    css_selector: &str,
1900    key: &str,
1901    text: Option<&str>,
1902) -> Result<PressKeyInfo, String> {
1903    match (browser_session, page_session) {
1904        (
1905            BrowserSessionHandle::Chromium { cdp_websocket_url },
1906            PageSessionHandle::Chromium {
1907                browsing_context_id,
1908                mapper_target_id,
1909                ..
1910            },
1911        ) => {
1912            let parsed = parse_selector("press_key", css_selector)?;
1913            if key.trim().is_empty() {
1914                return Err("press_key command requires a non-empty key".to_string());
1915            }
1916            let query_first_js = selector_query_first_js(parsed.kind);
1917            chromium_bidi_evaluate_void(cdp_websocket_url, mapper_target_id.as_deref(), browsing_context_id.as_deref(), &format!(
1918                "(() => {{ 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 }})); }})()",
1919                json_string_literal(&parsed.value, "press selector")?, json_string_literal(key, "press key")?, json_string_literal(text.unwrap_or(""), "press text")?,
1920                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),
1921            )).await?;
1922            Ok(PressKeyInfo {
1923                css_selector: css_selector.to_string(),
1924                key: key.to_string(),
1925                note: format!(
1926                    "pressed key {key} on element matching {} {}",
1927                    selector_kind_label(parsed.kind),
1928                    parsed.value
1929                ),
1930            })
1931        }
1932        (
1933            BrowserSessionHandle::Firefox { connection_id, .. },
1934            PageSessionHandle::Firefox {
1935                browsing_context_id,
1936            },
1937        ) => {
1938            let parsed = parse_selector("press_key", css_selector)?;
1939            if key.trim().is_empty() {
1940                return Err("press_key command requires a non-empty key".to_string());
1941            }
1942            let mut sessions = firefox_session_guard(connection_id).await?;
1943            let bidi = &mut sessions
1944                .get_mut(connection_id)
1945                .expect("Firefox session guard validated connection id")
1946                .connection;
1947            let query_first_js = selector_query_first_js(parsed.kind);
1948            firefox_evaluate_void(
1949                bidi,
1950                browsing_context_id,
1951                &format!(
1952                    "(() => {{
1953                        const selector = {};
1954                        const key = {};
1955                        const text = {};
1956                        const requirements = {};
1957                        {}
1958                        const element = {query_first_js};
1959                        const prepared = allwrightPrepareElement(element, selector, '{}', requirements);
1960                        prepared.element.dispatchEvent(new KeyboardEvent('keydown', {{ key, bubbles: true }}));
1961                        if (text && 'value' in prepared.element) {{
1962                            prepared.element.value += text;
1963                            prepared.element.dispatchEvent(new Event('input', {{ bubbles: true }}));
1964                            prepared.element.dispatchEvent(new Event('change', {{ bubbles: true }}));
1965                        }}
1966                        prepared.element.dispatchEvent(new KeyboardEvent('keyup', {{ key, bubbles: true }}));
1967                    }})()",
1968                    json_string_literal(&parsed.value, "press selector")?,
1969                    json_string_literal(key, "press key")?,
1970                    json_string_literal(text.unwrap_or(""), "press text")?,
1971                    discovery_requirements_literal(DiscoveryRequirements {
1972                        require_match: true,
1973                        require_visible: true,
1974                        focus_first_match: true,
1975                        require_focus: true,
1976                        ..DiscoveryRequirements::default()
1977                    }),
1978                    actionability_helpers_js(),
1979                    selector_kind_label(parsed.kind),
1980                ),
1981            )
1982            .await?;
1983            Ok(PressKeyInfo {
1984                css_selector: css_selector.to_string(),
1985                key: key.to_string(),
1986                note: format!(
1987                    "pressed key {key} on element matching {} {}",
1988                    selector_kind_label(parsed.kind),
1989                    parsed.value
1990                ),
1991            })
1992        }
1993        _ => Err("browser/page backend mismatch while pressing a key".to_string()),
1994    }
1995}
1996
1997pub async fn get_text_content(
1998    browser_session: &BrowserSessionHandle,
1999    page_session: &PageSessionHandle,
2000    css_selector: &str,
2001) -> Result<TextInfo, String> {
2002    match (browser_session, page_session) {
2003        (
2004            BrowserSessionHandle::Chromium { cdp_websocket_url },
2005            PageSessionHandle::Chromium {
2006                browsing_context_id,
2007                mapper_target_id,
2008                ..
2009            },
2010        ) => chromium_bidi_get_text(
2011            cdp_websocket_url,
2012            mapper_target_id.as_deref(),
2013            browsing_context_id.as_deref(),
2014            css_selector,
2015            "textContent",
2016        )
2017        .await
2018        .map(|mut info| {
2019            info.note = format!("resolved textContent for {}", css_selector);
2020            info
2021        }),
2022        (
2023            BrowserSessionHandle::Firefox { connection_id, .. },
2024            PageSessionHandle::Firefox {
2025                browsing_context_id,
2026            },
2027        ) => {
2028            firefox_get_text(
2029                connection_id,
2030                browsing_context_id,
2031                css_selector,
2032                "textContent",
2033            )
2034            .await
2035        }
2036        _ => Err("browser/page backend mismatch while resolving textContent".to_string()),
2037    }
2038}
2039
2040pub async fn get_inner_text(
2041    browser_session: &BrowserSessionHandle,
2042    page_session: &PageSessionHandle,
2043    css_selector: &str,
2044) -> Result<TextInfo, String> {
2045    match (browser_session, page_session) {
2046        (
2047            BrowserSessionHandle::Chromium { cdp_websocket_url },
2048            PageSessionHandle::Chromium {
2049                browsing_context_id,
2050                mapper_target_id,
2051                ..
2052            },
2053        ) => chromium_bidi_get_text(
2054            cdp_websocket_url,
2055            mapper_target_id.as_deref(),
2056            browsing_context_id.as_deref(),
2057            css_selector,
2058            "innerText",
2059        )
2060        .await
2061        .map(|mut info| {
2062            info.note = format!("resolved innerText for {}", css_selector);
2063            info
2064        }),
2065        (
2066            BrowserSessionHandle::Firefox { connection_id, .. },
2067            PageSessionHandle::Firefox {
2068                browsing_context_id,
2069            },
2070        ) => {
2071            firefox_get_text(
2072                connection_id,
2073                browsing_context_id,
2074                css_selector,
2075                "innerText",
2076            )
2077            .await
2078        }
2079        _ => Err("browser/page backend mismatch while resolving innerText".to_string()),
2080    }
2081}
2082
2083pub async fn wait_for_selector(
2084    browser_session: &BrowserSessionHandle,
2085    page_session: &PageSessionHandle,
2086    css_selector: &str,
2087    visible: bool,
2088) -> Result<WaitForSelectorInfo, String> {
2089    match (browser_session, page_session) {
2090        (
2091            BrowserSessionHandle::Chromium { cdp_websocket_url },
2092            PageSessionHandle::Chromium {
2093                browsing_context_id,
2094                mapper_target_id,
2095                ..
2096            },
2097        ) => {
2098            let parsed = parse_selector("wait_for_selector", css_selector)?;
2099            let discovered = discover_elements_via_bidi(
2100                cdp_websocket_url,
2101                mapper_target_id.as_deref(),
2102                browsing_context_id.as_deref(),
2103                css_selector,
2104                DiscoveryRequirements {
2105                    require_visible: visible,
2106                    ..DiscoveryRequirements::default()
2107                },
2108            )
2109            .await?;
2110            let success = if visible {
2111                discovered.first_center.is_some()
2112            } else {
2113                discovered.count > 0
2114            };
2115            if !success {
2116                return Err(if visible {
2117                    format!(
2118                        "no visible element matches {} {}",
2119                        selector_kind_label(parsed.kind),
2120                        parsed.value
2121                    )
2122                } else {
2123                    format!(
2124                        "no element matches {} {}",
2125                        selector_kind_label(parsed.kind),
2126                        parsed.value
2127                    )
2128                });
2129            }
2130            Ok(WaitForSelectorInfo {
2131                css_selector: css_selector.to_string(),
2132                visible,
2133                note: if visible {
2134                    format!(
2135                        "visible element matched {} {}",
2136                        selector_kind_label(parsed.kind),
2137                        parsed.value
2138                    )
2139                } else {
2140                    format!(
2141                        "element matched {} {}",
2142                        selector_kind_label(parsed.kind),
2143                        parsed.value
2144                    )
2145                },
2146            })
2147        }
2148        (
2149            BrowserSessionHandle::Firefox { connection_id, .. },
2150            PageSessionHandle::Firefox {
2151                browsing_context_id,
2152            },
2153        ) => {
2154            let parsed = parse_selector("wait_for_selector", css_selector)?;
2155            let mut sessions = firefox_session_guard(connection_id).await?;
2156            let bidi = &mut sessions
2157                .get_mut(connection_id)
2158                .expect("Firefox session guard validated connection id")
2159                .connection;
2160            let query_first_js = selector_query_first_js(parsed.kind);
2161            let success = firefox_evaluate_bool(
2162                bidi,
2163                browsing_context_id,
2164                &format!(
2165                    "(() => {{
2166                        const selector = {};
2167                        const requirements = {};
2168                        {}
2169                        const element = {query_first_js};
2170                        if (!element) {{
2171                            return false;
2172                        }}
2173                        if (!{}) {{
2174                            return true;
2175                        }}
2176                        try {{
2177                            return !!allwrightPrepareElement(element, selector, '{}', requirements);
2178                        }} catch (_error) {{
2179                            return false;
2180                        }}
2181                    }})()",
2182                    json_string_literal(&parsed.value, "wait selector")?,
2183                    discovery_requirements_literal(DiscoveryRequirements {
2184                        require_visible: visible,
2185                        ..DiscoveryRequirements::default()
2186                    }),
2187                    actionability_helpers_js(),
2188                    if visible { "true" } else { "false" },
2189                    selector_kind_label(parsed.kind),
2190                ),
2191            )
2192            .await?;
2193            if !success {
2194                return Err(if visible {
2195                    format!(
2196                        "no visible element matches {} {}",
2197                        selector_kind_label(parsed.kind),
2198                        parsed.value
2199                    )
2200                } else {
2201                    format!(
2202                        "no element matches {} {}",
2203                        selector_kind_label(parsed.kind),
2204                        parsed.value
2205                    )
2206                });
2207            }
2208            Ok(WaitForSelectorInfo {
2209                css_selector: css_selector.to_string(),
2210                visible,
2211                note: if visible {
2212                    format!(
2213                        "visible element matched {} {}",
2214                        selector_kind_label(parsed.kind),
2215                        parsed.value
2216                    )
2217                } else {
2218                    format!(
2219                        "element matched {} {}",
2220                        selector_kind_label(parsed.kind),
2221                        parsed.value
2222                    )
2223                },
2224            })
2225        }
2226        _ => Err("browser/page backend mismatch while waiting for selector".to_string()),
2227    }
2228}
2229
2230pub async fn screenshot(
2231    browser_session: &BrowserSessionHandle,
2232    page_session: &PageSessionHandle,
2233    full_page: bool,
2234) -> Result<ScreenshotInfo, String> {
2235    match (browser_session, page_session) {
2236        (
2237            BrowserSessionHandle::Chromium { cdp_websocket_url },
2238            PageSessionHandle::Chromium {
2239                browsing_context_id,
2240                mapper_target_id,
2241                ..
2242            },
2243        ) => {
2244            chromium_bidi_screenshot(
2245                cdp_websocket_url,
2246                mapper_target_id.as_deref(),
2247                browsing_context_id.as_deref(),
2248                full_page,
2249            )
2250            .await
2251        }
2252        (
2253            BrowserSessionHandle::Firefox { connection_id, .. },
2254            PageSessionHandle::Firefox {
2255                browsing_context_id,
2256            },
2257        ) => {
2258            let mut sessions = firefox_session_guard(connection_id).await?;
2259            let bidi = &mut sessions
2260                .get_mut(connection_id)
2261                .expect("Firefox session guard validated connection id")
2262                .connection;
2263            let response = bidi
2264                .send_command(
2265                    "browsingContext.captureScreenshot",
2266                    json!({
2267                        "context": browsing_context_id,
2268                        "origin": if full_page { "document" } else { "viewport" },
2269                    }),
2270                )
2271                .await?;
2272            Ok(ScreenshotInfo {
2273                png_data: decode_base64_field(
2274                    &response,
2275                    "/result/data",
2276                    "Firefox screenshot data",
2277                )?,
2278                note: if full_page {
2279                    "captured full-page Firefox screenshot via native WebDriver BiDi".to_string()
2280                } else {
2281                    "captured Firefox screenshot via native WebDriver BiDi".to_string()
2282                },
2283            })
2284        }
2285        _ => Err("browser/page backend mismatch while capturing screenshot".to_string()),
2286    }
2287}
2288
2289fn dom_set_value_and_dispatch_events_js(
2290    selector_kind: SelectorKind,
2291    selector_literal: &str,
2292    value_literal: &str,
2293) -> String {
2294    let query_first_js = selector_query_first_js(selector_kind);
2295    let selector_kind_label = selector_kind_label(selector_kind);
2296    format!(
2297        "(() => {{
2298            const selector = {selector_literal};
2299            const nextValue = {value_literal};
2300            const requirements = {requirements_literal};
2301            {actionability_helpers_js}
2302            const element = {query_first_js};
2303            const prepared = allwrightPrepareElement(
2304                element,
2305                selector,
2306                '{selector_kind_label}',
2307                requirements,
2308            );
2309            const editableElement = prepared.element;
2310
2311            if (editableElement instanceof HTMLInputElement || editableElement instanceof HTMLTextAreaElement) {{
2312                const prototype = editableElement instanceof HTMLInputElement
2313                    ? HTMLInputElement.prototype
2314                    : HTMLTextAreaElement.prototype;
2315                const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
2316                const setter = descriptor?.set;
2317                if (!setter) {{
2318                    throw new Error(`Element value setter is unavailable for {selector_kind_label}: ${{selector}}`);
2319                }}
2320                setter.call(editableElement, nextValue);
2321            }} else if (editableElement.isContentEditable) {{
2322                editableElement.textContent = nextValue;
2323            }} else {{
2324                throw new Error(`Element matched by {selector_kind_label} does not support fill: ${{selector}}`);
2325            }}
2326
2327            const inputEvent = typeof InputEvent === 'function'
2328                ? new InputEvent('input', {{
2329                    bubbles: true,
2330                    composed: true,
2331                    data: nextValue,
2332                    inputType: 'insertText'
2333                }})
2334                : new Event('input', {{ bubbles: true, composed: true }});
2335            editableElement.dispatchEvent(inputEvent);
2336            editableElement.dispatchEvent(new Event('change', {{ bubbles: true, composed: true }}));
2337            return true;
2338        }})()",
2339        requirements_literal = discovery_requirements_literal(DiscoveryRequirements {
2340            require_match: true,
2341            require_visible: true,
2342            require_editable: true,
2343            focus_first_match: true,
2344            require_focus: true,
2345            ..DiscoveryRequirements::default()
2346        }),
2347        actionability_helpers_js = actionability_helpers_js(),
2348    )
2349}
2350
2351fn discovery_requirements_literal(requirements: DiscoveryRequirements) -> String {
2352    format!(
2353        "{{ requireMatch: {}, requireVisible: {}, requirePointerInteractable: {}, requireEditable: {}, focusFirstMatch: {}, requireFocus: {} }}",
2354        requirements.require_match,
2355        requirements.require_visible,
2356        requirements.require_pointer_interactable,
2357        requirements.require_editable,
2358        requirements.focus_first_match,
2359        requirements.require_focus,
2360    )
2361}
2362
2363fn actionability_helpers_js() -> &'static str {
2364    r#"
2365            const allwrightCanFocusElement = (element) => {
2366                if (!(element instanceof HTMLElement)) {
2367                    return false;
2368                }
2369                if (typeof element.matches === 'function' && element.matches(':disabled')) {
2370                    return false;
2371                }
2372                return !element.closest('[inert]');
2373            };
2374            const allwrightIsEditableElement = (element) => {
2375                if (!(element instanceof HTMLElement)) {
2376                    return false;
2377                }
2378                if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
2379                    return !element.disabled && !element.readOnly;
2380                }
2381                return element.isContentEditable && !element.closest('[inert]');
2382            };
2383            const allwrightIsVisibleElement = (rect, style) => {
2384                if (!rect || rect.width <= 0 || rect.height <= 0) {
2385                    return false;
2386                }
2387                if (!style) {
2388                    return false;
2389                }
2390                if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') {
2391                    return false;
2392                }
2393                const opacity = Number.parseFloat(style.opacity ?? '1');
2394                if (!Number.isNaN(opacity) && opacity <= 0) {
2395                    return false;
2396                }
2397                return true;
2398            };
2399            const allwrightReceivesPointerEvents = (element, center, style) => {
2400                if (style.pointerEvents === 'none') {
2401                    return false;
2402                }
2403                const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
2404                const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
2405                if (center.x < 0 || center.y < 0 || center.x > viewportWidth || center.y > viewportHeight) {
2406                    return false;
2407                }
2408                const hit = document.elementFromPoint(center.x, center.y);
2409                return !!hit && (hit === element || element.contains(hit) || hit.contains(element));
2410            };
2411            const allwrightPrepareElement = (element, selector, selectorKindLabel, requirements) => {
2412                if (!element) {
2413                    throw new Error(`No element matches ${selectorKindLabel}: ${selector}`);
2414                }
2415                if (!(element instanceof Element)) {
2416                    throw new Error(`${selectorKindLabel} did not resolve to a DOM Element: ${selector}`);
2417                }
2418                if (!element.isConnected) {
2419                    throw new Error(`Element matched by ${selectorKindLabel} is detached from the live DOM: ${selector}`);
2420                }
2421                element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
2422                const rect = element.getBoundingClientRect();
2423                const style = window.getComputedStyle(element);
2424                if (requirements.requireVisible && !allwrightIsVisibleElement(rect, style)) {
2425                    throw new Error(`Element matched by ${selectorKindLabel} is not visible yet: ${selector}`);
2426                }
2427                const center = rect.width > 0 && rect.height > 0
2428                    ? { x: rect.left + (rect.width / 2), y: rect.top + (rect.height / 2) }
2429                    : null;
2430                if (requirements.requirePointerInteractable) {
2431                    if (!center || !allwrightReceivesPointerEvents(element, center, style)) {
2432                        throw new Error(`Element matched by ${selectorKindLabel} is not clickable yet: ${selector}`);
2433                    }
2434                }
2435                if (requirements.requireEditable && !allwrightIsEditableElement(element)) {
2436                    throw new Error(`Element matched by ${selectorKindLabel} is not editable yet: ${selector}`);
2437                }
2438                if (requirements.focusFirstMatch || requirements.requireFocus) {
2439                    if (!allwrightCanFocusElement(element)) {
2440                        throw new Error(`Element matched by ${selectorKindLabel} is not focusable yet: ${selector}`);
2441                    }
2442                    element.focus();
2443                    const activeElement = document.activeElement;
2444                    if (requirements.requireFocus && activeElement !== element && !element.contains(activeElement)) {
2445                        throw new Error(`Element matched by ${selectorKindLabel} did not receive focus yet: ${selector}`);
2446                    }
2447                }
2448                return { element, rect, center, style };
2449            };
2450    "#
2451}
2452
2453pub async fn resolve_bidi_context_for_tab(
2454    cdp_websocket_url: &str,
2455    existing_mapper_target_id: Option<&str>,
2456    existing_context_id: Option<&str>,
2457    current_url: Option<&str>,
2458) -> Result<(String, ChromiumBidiMapperInfo), String> {
2459    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
2460    let mapper = cdp
2461        .ensure_chromium_bidi_mapper(existing_mapper_target_id)
2462        .await?;
2463    let context_id = cdp
2464        .resolve_bidi_context_id(&mapper.mapper_session_id, existing_context_id, current_url)
2465        .await?;
2466
2467    Ok((
2468        context_id,
2469        ChromiumBidiMapperInfo {
2470            package_version: mapper.package_version,
2471            mapper_target_id: mapper.mapper_target_id,
2472            mapper_session_id: mapper.mapper_session_id,
2473            note: "resolved BiDi browsing context for tab".to_string(),
2474        },
2475    ))
2476}
2477
2478async fn chromium_bidi_session(
2479    cdp_websocket_url: &str,
2480    existing_mapper_target_id: Option<&str>,
2481    existing_context_id: Option<&str>,
2482) -> Result<(CdpConnection, MapperConnectionInfo, String), String> {
2483    let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
2484    let mapper = cdp
2485        .ensure_chromium_bidi_mapper(existing_mapper_target_id)
2486        .await?;
2487    let browsing_context_id = cdp
2488        .resolve_bidi_context_id(&mapper.mapper_session_id, existing_context_id, None)
2489        .await?;
2490    Ok((cdp, mapper, browsing_context_id))
2491}
2492
2493async fn chromium_bidi_evaluate_json_on_session(
2494    cdp: &mut CdpConnection,
2495    mapper_session_id: &str,
2496    browsing_context_id: &str,
2497    expression: &str,
2498) -> Result<Value, String> {
2499    let response = cdp
2500        .send_bidi_command(
2501            mapper_session_id,
2502            &json!({
2503                "id": 2,
2504                "method": "script.evaluate",
2505                "params": {
2506                    "expression": expression,
2507                    "target": { "context": browsing_context_id },
2508                    "awaitPromise": true,
2509                },
2510            }),
2511        )
2512        .await?;
2513    let result_type = json_string(&response, "/result/type")?;
2514    if result_type != "success" {
2515        let details = response
2516            .pointer("/result/exceptionDetails")
2517            .cloned()
2518            .unwrap_or(Value::Null);
2519        return Err(format!("Chromium BiDi script.evaluate failed: {details}"));
2520    }
2521    let value = json_string(&response, "/result/result/value")?;
2522    serde_json::from_str::<Value>(&value)
2523        .map_err(|error| format!("failed to parse Chromium BiDi script result JSON: {error}"))
2524}
2525
2526async fn chromium_bidi_evaluate_json(
2527    cdp_websocket_url: &str,
2528    existing_mapper_target_id: Option<&str>,
2529    existing_context_id: Option<&str>,
2530    expression: &str,
2531) -> Result<Value, String> {
2532    let (mut cdp, mapper, browsing_context_id) = chromium_bidi_session(
2533        cdp_websocket_url,
2534        existing_mapper_target_id,
2535        existing_context_id,
2536    )
2537    .await?;
2538    chromium_bidi_evaluate_json_on_session(
2539        &mut cdp,
2540        &mapper.mapper_session_id,
2541        &browsing_context_id,
2542        expression,
2543    )
2544    .await
2545}
2546
2547async fn chromium_bidi_evaluate_void(
2548    cdp_websocket_url: &str,
2549    existing_mapper_target_id: Option<&str>,
2550    existing_context_id: Option<&str>,
2551    expression: &str,
2552) -> Result<(), String> {
2553    let (mut cdp, mapper, context) = chromium_bidi_session(
2554        cdp_websocket_url,
2555        existing_mapper_target_id,
2556        existing_context_id,
2557    )
2558    .await?;
2559    let response = cdp
2560        .send_bidi_command_with_dialogs(
2561            &mapper.mapper_session_id,
2562            &json!({
2563                "id": 2, "method": "script.evaluate", "params": {
2564                    "expression": expression, "target": {"context": context}, "awaitPromise": true,
2565                },
2566            }),
2567            true,
2568        )
2569        .await?;
2570    check_script_action_result(&response)
2571}
2572
2573async fn chromium_bidi_evaluate_u32(
2574    cdp_websocket_url: &str,
2575    existing_mapper_target_id: Option<&str>,
2576    existing_context_id: Option<&str>,
2577    expression: &str,
2578) -> Result<u32, String> {
2579    let value = chromium_bidi_evaluate_json(
2580        cdp_websocket_url,
2581        existing_mapper_target_id,
2582        existing_context_id,
2583        &format!("JSON.stringify({expression})"),
2584    )
2585    .await?;
2586    json_u32(&value, "")
2587}
2588
2589pub async fn click_element_via_bidi(
2590    cdp_websocket_url: &str,
2591    existing_mapper_target_id: Option<&str>,
2592    existing_context_id: Option<&str>,
2593    css_selector: &str,
2594) -> Result<ClickInfo, String> {
2595    let parsed = parse_selector("click_element", css_selector)?;
2596    let selector_literal = json_string_literal(&parsed.value, "click selector")?;
2597    let query_first_js = selector_query_first_js(parsed.kind);
2598    let expression = format!(
2599        "JSON.stringify((() => {{
2600            const selector = {selector_literal};
2601            const requirements = {};
2602            {}
2603            const element = {query_first_js};
2604            return allwrightPrepareElement(element, selector, '{}', requirements).center;
2605        }})())",
2606        discovery_requirements_literal(DiscoveryRequirements {
2607            require_match: true,
2608            require_visible: true,
2609            require_pointer_interactable: true,
2610            ..DiscoveryRequirements::default()
2611        }),
2612        actionability_helpers_js(),
2613        selector_kind_label(parsed.kind),
2614    );
2615    let (mut cdp, mapper, browsing_context_id) = chromium_bidi_session(
2616        cdp_websocket_url,
2617        existing_mapper_target_id,
2618        existing_context_id,
2619    )
2620    .await?;
2621    let center = chromium_bidi_evaluate_json_on_session(
2622        &mut cdp,
2623        &mapper.mapper_session_id,
2624        &browsing_context_id,
2625        &expression,
2626    )
2627    .await?;
2628    let x = json_f64(&center, "/x")?;
2629    let y = json_f64(&center, "/y")?;
2630    cdp.send_bidi_command(
2631        &mapper.mapper_session_id,
2632        &json!({
2633            "id": 3,
2634            "method": "input.performActions",
2635            "params": {
2636                "context": browsing_context_id,
2637                "actions": [{
2638                    "type": "pointer",
2639                    "id": "allwright-mouse",
2640                    "parameters": { "pointerType": "mouse" },
2641                    "actions": [
2642                        { "type": "pointerMove", "origin": "viewport", "x": x, "y": y },
2643                        { "type": "pointerDown", "button": 0 },
2644                        { "type": "pointerUp", "button": 0 }
2645                    ]
2646                }]
2647            }
2648        }),
2649    )
2650    .await?;
2651    Ok(ClickInfo {
2652        css_selector: css_selector.to_string(),
2653        note: format!(
2654            "clicked element via Chromium WebDriver BiDi input.performActions using {} {}",
2655            selector_kind_label(parsed.kind),
2656            parsed.value
2657        ),
2658        bidi_session_id: format!("chromium-bidi:{browsing_context_id}"),
2659    })
2660}
2661
2662async fn discover_elements_via_bidi(
2663    cdp_websocket_url: &str,
2664    mapper_target_id: Option<&str>,
2665    browsing_context_id: Option<&str>,
2666    selector: &str,
2667    requirements: DiscoveryRequirements,
2668) -> Result<DiscoveredElements, String> {
2669    let selector_literal = json_string_literal(selector, "selector for discovery")?;
2670    let selector_segments_literal = selector_segments_literal("discover_elements", selector)?;
2671    let selector_kind_label = selector_chain_kind_label("discover_elements", selector)?;
2672    let query_all_js = selector_chain_query_all_js();
2673    let requirements_literal = discovery_requirements_literal(requirements);
2674    let value = chromium_bidi_evaluate_json(
2675        cdp_websocket_url,
2676        mapper_target_id,
2677        browsing_context_id,
2678        &format!(
2679            "JSON.stringify((() => {{
2680                const selector = {selector_literal};
2681                const selectorSegments = {selector_segments_literal};
2682                const selectorChainQueryAll = () => {query_all_js};
2683                const elements = {query_all_js};
2684                const first = elements[0] ?? null;
2685                const requirements = {requirements_literal};
2686                {}
2687                if (requirements.requireMatch && !first) {{
2688                    throw new Error(`No element matches {selector_kind_label}: ${{selector}}`);
2689                }}
2690                const prepared = first
2691                    ? allwrightPrepareElement(first, selector, '{selector_kind_label}', requirements)
2692                    : null;
2693                return {{ count: elements.length, first: prepared?.center ?? null }};
2694            }})())",
2695            actionability_helpers_js(),
2696        ),
2697    )
2698    .await?;
2699    let count = json_u32(&value, "/count")?;
2700    let first_center = if value
2701        .pointer("/first")
2702        .is_some_and(|center| !center.is_null())
2703    {
2704        Some(ElementCenter {
2705            x: json_f64(&value, "/first/x")?,
2706            y: json_f64(&value, "/first/y")?,
2707        })
2708    } else {
2709        None
2710    };
2711    Ok(DiscoveredElements {
2712        count,
2713        first_center,
2714    })
2715}
2716
2717async fn chromium_bidi_get_text(
2718    cdp_websocket_url: &str,
2719    mapper_target_id: Option<&str>,
2720    browsing_context_id: Option<&str>,
2721    css_selector: &str,
2722    property: &str,
2723) -> Result<TextInfo, String> {
2724    let parsed = parse_selector("get_text", css_selector)?;
2725    discover_elements_via_bidi(
2726        cdp_websocket_url,
2727        mapper_target_id,
2728        browsing_context_id,
2729        css_selector,
2730        DiscoveryRequirements {
2731            require_match: true,
2732            ..DiscoveryRequirements::default()
2733        },
2734    )
2735    .await?;
2736    let value = chromium_bidi_evaluate_json(
2737        cdp_websocket_url,
2738        mapper_target_id,
2739        browsing_context_id,
2740        &format!(
2741            "JSON.stringify((() => {{
2742                const selector = {};
2743                const element = {};
2744                if (!element) {{ throw new Error(`No element matches {}: ${{selector}}`); }}
2745                const value = element[{}];
2746                return typeof value === 'string' ? value : '';
2747            }})())",
2748            json_string_literal(&parsed.value, "text selector")?,
2749            selector_query_first_js(parsed.kind),
2750            selector_kind_label(parsed.kind),
2751            json_string_literal(property, "text property")?,
2752        ),
2753    )
2754    .await?;
2755    let text = value.as_str().ok_or_else(|| {
2756        format!("expected Chromium BiDi text result to be a string, found {value}")
2757    })?;
2758    Ok(TextInfo {
2759        css_selector: css_selector.to_string(),
2760        text: text.to_string(),
2761        note: String::new(),
2762    })
2763}
2764
2765async fn chromium_bidi_screenshot(
2766    cdp_websocket_url: &str,
2767    mapper_target_id: Option<&str>,
2768    browsing_context_id: Option<&str>,
2769    full_page: bool,
2770) -> Result<ScreenshotInfo, String> {
2771    let (mut cdp, mapper, browsing_context_id) =
2772        chromium_bidi_session(cdp_websocket_url, mapper_target_id, browsing_context_id).await?;
2773    let response = cdp
2774        .send_bidi_command(
2775            &mapper.mapper_session_id,
2776            &json!({
2777                "id": 2,
2778                "method": "browsingContext.captureScreenshot",
2779                "params": {
2780                    "context": browsing_context_id,
2781                    "origin": if full_page { "document" } else { "viewport" },
2782                }
2783            }),
2784        )
2785        .await?;
2786    Ok(ScreenshotInfo {
2787        png_data: decode_base64_field(&response, "/result/data", "Chromium BiDi screenshot data")?,
2788        note: if full_page {
2789            "captured full-page Chromium screenshot via WebDriver BiDi".to_string()
2790        } else {
2791            "captured Chromium screenshot via WebDriver BiDi".to_string()
2792        },
2793    })
2794}
2795
2796#[cfg(target_os = "macos")]
2797fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
2798    let browser_binary = chrome_binary.map(ToOwned::to_owned).unwrap_or_else(|| {
2799        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".to_string()
2800    });
2801    launch_chrome_with_cdp(&browser_binary)
2802}
2803
2804#[cfg(target_os = "linux")]
2805fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
2806    if let Some(chrome_binary) = chrome_binary {
2807        return launch_chrome_with_cdp(chrome_binary);
2808    }
2809
2810    let candidates = ["google-chrome", "chromium", "chromium-browser"];
2811
2812    for candidate in candidates {
2813        match launch_chrome_with_cdp(candidate) {
2814            Ok(launch_info) => return Ok(launch_info),
2815            Err(error) => {
2816                if error.contains("No such file or directory")
2817                    || error.contains("os error 2")
2818                    || error.contains("not found")
2819                {
2820                    continue;
2821                }
2822                return Err(format!("failed to launch {candidate}: {error}"));
2823            }
2824        }
2825    }
2826
2827    Err("could not find a Chrome-compatible browser binary".to_string())
2828}
2829
2830#[cfg(target_os = "windows")]
2831fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
2832    if let Some(chrome_binary) = chrome_binary {
2833        return launch_chrome_with_cdp(chrome_binary);
2834    }
2835
2836    launch_chrome_with_cdp("chrome")
2837}
2838
2839#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
2840fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
2841    match chrome_binary {
2842        Some(chrome_binary) => launch_chrome_with_cdp(chrome_binary),
2843        None => Err("Chrome launching is not supported on this platform yet".to_string()),
2844    }
2845}
2846
2847fn launch_chrome_with_cdp(browser_binary: &str) -> Result<ChromeLaunchInfo, String> {
2848    let user_data_dir = create_chrome_user_data_dir()?;
2849    let user_data_dir_str = user_data_dir.to_string_lossy().to_string();
2850
2851    let child = Command::new(browser_binary)
2852        .args([
2853            "--new-window",
2854            "--remote-debugging-port=0",
2855            "--no-first-run",
2856            "--no-default-browser-check",
2857            "--disable-sync",
2858            &format!("--user-data-dir={user_data_dir_str}"),
2859        ])
2860        .stdout(Stdio::null())
2861        .stderr(Stdio::null())
2862        .spawn()
2863        .map_err(|error| format!("failed to launch Chrome binary {browser_binary}: {error}"))?;
2864    let process_id = child.id();
2865
2866    let cdp_websocket_url = wait_for_cdp_endpoint(&user_data_dir)?;
2867
2868    Ok(ChromeLaunchInfo {
2869        browser: browser_binary.to_string(),
2870        note: "launched Chrome window with CDP enabled".to_string(),
2871        cdp_websocket_url,
2872        user_data_dir: user_data_dir_str,
2873        process_id,
2874    })
2875}
2876
2877fn create_chrome_user_data_dir() -> Result<PathBuf, String> {
2878    let timestamp = SystemTime::now()
2879        .duration_since(UNIX_EPOCH)
2880        .map_err(|error| format!("failed to compute timestamp: {error}"))?
2881        .as_millis();
2882    let pid = std::process::id();
2883    let dir = std::env::temp_dir().join(format!("allwright-chrome-{pid}-{timestamp}"));
2884    fs::create_dir_all(&dir).map_err(|error| {
2885        format!(
2886            "failed to create Chrome user data dir {}: {error}",
2887            dir.display()
2888        )
2889    })?;
2890    let default_profile = dir.join("Default");
2891    fs::create_dir_all(&default_profile).map_err(|error| {
2892        format!(
2893            "failed to create Chrome default profile {}: {error}",
2894            default_profile.display()
2895        )
2896    })?;
2897    let preferences = default_profile.join("Preferences");
2898    fs::write(&preferences, chromium_profile_preferences().to_string()).map_err(|error| {
2899        format!(
2900            "failed to write Chrome automation preferences {}: {error}",
2901            preferences.display()
2902        )
2903    })?;
2904    Ok(dir)
2905}
2906
2907fn chromium_profile_preferences() -> Value {
2908    json!({
2909        "download": {
2910            "prompt_for_download": false,
2911        },
2912        "download_bubble": {
2913            "partial_view_enabled": false,
2914        },
2915        "profile": {
2916            "default_content_setting_values": {
2917                "automatic_downloads": 1,
2918            },
2919        },
2920    })
2921}
2922
2923fn wait_for_cdp_endpoint(user_data_dir: &std::path::Path) -> Result<String, String> {
2924    let active_port_file = user_data_dir.join("DevToolsActivePort");
2925
2926    for _ in 0..100 {
2927        if active_port_file.exists() {
2928            let contents = fs::read_to_string(&active_port_file).map_err(|error| {
2929                format!(
2930                    "failed to read DevToolsActivePort file {}: {error}",
2931                    active_port_file.display()
2932                )
2933            })?;
2934            return parse_devtools_active_port(&contents);
2935        }
2936        thread::sleep(StdDuration::from_millis(50));
2937    }
2938
2939    Err(format!(
2940        "timed out waiting for DevToolsActivePort in {}",
2941        user_data_dir.display()
2942    ))
2943}
2944
2945fn parse_devtools_active_port(contents: &str) -> Result<String, String> {
2946    let mut lines = contents.lines();
2947    let port = lines
2948        .next()
2949        .map(str::trim)
2950        .filter(|value| !value.is_empty())
2951        .ok_or_else(|| "DevToolsActivePort missing port line".to_string())?;
2952    let browser_path = lines
2953        .next()
2954        .map(str::trim)
2955        .filter(|value| !value.is_empty())
2956        .ok_or_else(|| "DevToolsActivePort missing websocket path line".to_string())?;
2957
2958    Ok(format!("ws://127.0.0.1:{port}{browser_path}"))
2959}
2960
2961#[cfg(target_os = "macos")]
2962fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
2963    let browser_binary = browser_binary
2964        .map(ToOwned::to_owned)
2965        .unwrap_or_else(|| "/Applications/Firefox.app/Contents/MacOS/firefox".to_string());
2966    launch_firefox_with_remote_agent(&browser_binary)
2967}
2968
2969#[cfg(target_os = "linux")]
2970fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
2971    if let Some(browser_binary) = browser_binary {
2972        return launch_firefox_with_remote_agent(browser_binary);
2973    }
2974
2975    for candidate in ["firefox", "firefox-esr"] {
2976        match launch_firefox_with_remote_agent(candidate) {
2977            Ok(launch_info) => return Ok(launch_info),
2978            Err(error)
2979                if error.contains("No such file or directory")
2980                    || error.contains("os error 2")
2981                    || error.contains("not found") => {}
2982            Err(error) => return Err(format!("failed to launch {candidate}: {error}")),
2983        }
2984    }
2985
2986    Err("could not find a Firefox browser binary".to_string())
2987}
2988
2989#[cfg(target_os = "windows")]
2990fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
2991    let browser_binary = browser_binary.unwrap_or("firefox");
2992    launch_firefox_with_remote_agent(browser_binary)
2993}
2994
2995#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
2996fn launch_firefox_with_bidi(browser_binary: Option<&str>) -> Result<BrowserLaunchInfo, String> {
2997    match browser_binary {
2998        Some(browser_binary) => launch_firefox_with_remote_agent(browser_binary),
2999        None => Err("Firefox launching is not supported on this platform yet".to_string()),
3000    }
3001}
3002
3003fn launch_firefox_with_remote_agent(browser_binary: &str) -> Result<BrowserLaunchInfo, String> {
3004    let user_data_dir = create_firefox_user_data_dir()?;
3005    let user_data_dir_str = user_data_dir.to_string_lossy().to_string();
3006    let remote_port = choose_loopback_port()?;
3007
3008    let child = Command::new(browser_binary)
3009        .args([
3010            "--new-instance",
3011            "--no-remote",
3012            "--remote-debugging-port",
3013            &remote_port.to_string(),
3014            "--profile",
3015            &user_data_dir_str,
3016            "about:blank",
3017        ])
3018        .stdout(Stdio::null())
3019        .stderr(Stdio::null())
3020        .spawn()
3021        .map_err(|error| format!("failed to launch Firefox binary {browser_binary}: {error}"))?;
3022    let process_id = child.id();
3023
3024    let bootstrap_ws_url = format!("ws://127.0.0.1:{remote_port}/session");
3025    let (connection_id, bidi_session_id, initial_context_id) = block_on_plugin_future(async {
3026        let mut bidi = wait_for_bidi_endpoint(&bootstrap_ws_url).await?;
3027        let session = bidi
3028            .send_command(
3029                "session.new",
3030                json!({
3031                    "capabilities": {
3032                        "alwaysMatch": {
3033                            "acceptInsecureCerts": true,
3034                            "unhandledPromptBehavior": suppressed_file_dialog_behavior()
3035                        }
3036                    }
3037                }),
3038            )
3039            .await?;
3040        let session_id = required_string(&session, "/result/sessionId")?;
3041        let initial_context_id = bidi.wait_for_top_level_context().await?;
3042        let connection_id = format!("firefox-bidi-{process_id}-{session_id}");
3043        firefox_session_registry().lock().await.insert(
3044            connection_id.clone(),
3045            FirefoxSessionState {
3046                process_id,
3047                connection: bidi,
3048            },
3049        );
3050        Ok((connection_id, session_id, initial_context_id))
3051    })?;
3052
3053    Ok(BrowserLaunchInfo {
3054        browser_kind: BrowserKind::Firefox,
3055        browser: browser_binary.to_string(),
3056        note: "launched Firefox window with native WebDriver BiDi enabled".to_string(),
3057        user_data_dir: user_data_dir_str,
3058        process_id,
3059        browser_session: BrowserSessionHandle::Firefox {
3060            connection_id,
3061            bidi_session_id: bidi_session_id.clone(),
3062        },
3063        initial_page: PageInfo {
3064            note: "tracked initial Firefox tab via native WebDriver BiDi".to_string(),
3065            page_session: PageSessionHandle::Firefox {
3066                browsing_context_id: initial_context_id,
3067            },
3068        },
3069    })
3070}
3071
3072fn create_firefox_user_data_dir() -> Result<PathBuf, String> {
3073    let timestamp = SystemTime::now()
3074        .duration_since(UNIX_EPOCH)
3075        .map_err(|error| format!("failed to compute timestamp: {error}"))?
3076        .as_millis();
3077    let pid = std::process::id();
3078    let dir = std::env::temp_dir().join(format!("allwright-firefox-{pid}-{timestamp}"));
3079    fs::create_dir_all(&dir).map_err(|error| {
3080        format!(
3081            "failed to create Firefox profile dir {}: {error}",
3082            dir.display()
3083        )
3084    })?;
3085    let user_js = dir.join("user.js");
3086    fs::write(&user_js, firefox_download_preferences()).map_err(|error| {
3087        format!(
3088            "failed to write Firefox automation preferences {}: {error}",
3089            user_js.display()
3090        )
3091    })?;
3092    Ok(dir)
3093}
3094
3095fn firefox_download_preferences() -> &'static str {
3096    r#"user_pref("browser.download.useDownloadDir", true);
3097user_pref("browser.download.always_ask_before_handling_new_types", false);
3098user_pref("browser.download.alwaysOpenPanel", false);
3099"#
3100}
3101
3102fn choose_loopback_port() -> Result<u16, String> {
3103    let listener = TcpListener::bind("127.0.0.1:0")
3104        .map_err(|error| format!("failed to reserve a loopback port for Firefox: {error}"))?;
3105    let port = listener
3106        .local_addr()
3107        .map_err(|error| format!("failed to read reserved loopback port: {error}"))?
3108        .port();
3109    drop(listener);
3110    Ok(port)
3111}
3112
3113async fn wait_for_bidi_endpoint(ws_url: &str) -> Result<BidiConnection, String> {
3114    let mut last_error = None;
3115    for _ in 0..100 {
3116        match BidiConnection::connect(ws_url).await {
3117            Ok(connection) => return Ok(connection),
3118            Err(error) => {
3119                last_error = Some(error);
3120                sleep(Duration::from_millis(50)).await;
3121            }
3122        }
3123    }
3124
3125    Err(format!(
3126        "timed out waiting for WebDriver BiDi endpoint {ws_url}: {}",
3127        last_error.unwrap_or_else(|| "unknown error".to_string())
3128    ))
3129}
3130
3131fn parse_selector<'a>(command_name: &str, selector: &'a str) -> Result<ParsedSelector<'a>, String> {
3132    let trimmed = selector.trim();
3133    if trimmed.is_empty() {
3134        return Err(format!(
3135            "{command_name} command requires a non-empty selector"
3136        ));
3137    }
3138
3139    if parse_explicit_selector_prefix(trimmed).is_some()
3140        && parse_selector_chain(command_name, trimmed)?.len() > 1
3141    {
3142        return Ok(ParsedSelector {
3143            value: Cow::Owned(format!(
3144                "{{\"chain\":{}}}",
3145                selector_segments_literal(command_name, trimmed)?
3146            )),
3147            kind: SelectorKind::Structured,
3148        });
3149    }
3150    if trimmed.to_ascii_lowercase().starts_with("aw=") {
3151        return Ok(ParsedSelector {
3152            value: parse_selector_value(&trimmed[3..])?,
3153            kind: SelectorKind::Structured,
3154        });
3155    }
3156    let lowercase = trimmed.to_ascii_lowercase();
3157    let parsed = if lowercase.starts_with("xpath=") {
3158        let value = parse_selector_value(&trimmed[6..])?;
3159        ParsedSelector {
3160            value,
3161            kind: SelectorKind::XPath,
3162        }
3163    } else if lowercase.starts_with("xpath:") {
3164        let value = parse_selector_value(&trimmed[6..])?;
3165        ParsedSelector {
3166            value,
3167            kind: SelectorKind::XPath,
3168        }
3169    } else if lowercase.starts_with("css=") {
3170        let value = parse_selector_value(&trimmed[4..])?;
3171        ParsedSelector {
3172            value,
3173            kind: SelectorKind::Css,
3174        }
3175    } else if lowercase.starts_with("css:") {
3176        let value = parse_selector_value(&trimmed[4..])?;
3177        ParsedSelector {
3178            value,
3179            kind: SelectorKind::Css,
3180        }
3181    } else if looks_like_xpath(trimmed) {
3182        ParsedSelector {
3183            value: Cow::Borrowed(trimmed),
3184            kind: SelectorKind::XPath,
3185        }
3186    } else {
3187        ParsedSelector {
3188            value: Cow::Borrowed(trimmed),
3189            kind: SelectorKind::Css,
3190        }
3191    };
3192
3193    if parsed.value.is_empty() {
3194        return Err(format!(
3195            "{command_name} command requires a non-empty selector"
3196        ));
3197    }
3198
3199    Ok(parsed)
3200}
3201
3202fn parse_selector_value(raw: &str) -> Result<Cow<'_, str>, String> {
3203    let trimmed = raw.trim();
3204    if trimmed.is_empty() {
3205        return Err("selector value must not be empty".to_string());
3206    }
3207    if trimmed.starts_with('"') {
3208        let end = find_json_string_end(trimmed)
3209            .ok_or_else(|| "selector value contains an unterminated JSON string".to_string())?;
3210        if trimmed[end..].trim().is_empty() {
3211            let decoded = serde_json::from_str::<String>(&trimmed[..end])
3212                .map_err(|error| format!("failed to decode selector JSON string: {error}"))?;
3213            return Ok(Cow::Owned(decoded));
3214        }
3215    }
3216    Ok(Cow::Borrowed(trimmed))
3217}
3218
3219fn find_json_string_end(value: &str) -> Option<usize> {
3220    let bytes = value.as_bytes();
3221    if bytes.first().copied()? != b'"' {
3222        return None;
3223    }
3224
3225    let mut index = 1usize;
3226    let mut escaped = false;
3227    while index < bytes.len() {
3228        let byte = bytes[index];
3229        if escaped {
3230            escaped = false;
3231            index += 1;
3232            continue;
3233        }
3234        match byte {
3235            b'\\' => escaped = true,
3236            b'"' => return Some(index + 1),
3237            _ => {}
3238        }
3239        index += 1;
3240    }
3241    None
3242}
3243
3244fn looks_like_xpath(selector: &str) -> bool {
3245    selector.starts_with("//")
3246        || selector.starts_with(".//")
3247        || selector.starts_with("../")
3248        || selector.starts_with('/')
3249        || selector.starts_with('(')
3250}
3251
3252fn parse_explicit_selector_prefix(selector: &str) -> Option<ExplicitSelectorPrefix> {
3253    let lower = selector.to_ascii_lowercase();
3254    if lower.starts_with("aw=") {
3255        return Some(ExplicitSelectorPrefix {
3256            kind: SelectorKind::Structured,
3257            prefix_len: 3,
3258        });
3259    }
3260    if lower.starts_with("xpath=") || lower.starts_with("xpath:") {
3261        return Some(ExplicitSelectorPrefix {
3262            kind: SelectorKind::XPath,
3263            prefix_len: 6,
3264        });
3265    }
3266    if lower.starts_with("css=") || lower.starts_with("css:") {
3267        return Some(ExplicitSelectorPrefix {
3268            kind: SelectorKind::Css,
3269            prefix_len: 4,
3270        });
3271    }
3272    None
3273}
3274
3275fn parse_selector_chain<'a>(
3276    command_name: &str,
3277    selector: &'a str,
3278) -> Result<Vec<ParsedSelector<'a>>, String> {
3279    let trimmed = selector.trim();
3280    if trimmed.is_empty() {
3281        return Err(format!(
3282            "{command_name} command requires a non-empty selector"
3283        ));
3284    }
3285
3286    let Some(first_prefix) = parse_explicit_selector_prefix(trimmed) else {
3287        return Ok(vec![parse_selector(command_name, selector)?]);
3288    };
3289
3290    let mut segments = Vec::new();
3291    let mut current_start = 0usize;
3292    let mut current_prefix = first_prefix;
3293    let bytes = trimmed.as_bytes();
3294    let mut scan = current_prefix.prefix_len;
3295    let mut in_json_string = false;
3296    let mut escaped = false;
3297
3298    while scan < bytes.len() {
3299        let byte = bytes[scan];
3300        if in_json_string {
3301            if escaped {
3302                escaped = false;
3303            } else if byte == b'\\' {
3304                escaped = true;
3305            } else if byte == b'"' {
3306                in_json_string = false;
3307            }
3308            scan += 1;
3309            continue;
3310        }
3311
3312        if byte == b'"' {
3313            in_json_string = true;
3314            scan += 1;
3315            continue;
3316        }
3317
3318        if byte.is_ascii_whitespace() {
3319            let next = scan + 1;
3320            if next < bytes.len()
3321                && let Some(prefix) = parse_explicit_selector_prefix(&trimmed[next..])
3322            {
3323                let value = parse_selector_value(
3324                    &trimmed[current_start + current_prefix.prefix_len..scan],
3325                )?;
3326                if value.is_empty() {
3327                    return Err(format!(
3328                        "{command_name} command requires a non-empty selector"
3329                    ));
3330                }
3331                segments.push(ParsedSelector {
3332                    value,
3333                    kind: current_prefix.kind,
3334                });
3335                current_start = next;
3336                current_prefix = prefix;
3337                scan = current_start + current_prefix.prefix_len;
3338                continue;
3339            }
3340        }
3341        scan += 1;
3342    }
3343
3344    let value = parse_selector_value(&trimmed[current_start + current_prefix.prefix_len..])?;
3345    if value.is_empty() {
3346        return Err(format!(
3347            "{command_name} command requires a non-empty selector"
3348        ));
3349    }
3350    segments.push(ParsedSelector {
3351        value,
3352        kind: current_prefix.kind,
3353    });
3354    Ok(segments)
3355}
3356
3357fn selector_kind_label(kind: SelectorKind) -> &'static str {
3358    match kind {
3359        SelectorKind::Structured => "semantic selector",
3360        SelectorKind::Css => "css selector",
3361        SelectorKind::XPath => "xpath selector",
3362    }
3363}
3364
3365fn decode_base64_field(value: &Value, pointer: &str, label: &str) -> Result<Vec<u8>, String> {
3366    let encoded = value
3367        .pointer(pointer)
3368        .and_then(Value::as_str)
3369        .ok_or_else(|| format!("{label} is missing from protocol response: {value}"))?;
3370    base64::engine::general_purpose::STANDARD
3371        .decode(encoded)
3372        .map_err(|error| format!("failed to decode {label}: {error}"))
3373}
3374
3375fn semantic_query_js(expression: &str) -> String {
3376    format!(
3377        "(() => {{\n{}\n{}\nreturn allwrightQuery({expression});\n}})()",
3378        include_str!("accessibility_semantics.js"),
3379        include_str!("selectors.js")
3380    )
3381}
3382
3383fn selector_query_all_js(kind: SelectorKind) -> String {
3384    let kind = match kind {
3385        SelectorKind::Css => "css",
3386        SelectorKind::XPath => "xpath",
3387        SelectorKind::Structured => "aw",
3388    };
3389    semantic_query_js(&format!("[{{kind: '{kind}', value: selector}}]"))
3390}
3391
3392fn selector_query_first_js(kind: SelectorKind) -> String {
3393    format!("({})[0]", selector_query_all_js(kind))
3394}
3395
3396fn selector_segments_literal(command_name: &str, selector: &str) -> Result<String, String> {
3397    let segments = parse_selector_chain(command_name, selector)?;
3398    let serialized = segments
3399        .into_iter()
3400        .enumerate()
3401        .map(|(index, segment)| {
3402            let value = if index > 0 && segment.kind == SelectorKind::XPath {
3403                if segment.value.starts_with("//") {
3404                    format!(".{}", segment.value)
3405                } else if segment.value.starts_with('/') {
3406                    format!(".{}", segment.value)
3407                } else if segment.value.starts_with('.') {
3408                    segment.value.to_string()
3409                } else {
3410                    format!(".//{}", segment.value)
3411                }
3412            } else {
3413                segment.value.to_string()
3414            };
3415            json!({
3416                "kind": match segment.kind {
3417                    SelectorKind::Structured => "aw",
3418                    SelectorKind::Css => "css",
3419                    SelectorKind::XPath => "xpath",
3420                },
3421                "value": value,
3422            })
3423        })
3424        .collect::<Vec<_>>();
3425    serde_json::to_string(&serialized)
3426        .map_err(|error| format!("failed to serialize selector chain: {error}"))
3427}
3428
3429fn selector_chain_kind_label(command_name: &str, selector: &str) -> Result<String, String> {
3430    let segments = parse_selector_chain(command_name, selector)?;
3431    if segments.len() == 1 {
3432        Ok(selector_kind_label(segments[0].kind).to_string())
3433    } else {
3434        Ok("selector chain".to_string())
3435    }
3436}
3437
3438fn selector_chain_query_all_js() -> String {
3439    semantic_query_js("selectorSegments")
3440}
3441
3442fn firefox_session_registry() -> &'static Mutex<HashMap<String, FirefoxSessionState>> {
3443    FIREFOX_SESSION_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
3444}
3445
3446fn file_chooser_hook_registry() -> &'static Mutex<HashMap<String, FileChooserHookState>> {
3447    FILE_CHOOSER_HOOK_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
3448}
3449
3450fn file_chooser_registry() -> &'static Mutex<HashMap<String, FileChooserState>> {
3451    FILE_CHOOSER_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
3452}
3453
3454fn download_hook_registry() -> &'static Mutex<HashMap<String, DownloadHookState>> {
3455    DOWNLOAD_HOOK_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
3456}
3457
3458fn download_registry() -> &'static Mutex<HashMap<String, DownloadState>> {
3459    DOWNLOAD_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
3460}
3461
3462fn next_file_chooser_id(prefix: &str) -> String {
3463    format!(
3464        "{prefix}-{}",
3465        FILE_CHOOSER_COUNTER.fetch_add(1, Ordering::Relaxed)
3466    )
3467}
3468
3469fn next_download_id(prefix: &str) -> String {
3470    format!(
3471        "{prefix}-{}",
3472        DOWNLOAD_COUNTER.fetch_add(1, Ordering::Relaxed)
3473    )
3474}
3475
3476fn create_download_dir() -> Result<PathBuf, String> {
3477    let dir = std::env::temp_dir().join(next_download_id("allwright-downloads"));
3478    fs::create_dir_all(&dir).map_err(|error| {
3479        format!(
3480            "failed to create download directory {}: {error}",
3481            dir.display()
3482        )
3483    })?;
3484    fs::canonicalize(&dir).map_err(|error| {
3485        format!(
3486            "failed to resolve download directory {}: {error}",
3487            dir.display()
3488        )
3489    })
3490}
3491
3492async fn firefox_session_guard(
3493    connection_id: &str,
3494) -> Result<MutexGuard<'static, HashMap<String, FirefoxSessionState>>, String> {
3495    let registry = firefox_session_registry();
3496    let sessions = registry.lock().await;
3497    if sessions.contains_key(connection_id) {
3498        Ok(sessions)
3499    } else {
3500        Err(format!(
3501            "Firefox automation session {connection_id} is not available"
3502        ))
3503    }
3504}
3505
3506async fn firefox_evaluate_json(
3507    bidi: &mut BidiConnection,
3508    browsing_context_id: &str,
3509    expression: &str,
3510) -> Result<Value, String> {
3511    let result = bidi
3512        .send_command(
3513            "script.evaluate",
3514            json!({
3515                "expression": expression,
3516                "target": {
3517                    "context": browsing_context_id,
3518                },
3519                "awaitPromise": true,
3520            }),
3521        )
3522        .await?;
3523    let result_type = json_string(&result, "/result/type")?;
3524    if result_type != "success" {
3525        let details = result
3526            .pointer("/result/exceptionDetails")
3527            .cloned()
3528            .unwrap_or(Value::Null);
3529        return Err(format!("Firefox script.evaluate failed: {details}"));
3530    }
3531    let value = json_string(&result, "/result/result/value")?;
3532    serde_json::from_str::<Value>(&value)
3533        .map_err(|error| format!("failed to parse Firefox script result JSON: {error}"))
3534}
3535
3536async fn firefox_evaluate_void(
3537    bidi: &mut BidiConnection,
3538    browsing_context_id: &str,
3539    expression: &str,
3540) -> Result<(), String> {
3541    let response = bidi.send_command_with_dialogs("script.evaluate", json!({
3542        "expression": expression, "target": {"context": browsing_context_id}, "awaitPromise": true,
3543    }), true).await?;
3544    check_script_action_result(&response)
3545}
3546
3547fn check_script_action_result(response: &Value) -> Result<(), String> {
3548    if json_string(response, "/result/type")? != "success" {
3549        return Err(format!(
3550            "BiDi script action failed: {}",
3551            response
3552                .pointer("/result/exceptionDetails")
3553                .unwrap_or(&Value::Null)
3554        ));
3555    }
3556    Ok(())
3557}
3558
3559async fn firefox_evaluate_u32(
3560    bidi: &mut BidiConnection,
3561    browsing_context_id: &str,
3562    expression: &str,
3563) -> Result<u32, String> {
3564    let value = firefox_evaluate_json(
3565        bidi,
3566        browsing_context_id,
3567        &format!("JSON.stringify({expression})"),
3568    )
3569    .await?;
3570    if let Some(number) = value.as_u64() {
3571        return u32::try_from(number)
3572            .map_err(|_| format!("Firefox script result exceeds u32: {number}"));
3573    }
3574    if let Some(number) = value.as_i64() {
3575        return u32::try_from(number)
3576            .map_err(|_| format!("Firefox script result is out of range for u32: {number}"));
3577    }
3578    Err(format!(
3579        "expected numeric Firefox script result, found {value}"
3580    ))
3581}
3582
3583async fn firefox_evaluate_bool(
3584    bidi: &mut BidiConnection,
3585    browsing_context_id: &str,
3586    expression: &str,
3587) -> Result<bool, String> {
3588    let value = firefox_evaluate_json(
3589        bidi,
3590        browsing_context_id,
3591        &format!("JSON.stringify({expression})"),
3592    )
3593    .await?;
3594    value
3595        .as_bool()
3596        .ok_or_else(|| format!("expected boolean Firefox script result, found {value}"))
3597}
3598
3599async fn firefox_get_text(
3600    connection_id: &str,
3601    browsing_context_id: &str,
3602    css_selector: &str,
3603    property: &str,
3604) -> Result<TextInfo, String> {
3605    let parsed = parse_selector("get_text", css_selector)?;
3606    let mut sessions = firefox_session_guard(connection_id).await?;
3607    let bidi = &mut sessions
3608        .get_mut(connection_id)
3609        .expect("Firefox session guard validated connection id")
3610        .connection;
3611    let query_first_js = selector_query_first_js(parsed.kind);
3612    let value = firefox_evaluate_json(
3613        bidi,
3614        browsing_context_id,
3615        &format!(
3616            "JSON.stringify((() => {{
3617                const selector = {};
3618                const property = {};
3619                const element = {query_first_js};
3620                if (!element) {{
3621                    throw new Error(`No element matches {}: ${{selector}}`);
3622                }}
3623                const value = element[property];
3624                return typeof value === 'string' ? value : '';
3625            }})())",
3626            json_string_literal(&parsed.value, "text selector")?,
3627            json_string_literal(property, "text property")?,
3628            selector_kind_label(parsed.kind),
3629        ),
3630    )
3631    .await?;
3632
3633    Ok(TextInfo {
3634        css_selector: css_selector.to_string(),
3635        text: value.as_str().map(str::to_string).unwrap_or_default(),
3636        note: format!(
3637            "resolved {property} for {} {}",
3638            selector_kind_label(parsed.kind),
3639            parsed.value
3640        ),
3641    })
3642}
3643
3644pub async fn boot() -> String {
3645    sleep(Duration::from_millis(15)).await;
3646    "web ready".to_string()
3647}
3648
3649#[cfg(unix)]
3650fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
3651    let status = Command::new("kill")
3652        .args(["-TERM", &process_id.to_string()])
3653        .status()
3654        .map_err(|error| format!("failed to send TERM to process {process_id}: {error}"))?;
3655
3656    if status.success() {
3657        Ok(())
3658    } else {
3659        Err(format!(
3660            "kill -TERM returned non-zero exit status for process {process_id}: {status}"
3661        ))
3662    }
3663}
3664
3665#[cfg(windows)]
3666fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
3667    let status = Command::new("taskkill")
3668        .args(["/PID", &process_id.to_string(), "/T", "/F"])
3669        .status()
3670        .map_err(|error| format!("failed to terminate process {process_id}: {error}"))?;
3671
3672    if status.success() {
3673        Ok(())
3674    } else {
3675        Err(format!(
3676            "taskkill returned non-zero exit status for process {process_id}: {status}"
3677        ))
3678    }
3679}
3680
3681#[cfg(not(any(unix, windows)))]
3682fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
3683    Err(format!(
3684        "browser process termination is not implemented for process {process_id} on this platform"
3685    ))
3686}
3687
3688type CdpSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
3689type BidiSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
3690
3691struct MapperConnectionInfo {
3692    package_version: String,
3693    mapper_target_id: String,
3694    mapper_session_id: String,
3695}
3696
3697struct CdpConnection {
3698    websocket_url: String,
3699    socket: CdpSocket,
3700    next_id: u64,
3701    pending_bidi_events: VecDeque<Value>,
3702}
3703
3704impl CdpConnection {
3705    async fn connect(cdp_websocket_url: &str) -> Result<Self, String> {
3706        let (socket, _) = connect_async(cdp_websocket_url).await.map_err(|error| {
3707            format!("failed to connect to CDP websocket {cdp_websocket_url}: {error}")
3708        })?;
3709        Ok(Self {
3710            websocket_url: cdp_websocket_url.to_string(),
3711            socket,
3712            next_id: 1,
3713            pending_bidi_events: VecDeque::new(),
3714        })
3715    }
3716
3717    async fn send_command(
3718        &mut self,
3719        method: &str,
3720        params: Value,
3721        session_id: Option<&str>,
3722    ) -> Result<Value, String> {
3723        let id = self.next_id;
3724        self.next_id += 1;
3725
3726        let mut payload = json!({
3727            "id": id,
3728            "method": method,
3729            "params": params,
3730        });
3731        if let Some(session_id) = session_id {
3732            payload["sessionId"] = Value::String(session_id.to_string());
3733        }
3734
3735        self.socket
3736            .send(Message::Text(payload.to_string().into()))
3737            .await
3738            .map_err(|error| format!("failed to send CDP command {method}: {error}"))?;
3739
3740        loop {
3741            let message = self.next_json_message().await?;
3742            if message.get("id").and_then(Value::as_u64) != Some(id) {
3743                continue;
3744            }
3745
3746            if let Some(error) = message.get("error") {
3747                return Err(format!("CDP command {method} failed: {error}"));
3748            }
3749
3750            return Ok(message.get("result").cloned().unwrap_or(Value::Null));
3751        }
3752    }
3753
3754    async fn evaluate_expression(
3755        &mut self,
3756        session_id: &str,
3757        expression: &str,
3758        await_promise: bool,
3759    ) -> Result<Value, String> {
3760        let result = self
3761            .send_command(
3762                "Runtime.evaluate",
3763                json!({
3764                    "expression": expression,
3765                    "awaitPromise": await_promise,
3766                    "returnByValue": true,
3767                }),
3768                Some(session_id),
3769            )
3770            .await?;
3771        if let Some(exception_details) = result.get("exceptionDetails") {
3772            return Err(format_runtime_exception_message(exception_details));
3773        }
3774        Ok(result)
3775    }
3776
3777    async fn ensure_chromium_bidi_mapper(
3778        &mut self,
3779        existing_mapper_target_id: Option<&str>,
3780    ) -> Result<MapperConnectionInfo, String> {
3781        let (mut mapper_target_id, mut created_target) = match existing_mapper_target_id {
3782            Some(existing_mapper_target_id) if !existing_mapper_target_id.trim().is_empty() => {
3783                (existing_mapper_target_id.to_string(), false)
3784            }
3785            _ => (self.create_bidi_mapper_target().await?, true),
3786        };
3787
3788        let attached = match self
3789            .send_command(
3790                "Target.attachToTarget",
3791                json!({
3792                    "targetId": mapper_target_id,
3793                    "flatten": true,
3794                }),
3795                None,
3796            )
3797            .await
3798        {
3799            Ok(attached) => attached,
3800            Err(error)
3801                if !created_target
3802                    && error.contains("Target.attachToTarget failed")
3803                    && error.contains("No target with given id found") =>
3804            {
3805                mapper_target_id = self.create_bidi_mapper_target().await?;
3806                created_target = true;
3807                self.send_command(
3808                    "Target.attachToTarget",
3809                    json!({
3810                        "targetId": mapper_target_id,
3811                        "flatten": true,
3812                    }),
3813                    None,
3814                )
3815                .await?
3816            }
3817            Err(error) => return Err(error),
3818        };
3819        let mapper_session_id = required_string(&attached, "/sessionId")?;
3820
3821        self.send_command("Runtime.enable", json!({}), Some(&mapper_session_id))
3822            .await?;
3823        self.ensure_runtime_binding(&mapper_session_id, "sendBidiResponse")
3824            .await?;
3825
3826        if created_target
3827            || !self
3828                .mapper_runtime_is_initialized(&mapper_session_id)
3829                .await?
3830        {
3831            self.send_command(
3832                "Target.exposeDevToolsProtocol",
3833                json!({
3834                    "bindingName": "cdp",
3835                    "targetId": mapper_target_id,
3836                    "inheritPermissions": true,
3837                }),
3838                None,
3839            )
3840            .await?;
3841            self.evaluate_expression(&mapper_session_id, CHROMIUM_BIDI_MAPPER_BUNDLE, false)
3842                .await?;
3843            self.evaluate_expression(
3844                &mapper_session_id,
3845                &format!(
3846                    "window.runMapperInstance('{}')",
3847                    js_single_quote(&mapper_target_id)
3848                ),
3849                true,
3850            )
3851            .await?;
3852            self.send_bidi_command(
3853                &mapper_session_id,
3854                &json!({
3855                    "id": 1,
3856                    "method": "session.new",
3857                    "params": {
3858                        "capabilities": {
3859                            "alwaysMatch": {
3860                                "unhandledPromptBehavior": suppressed_file_dialog_behavior()
3861                            }
3862                        }
3863                    }
3864                }),
3865            )
3866            .await?;
3867        }
3868
3869        Ok(MapperConnectionInfo {
3870            package_version: CHROMIUM_BIDI_NPM_VERSION.to_string(),
3871            mapper_target_id,
3872            mapper_session_id,
3873        })
3874    }
3875
3876    async fn create_bidi_mapper_target(&mut self) -> Result<String, String> {
3877        // Chromium destroys hidden targets when their creating CDP connection closes.
3878        // Keep that connection alive for the mapper's lifetime, independently of commands.
3879        let mut owner = CdpConnection::connect(&self.websocket_url).await?;
3880        let created = owner
3881            .send_command(
3882                "Target.createTarget",
3883                json!({
3884                    "url": "about:blank#MAPPER_TARGET", "hidden": true, "background": true,
3885                }),
3886                None,
3887            )
3888            .await?;
3889        let target = required_string(&created, "/targetId")?;
3890        tokio::spawn(async move { while owner.next_json_message().await.is_ok() {} });
3891        Ok(target)
3892    }
3893
3894    async fn ensure_runtime_binding(&mut self, session_id: &str, name: &str) -> Result<(), String> {
3895        match self
3896            .send_command(
3897                "Runtime.addBinding",
3898                json!({
3899                    "name": name,
3900                }),
3901                Some(session_id),
3902            )
3903            .await
3904        {
3905            Ok(_) => Ok(()),
3906            Err(error)
3907                if error.contains("already exists") || error.contains("Cannot add binding") =>
3908            {
3909                Ok(())
3910            }
3911            Err(error) => Err(error),
3912        }
3913    }
3914
3915    async fn mapper_runtime_is_initialized(&mut self, session_id: &str) -> Result<bool, String> {
3916        let result = self
3917            .evaluate_expression(
3918                session_id,
3919                "typeof window.runMapperInstance === 'function' && typeof window.onBidiMessage === 'function'",
3920                true,
3921            )
3922            .await?;
3923        Ok(result
3924            .pointer("/result/value")
3925            .and_then(Value::as_bool)
3926            .unwrap_or(false))
3927    }
3928
3929    async fn send_bidi_command(
3930        &mut self,
3931        mapper_session_id: &str,
3932        command: &Value,
3933    ) -> Result<Value, String> {
3934        self.send_bidi_command_with_dialogs(mapper_session_id, command, false)
3935            .await
3936    }
3937
3938    async fn send_bidi_command_with_dialogs(
3939        &mut self,
3940        mapper_session_id: &str,
3941        command: &Value,
3942        script_action: bool,
3943    ) -> Result<Value, String> {
3944        let command_id = command
3945            .get("id")
3946            .and_then(Value::as_u64)
3947            .ok_or_else(|| "BiDi command is missing numeric id".to_string())?;
3948        let eval_id = self.next_id;
3949        self.next_id += 1;
3950        let command_json = command.to_string();
3951        let expression = format!(
3952            "onBidiMessage({})",
3953            serde_json::to_string(&command_json)
3954                .map_err(|error| format!("failed to encode BiDi command payload: {error}"))?
3955        );
3956
3957        let payload = json!({
3958            "id": eval_id,
3959            "method": "Runtime.evaluate",
3960            "params": {
3961                "expression": expression,
3962                "awaitPromise": true,
3963            },
3964            "sessionId": mapper_session_id,
3965        });
3966        self.socket
3967            .send(Message::Text(payload.to_string().into()))
3968            .await
3969            .map_err(|error| format!("failed to send mapper Runtime.evaluate call: {error}"))?;
3970
3971        loop {
3972            let message = self.next_json_message().await?;
3973
3974            if message.get("id").and_then(Value::as_u64) == Some(eval_id) {
3975                if let Some(error) = message.get("error") {
3976                    return Err(format!("mapper Runtime.evaluate failed: {error}"));
3977                }
3978                if let Some(exception_details) = message.pointer("/result/exceptionDetails") {
3979                    return Err(format_mapper_runtime_exception_message(exception_details));
3980                }
3981                continue;
3982            }
3983
3984            if message.get("sessionId").and_then(Value::as_str) != Some(mapper_session_id) {
3985                continue;
3986            }
3987
3988            if message.get("method").and_then(Value::as_str) != Some("Runtime.bindingCalled") {
3989                continue;
3990            }
3991
3992            let params = message
3993                .get("params")
3994                .ok_or_else(|| "Runtime.bindingCalled message is missing params".to_string())?;
3995            if params.get("name").and_then(Value::as_str) != Some("sendBidiResponse") {
3996                continue;
3997            }
3998
3999            let payload = params
4000                .get("payload")
4001                .and_then(Value::as_str)
4002                .ok_or_else(|| "Runtime.bindingCalled payload is missing".to_string())?;
4003            let response = serde_json::from_str::<Value>(payload)
4004                .map_err(|error| format!("failed to parse BiDi mapper response JSON: {error}"))?;
4005            if response.get("id").and_then(Value::as_u64) != Some(command_id) {
4006                // Input dispatch can remain pending while a modal prompt is open.
4007                // Yield at that boundary so the sequential context stream can handle it.
4008                let interrupted =
4009                    dialogs::interrupted_action_result(command, &response, script_action);
4010                if response.get("method").is_some() {
4011                    self.pending_bidi_events.push_back(response);
4012                }
4013                if let Some(result) = interrupted {
4014                    return Ok(result);
4015                }
4016                continue;
4017            }
4018
4019            match response.get("type").and_then(Value::as_str) {
4020                Some("success") => return Ok(response),
4021                Some("error") => {
4022                    let message = response
4023                        .get("message")
4024                        .and_then(Value::as_str)
4025                        .unwrap_or("unknown BiDi mapper error");
4026                    let error = response
4027                        .get("error")
4028                        .and_then(Value::as_str)
4029                        .unwrap_or("unknown error");
4030                    return Err(format!("BiDi mapper returned {error}: {message}"));
4031                }
4032                Some(other) => {
4033                    return Err(format!("unexpected BiDi response type {other}: {response}"));
4034                }
4035                None => {
4036                    return Err(format!("BiDi mapper response is missing type: {response}"));
4037                }
4038            }
4039        }
4040    }
4041
4042    async fn poll_bidi_event(
4043        &mut self,
4044        mapper_session_id: &str,
4045        method: &str,
4046    ) -> Result<Option<Value>, String> {
4047        self.poll_bidi_event_for_context(mapper_session_id, method, None)
4048            .await
4049    }
4050
4051    async fn poll_bidi_event_for_context(
4052        &mut self,
4053        mapper_session_id: &str,
4054        method: &str,
4055        context: Option<&str>,
4056    ) -> Result<Option<Value>, String> {
4057        let matches = |event: &Value| {
4058            event.get("method").and_then(Value::as_str) == Some(method)
4059                && context.is_none_or(|id| {
4060                    event.pointer("/params/context").and_then(Value::as_str) == Some(id)
4061                })
4062        };
4063        if let Some(index) = self.pending_bidi_events.iter().position(matches) {
4064            return Ok(self.pending_bidi_events.remove(index));
4065        }
4066
4067        let message = match timeout(Duration::from_millis(10), self.next_json_message()).await {
4068            Ok(result) => result?,
4069            Err(_) => return Ok(None),
4070        };
4071        if message.get("sessionId").and_then(Value::as_str) != Some(mapper_session_id)
4072            || message.get("method").and_then(Value::as_str) != Some("Runtime.bindingCalled")
4073            || message.pointer("/params/name").and_then(Value::as_str) != Some("sendBidiResponse")
4074        {
4075            return Ok(None);
4076        }
4077        let payload = message
4078            .pointer("/params/payload")
4079            .and_then(Value::as_str)
4080            .ok_or_else(|| "Runtime.bindingCalled payload is missing".to_string())?;
4081        let event = serde_json::from_str::<Value>(payload)
4082            .map_err(|error| format!("failed to parse BiDi mapper event JSON: {error}"))?;
4083        if matches(&event) {
4084            Ok(Some(event))
4085        } else {
4086            if event.get("method").is_some() {
4087                self.pending_bidi_events.push_back(event);
4088            }
4089            Ok(None)
4090        }
4091    }
4092
4093    async fn resolve_bidi_context_id(
4094        &mut self,
4095        mapper_session_id: &str,
4096        existing_context_id: Option<&str>,
4097        current_url: Option<&str>,
4098    ) -> Result<String, String> {
4099        if let Some(existing_context_id) = existing_context_id {
4100            if !existing_context_id.trim().is_empty() {
4101                let probe = json!({
4102                    "id": 1,
4103                    "method": "browsingContext.getTree",
4104                    "params": {
4105                        "root": existing_context_id,
4106                        "maxDepth": 0,
4107                    }
4108                });
4109                match self.send_bidi_command(mapper_session_id, &probe).await {
4110                    Ok(_) => return Ok(existing_context_id.to_string()),
4111                    Err(error) => return Err(error),
4112                }
4113            }
4114        }
4115
4116        let target_url = current_url
4117            .map(str::trim)
4118            .filter(|value| !value.is_empty())
4119            .map(str::to_string);
4120
4121        for attempt in 0..20 {
4122            let tree = self
4123                .send_bidi_command(
4124                    mapper_session_id,
4125                    &json!({
4126                        "id": 2,
4127                        "method": "browsingContext.getTree",
4128                        "params": {
4129                            "maxDepth": 0,
4130                        }
4131                    }),
4132                )
4133                .await?;
4134            let contexts = tree
4135                .pointer("/result/contexts")
4136                .and_then(Value::as_array)
4137                .ok_or_else(|| {
4138                    format!("BiDi browsingContext.getTree returned no contexts: {tree}")
4139                })?;
4140
4141            if let Some(target_url) = target_url.as_deref() {
4142                if let Some(context_id) = contexts.iter().find_map(|context| {
4143                    let url = context.get("url").and_then(Value::as_str)?;
4144                    if url == target_url {
4145                        context
4146                            .get("context")
4147                            .and_then(Value::as_str)
4148                            .map(str::to_string)
4149                    } else {
4150                        None
4151                    }
4152                }) {
4153                    return Ok(context_id);
4154                }
4155            }
4156
4157            if let Some(context_id) = contexts
4158                .first()
4159                .and_then(|context| context.get("context"))
4160                .and_then(Value::as_str)
4161                .filter(|value| !value.trim().is_empty())
4162                .map(str::to_string)
4163            {
4164                return Ok(context_id);
4165            }
4166
4167            if attempt < 19 {
4168                sleep(Duration::from_millis(150)).await;
4169            } else {
4170                return Err(format!(
4171                    "BiDi browsingContext.getTree returned no usable context ids after retries: {tree}"
4172                ));
4173            }
4174        }
4175
4176        Err("BiDi browsing context resolution exhausted retries unexpectedly".to_string())
4177    }
4178
4179    async fn next_json_message(&mut self) -> Result<Value, String> {
4180        loop {
4181            let message = self
4182                .socket
4183                .next()
4184                .await
4185                .ok_or_else(|| "CDP websocket closed unexpectedly".to_string())?
4186                .map_err(|error| format!("failed to read CDP websocket message: {error}"))?;
4187
4188            match message {
4189                Message::Text(text) => {
4190                    let value = serde_json::from_str::<Value>(&text)
4191                        .map_err(|error| format!("failed to parse CDP JSON message: {error}"))?;
4192                    return Ok(value);
4193                }
4194                Message::Binary(bytes) => {
4195                    let value = serde_json::from_slice::<Value>(&bytes)
4196                        .map_err(|error| format!("failed to parse CDP JSON message: {error}"))?;
4197                    return Ok(value);
4198                }
4199                Message::Ping(payload) => {
4200                    self.socket
4201                        .send(Message::Pong(payload))
4202                        .await
4203                        .map_err(|error| format!("failed to reply to CDP ping: {error}"))?;
4204                }
4205                Message::Pong(_) => {}
4206                Message::Frame(_) => {}
4207                Message::Close(frame) => {
4208                    return Err(format!("CDP websocket closed: {frame:?}"));
4209                }
4210            }
4211        }
4212    }
4213}
4214
4215struct BidiConnection {
4216    frame_contexts: std::collections::HashSet<String>,
4217    socket: BidiSocket,
4218    next_id: u64,
4219    pending_events: VecDeque<Value>,
4220}
4221
4222impl BidiConnection {
4223    async fn connect(bidi_websocket_url: &str) -> Result<Self, String> {
4224        let (socket, _) = connect_async(bidi_websocket_url).await.map_err(|error| {
4225            format!("failed to connect to WebDriver BiDi websocket {bidi_websocket_url}: {error}")
4226        })?;
4227        Ok(Self {
4228            frame_contexts: std::collections::HashSet::new(),
4229            socket,
4230            next_id: 1,
4231            pending_events: VecDeque::new(),
4232        })
4233    }
4234
4235    async fn send_command(&mut self, method: &str, params: Value) -> Result<Value, String> {
4236        self.send_command_with_dialogs(method, params, false).await
4237    }
4238
4239    async fn send_command_with_dialogs(
4240        &mut self,
4241        method: &str,
4242        params: Value,
4243        script_action: bool,
4244    ) -> Result<Value, String> {
4245        if let Some(context) = params
4246            .pointer("/target/context")
4247            .or_else(|| params.get("context"))
4248            .and_then(Value::as_str)
4249        {
4250            if self.frame_contexts.contains(context) {
4251                // Firefox can retain script realms in the back/forward cache after a
4252                // frame leaves the active document. Only active tree members are pages.
4253                let tree = self
4254                    .send_command_raw("browsingContext.getTree", json!({}))
4255                    .await?;
4256                if !frames::tree_contains_context(&tree, context) {
4257                    return Err(format!("frame browsing context {context} is detached"));
4258                }
4259            }
4260        }
4261        self.send_command_raw_with_dialogs(method, params, script_action)
4262            .await
4263    }
4264
4265    async fn send_command_raw(&mut self, method: &str, params: Value) -> Result<Value, String> {
4266        self.send_command_raw_with_dialogs(method, params, false)
4267            .await
4268    }
4269
4270    async fn send_command_raw_with_dialogs(
4271        &mut self,
4272        method: &str,
4273        params: Value,
4274        script_action: bool,
4275    ) -> Result<Value, String> {
4276        let id = self.next_id;
4277        self.next_id += 1;
4278        let payload = json!({
4279            "id": id,
4280            "method": method,
4281            "params": params,
4282        });
4283        self.socket
4284            .send(Message::Text(payload.to_string().into()))
4285            .await
4286            .map_err(|error| format!("failed to send WebDriver BiDi command {method}: {error}"))?;
4287
4288        loop {
4289            let message = self.next_json_message().await?;
4290            if message.get("id").and_then(Value::as_u64) != Some(id) {
4291                let interrupted =
4292                    dialogs::interrupted_action_result(&payload, &message, script_action);
4293                if message.get("method").is_some() {
4294                    self.pending_events.push_back(message);
4295                }
4296                if let Some(result) = interrupted {
4297                    return Ok(result);
4298                }
4299                continue;
4300            }
4301
4302            if let Some(error) = message.get("error").and_then(Value::as_str) {
4303                let message_text = message
4304                    .get("message")
4305                    .and_then(Value::as_str)
4306                    .unwrap_or("unknown WebDriver BiDi error");
4307                return Err(format!(
4308                    "WebDriver BiDi command {method} returned {error}: {message_text}"
4309                ));
4310            }
4311
4312            return Ok(message);
4313        }
4314    }
4315
4316    async fn poll_event(&mut self, method: &str) -> Result<Option<Value>, String> {
4317        self.poll_event_for_context(method, None).await
4318    }
4319
4320    async fn poll_event_for_context(
4321        &mut self,
4322        method: &str,
4323        context: Option<&str>,
4324    ) -> Result<Option<Value>, String> {
4325        let matches = |event: &Value| {
4326            event.get("method").and_then(Value::as_str) == Some(method)
4327                && context.is_none_or(|id| {
4328                    event.pointer("/params/context").and_then(Value::as_str) == Some(id)
4329                })
4330        };
4331        if let Some(index) = self.pending_events.iter().position(matches) {
4332            return Ok(self.pending_events.remove(index));
4333        }
4334        match timeout(Duration::from_millis(10), self.next_json_message()).await {
4335            Ok(Ok(event)) if matches(&event) => Ok(Some(event)),
4336            Ok(Ok(event)) => {
4337                self.pending_events.push_back(event);
4338                Ok(None)
4339            }
4340            Ok(Err(error)) => Err(error),
4341            Err(_) => Ok(None),
4342        }
4343    }
4344
4345    async fn wait_for_top_level_context(&mut self) -> Result<String, String> {
4346        for _ in 0..100 {
4347            let tree = self
4348                .send_command(
4349                    "browsingContext.getTree",
4350                    json!({
4351                        "maxDepth": 0,
4352                    }),
4353                )
4354                .await?;
4355            if let Some(context_id) = tree
4356                .pointer("/result/contexts")
4357                .and_then(Value::as_array)
4358                .and_then(|contexts| contexts.first())
4359                .and_then(|context| context.get("context"))
4360                .and_then(Value::as_str)
4361                .filter(|value| !value.trim().is_empty())
4362            {
4363                return Ok(context_id.to_string());
4364            }
4365            sleep(Duration::from_millis(50)).await;
4366        }
4367
4368        Err("timed out waiting for an initial Firefox browsing context".to_string())
4369    }
4370
4371    async fn next_json_message(&mut self) -> Result<Value, String> {
4372        loop {
4373            let message = self
4374                .socket
4375                .next()
4376                .await
4377                .ok_or_else(|| "WebDriver BiDi websocket closed unexpectedly".to_string())?
4378                .map_err(|error| {
4379                    format!("failed to read WebDriver BiDi websocket message: {error}")
4380                })?;
4381
4382            match message {
4383                Message::Text(text) => {
4384                    let value = serde_json::from_str::<Value>(&text).map_err(|error| {
4385                        format!("failed to parse WebDriver BiDi JSON message: {error}")
4386                    })?;
4387                    return Ok(value);
4388                }
4389                Message::Binary(bytes) => {
4390                    let value = serde_json::from_slice::<Value>(&bytes).map_err(|error| {
4391                        format!("failed to parse WebDriver BiDi JSON message: {error}")
4392                    })?;
4393                    return Ok(value);
4394                }
4395                Message::Ping(payload) => {
4396                    self.socket
4397                        .send(Message::Pong(payload))
4398                        .await
4399                        .map_err(|error| {
4400                            format!("failed to reply to WebDriver BiDi ping: {error}")
4401                        })?;
4402                }
4403                Message::Pong(_) => {}
4404                Message::Frame(_) => {}
4405                Message::Close(frame) => {
4406                    return Err(format!("WebDriver BiDi websocket closed: {frame:?}"));
4407                }
4408            }
4409        }
4410    }
4411}
4412
4413fn required_string(value: &Value, pointer: &str) -> Result<String, String> {
4414    value
4415        .pointer(pointer)
4416        .and_then(Value::as_str)
4417        .map(str::to_string)
4418        .filter(|value| !value.is_empty())
4419        .ok_or_else(|| format!("missing string field at JSON pointer {pointer}"))
4420}
4421
4422fn download_event_id(event: &Value) -> Result<String, String> {
4423    event
4424        .pointer("/params/download")
4425        .or_else(|| event.pointer("/params/navigation"))
4426        .and_then(Value::as_str)
4427        .filter(|value| !value.is_empty())
4428        .map(str::to_string)
4429        .ok_or_else(|| "download event is missing its download identifier".to_string())
4430}
4431
4432fn js_single_quote(value: &str) -> String {
4433    value.replace('\\', "\\\\").replace('\'', "\\'")
4434}
4435
4436fn json_u32(value: &Value, pointer: &str) -> Result<u32, String> {
4437    let raw = value
4438        .pointer(pointer)
4439        .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
4440
4441    if let Some(number) = raw.as_u64() {
4442        return u32::try_from(number)
4443            .map_err(|_| format!("numeric JSON value at {pointer} exceeds u32: {number}"));
4444    }
4445
4446    if let Some(number) = raw.as_i64() {
4447        return u32::try_from(number).map_err(|_| {
4448            format!("numeric JSON value at {pointer} is out of range for u32: {number}")
4449        });
4450    }
4451
4452    Err(format!(
4453        "expected numeric JSON value at {pointer}, found {raw}"
4454    ))
4455}
4456
4457fn json_f64(value: &Value, pointer: &str) -> Result<f64, String> {
4458    let raw = value
4459        .pointer(pointer)
4460        .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
4461
4462    raw.as_f64()
4463        .ok_or_else(|| format!("expected numeric JSON value at {pointer}, found {raw}"))
4464}
4465
4466fn json_string(value: &Value, pointer: &str) -> Result<String, String> {
4467    value
4468        .pointer(pointer)
4469        .and_then(Value::as_str)
4470        .map(str::to_string)
4471        .ok_or_else(|| format!("expected string JSON value at {pointer}"))
4472}
4473
4474fn json_string_literal(value: &str, context: &str) -> Result<String, String> {
4475    serde_json::to_string(value).map_err(|error| format!("failed to serialize {context}: {error}"))
4476}
4477
4478fn format_runtime_exception_message(exception_details: &Value) -> String {
4479    format_exception_message(
4480        "Runtime.evaluate failed",
4481        exception_details,
4482        "Runtime.evaluate failed with exception details",
4483    )
4484}
4485
4486fn format_mapper_runtime_exception_message(exception_details: &Value) -> String {
4487    format_exception_message(
4488        "mapper Runtime.evaluate failed",
4489        exception_details,
4490        "mapper Runtime.evaluate raised exception details",
4491    )
4492}
4493
4494fn format_exception_message(
4495    prefix: &str,
4496    exception_details: &Value,
4497    fallback_prefix: &str,
4498) -> String {
4499    let message = exception_details
4500        .pointer("/exception/preview/properties")
4501        .and_then(Value::as_array)
4502        .and_then(|properties| {
4503            properties.iter().find_map(|property| {
4504                let name = property.get("name").and_then(Value::as_str)?;
4505                let value = property.get("value").and_then(Value::as_str)?;
4506                (name == "message").then_some(value)
4507            })
4508        })
4509        .or_else(|| {
4510            exception_details
4511                .pointer("/exception/message")
4512                .and_then(Value::as_str)
4513        })
4514        .or_else(|| {
4515            exception_details
4516                .pointer("/exception/description")
4517                .and_then(Value::as_str)
4518        })
4519        .unwrap_or_default();
4520
4521    let compact = message.split_whitespace().collect::<Vec<_>>().join(" ");
4522    if let Some(selector) = extract_invalid_selector(&compact) {
4523        return format!("{prefix}: invalid selector {selector}");
4524    }
4525    if !compact.is_empty() {
4526        return format!(
4527            "{prefix}: {}",
4528            compact
4529                .trim_start_matches("SyntaxError: ")
4530                .trim_start_matches("DOMException: ")
4531        );
4532    }
4533    format!("{fallback_prefix}: {exception_details}")
4534}
4535
4536fn extract_invalid_selector(message: &str) -> Option<String> {
4537    let prefix = "Failed to execute 'querySelectorAll' on 'Document': '";
4538    let suffix = "' is not a valid selector.";
4539    let start = message.find(prefix)? + prefix.len();
4540    let end = message[start..].find(suffix)? + start;
4541    Some(message[start..end].to_string())
4542}
4543
4544fn block_on_plugin_future<T, F>(future: F) -> Result<T, String>
4545where
4546    F: Future<Output = Result<T, String>>,
4547{
4548    let runtime = plugin_runtime()?;
4549    runtime.block_on(future)
4550}
4551
4552fn plugin_runtime() -> Result<&'static tokio::runtime::Runtime, String> {
4553    if let Some(runtime) = PLUGIN_RUNTIME.get() {
4554        return Ok(runtime);
4555    }
4556
4557    let runtime = tokio::runtime::Builder::new_multi_thread()
4558        .enable_all()
4559        .worker_threads(2)
4560        .thread_name("allwright-web-plugin")
4561        .build()
4562        .map_err(|error| format!("failed to create plugin runtime: {error}"))?;
4563    let _ = PLUGIN_RUNTIME.set(runtime);
4564    PLUGIN_RUNTIME
4565        .get()
4566        .ok_or_else(|| "failed to initialize plugin runtime".to_string())
4567}
4568
4569fn plugin_response(result: Result<PluginResult, String>) -> *mut c_char {
4570    let envelope = match result {
4571        Ok(result) => PluginEnvelope {
4572            ok: true,
4573            result: Some(result),
4574            error: None,
4575        },
4576        Err(error) => PluginEnvelope {
4577            ok: false,
4578            result: None,
4579            error: Some(error),
4580        },
4581    };
4582
4583    let json = match serde_json::to_string(&envelope) {
4584        Ok(json) => json,
4585        Err(error) => {
4586            let fallback = PluginEnvelope {
4587                ok: false,
4588                result: None,
4589                error: Some(format!("failed to serialize plugin response: {error}")),
4590            };
4591            serde_json::to_string(&fallback).unwrap_or_else(|_| {
4592                "{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response\"}"
4593                    .to_string()
4594            })
4595        }
4596    };
4597
4598    CString::new(json).unwrap().into_raw()
4599}
4600
4601fn handle_plugin_command(command: PluginCommand) -> Result<PluginResult, String> {
4602    match command {
4603        PluginCommand::LaunchBrowser {
4604            browser_kind,
4605            browser_binary,
4606        } => {
4607            launch_browser(browser_kind, browser_binary.as_deref()).map(PluginResult::LaunchBrowser)
4608        }
4609        PluginCommand::ResolveFrame {
4610            browser_session,
4611            page_session,
4612            css_selector,
4613            timeout_ms,
4614        } => block_on_plugin_future(async move {
4615            timeout(
4616                Duration::from_millis(timeout_ms),
4617                resolve_frame(&browser_session, &page_session, &css_selector),
4618            )
4619            .await
4620            .map_err(|_| "timed out waiting for frame to load and become stable".to_string())?
4621            .map(PluginResult::ResolveFrame)
4622        }),
4623        PluginCommand::OpenPage { browser_session } => block_on_plugin_future(async move {
4624            open_page(&browser_session)
4625                .await
4626                .map(PluginResult::OpenPage)
4627        }),
4628        PluginCommand::RegisterHook {
4629            browser_session,
4630            page_session,
4631            hook_type,
4632        } => block_on_plugin_future(async move {
4633            register_hook(&browser_session, &page_session, hook_type)
4634                .await
4635                .map(PluginResult::RegisterHook)
4636        }),
4637        PluginCommand::HandleDialog {
4638            browser_session,
4639            page_session,
4640            dialog_id,
4641            accept,
4642            prompt_text,
4643        } => block_on_plugin_future(async move {
4644            dialogs::handle(
4645                &browser_session,
4646                &page_session,
4647                &dialog_id,
4648                accept,
4649                prompt_text,
4650            )
4651            .await?;
4652            Ok(PluginResult::HandleDialog)
4653        }),
4654        PluginCommand::PollHook {
4655            browser_session,
4656            registration,
4657        } => block_on_plugin_future(async move {
4658            poll_hook(&browser_session, &registration)
4659                .await
4660                .map(PluginResult::PollHook)
4661        }),
4662        PluginCommand::SetFileChooserFiles {
4663            browser_session,
4664            page_session,
4665            file_chooser_id,
4666            files,
4667        } => block_on_plugin_future(async move {
4668            set_file_chooser_files(&browser_session, &page_session, &file_chooser_id, &files)
4669                .await
4670                .map(PluginResult::SetFileChooserFiles)
4671        }),
4672        PluginCommand::SaveDownload {
4673            browser_session,
4674            page_session,
4675            download_id,
4676            path,
4677        } => block_on_plugin_future(async move {
4678            save_download(&browser_session, &page_session, &download_id, &path)
4679                .await
4680                .map(PluginResult::SaveDownload)
4681        }),
4682        PluginCommand::ClosePage {
4683            browser_session,
4684            page_session,
4685        } => block_on_plugin_future(async move {
4686            close_page(&browser_session, &page_session).await?;
4687            Ok(PluginResult::ClosePage)
4688        }),
4689        PluginCommand::NavigatePage {
4690            browser_session,
4691            page_session,
4692            url,
4693        } => block_on_plugin_future(async move {
4694            navigate_page(&browser_session, &page_session, &url)
4695                .await
4696                .map(PluginResult::NavigatePage)
4697        }),
4698        PluginCommand::ClickElement {
4699            browser_session,
4700            page_session,
4701            css_selector,
4702        } => block_on_plugin_future(async move {
4703            click_element(&browser_session, &page_session, &css_selector)
4704                .await
4705                .map(PluginResult::ClickElement)
4706        }),
4707        PluginCommand::CountElements {
4708            browser_session,
4709            page_session,
4710            css_selector,
4711        } => block_on_plugin_future(async move {
4712            count_elements(&browser_session, &page_session, &css_selector)
4713                .await
4714                .map(PluginResult::CountElements)
4715        }),
4716        PluginCommand::HighlightElements {
4717            browser_session,
4718            page_session,
4719            css_selector,
4720            duration_ms,
4721        } => block_on_plugin_future(async move {
4722            highlight_elements(&browser_session, &page_session, &css_selector, duration_ms)
4723                .await
4724                .map(PluginResult::HighlightElements)
4725        }),
4726        PluginCommand::FocusElement {
4727            browser_session,
4728            page_session,
4729            css_selector,
4730        } => block_on_plugin_future(async move {
4731            focus_element(&browser_session, &page_session, &css_selector)
4732                .await
4733                .map(PluginResult::FocusElement)
4734        }),
4735        PluginCommand::FillElement {
4736            browser_session,
4737            page_session,
4738            css_selector,
4739            value,
4740        } => block_on_plugin_future(async move {
4741            fill_element(&browser_session, &page_session, &css_selector, &value)
4742                .await
4743                .map(PluginResult::FillElement)
4744        }),
4745        PluginCommand::HoverElement {
4746            browser_session,
4747            page_session,
4748            css_selector,
4749        } => block_on_plugin_future(async move {
4750            hover_element(&browser_session, &page_session, &css_selector)
4751                .await
4752                .map(PluginResult::HoverElement)
4753        }),
4754        PluginCommand::PressKey {
4755            browser_session,
4756            page_session,
4757            css_selector,
4758            key,
4759            text,
4760        } => block_on_plugin_future(async move {
4761            press_key(
4762                &browser_session,
4763                &page_session,
4764                &css_selector,
4765                &key,
4766                text.as_deref(),
4767            )
4768            .await
4769            .map(PluginResult::PressKey)
4770        }),
4771        PluginCommand::Capture {
4772            browser_session,
4773            page_session,
4774            kind,
4775            css_selector,
4776            attribute_name,
4777        } => block_on_plugin_future(async move {
4778            capture(
4779                &browser_session,
4780                &page_session,
4781                &kind,
4782                &css_selector,
4783                &attribute_name,
4784            )
4785            .await
4786            .map(PluginResult::Capture)
4787        }),
4788        PluginCommand::GetTextContent {
4789            browser_session,
4790            page_session,
4791            css_selector,
4792        } => block_on_plugin_future(async move {
4793            get_text_content(&browser_session, &page_session, &css_selector)
4794                .await
4795                .map(PluginResult::GetTextContent)
4796        }),
4797        PluginCommand::GetInnerText {
4798            browser_session,
4799            page_session,
4800            css_selector,
4801        } => block_on_plugin_future(async move {
4802            get_inner_text(&browser_session, &page_session, &css_selector)
4803                .await
4804                .map(PluginResult::GetInnerText)
4805        }),
4806        PluginCommand::WaitForSelector {
4807            browser_session,
4808            page_session,
4809            css_selector,
4810            visible,
4811        } => block_on_plugin_future(async move {
4812            wait_for_selector(&browser_session, &page_session, &css_selector, visible)
4813                .await
4814                .map(PluginResult::WaitForSelector)
4815        }),
4816        PluginCommand::AccessibilitySnapshot {
4817            browser_session,
4818            page_session,
4819            format,
4820            mode,
4821        } => block_on_plugin_future(async move {
4822            accessibility::accessibility_snapshot_with_mode(
4823                &browser_session,
4824                &page_session,
4825                &format,
4826                &mode,
4827            )
4828            .await
4829            .map(PluginResult::AccessibilitySnapshot)
4830        }),
4831        PluginCommand::Screenshot {
4832            browser_session,
4833            page_session,
4834            full_page,
4835        } => block_on_plugin_future(async move {
4836            screenshot(&browser_session, &page_session, full_page)
4837                .await
4838                .map(PluginResult::Screenshot)
4839        }),
4840        PluginCommand::OpenChromeWindow { chrome_binary } => {
4841            open_chrome_window(chrome_binary.as_deref()).map(PluginResult::OpenChromeWindow)
4842        }
4843        PluginCommand::DiscoverInitialTab { cdp_websocket_url } => {
4844            block_on_plugin_future(async move {
4845                let result = discover_initial_tab(&cdp_websocket_url).await?;
4846                Ok(PluginResult::DiscoverInitialTab(result))
4847            })
4848        }
4849        PluginCommand::OpenChromeTab { cdp_websocket_url } => block_on_plugin_future(async move {
4850            let result = open_chrome_tab(&cdp_websocket_url).await?;
4851            Ok(PluginResult::OpenChromeTab(result))
4852        }),
4853        PluginCommand::CloseBrowserProcess { process_id } => {
4854            close_browser_process(process_id)?;
4855            Ok(PluginResult::CloseBrowserProcess)
4856        }
4857        PluginCommand::CloseChromeTab {
4858            cdp_websocket_url,
4859            target_id,
4860        } => block_on_plugin_future(async move {
4861            close_chrome_tab(&cdp_websocket_url, &target_id).await?;
4862            Ok(PluginResult::CloseChromeTab)
4863        }),
4864        PluginCommand::NavigateChromeTab {
4865            cdp_websocket_url,
4866            target_id,
4867            url,
4868        } => block_on_plugin_future(async move {
4869            let result = navigate_chrome_tab(&cdp_websocket_url, &target_id, &url).await?;
4870            Ok(PluginResult::NavigateChromeTab(result))
4871        }),
4872        PluginCommand::InjectChromiumBidiMapper { cdp_websocket_url } => {
4873            block_on_plugin_future(async move {
4874                let result = inject_chromium_bidi_mapper(&cdp_websocket_url).await?;
4875                Ok(PluginResult::InjectChromiumBidiMapper(result))
4876            })
4877        }
4878        PluginCommand::ResolveBidiContextForTab {
4879            cdp_websocket_url,
4880            mapper_target_id,
4881            browsing_context_id,
4882            url,
4883        } => block_on_plugin_future(async move {
4884            let (resolved_browsing_context_id, mapper) = resolve_bidi_context_for_tab(
4885                &cdp_websocket_url,
4886                mapper_target_id.as_deref(),
4887                browsing_context_id.as_deref(),
4888                url.as_deref(),
4889            )
4890            .await?;
4891            Ok(PluginResult::ResolveBidiContextForTab {
4892                browsing_context_id: resolved_browsing_context_id,
4893                mapper,
4894            })
4895        }),
4896    }
4897}
4898
4899#[unsafe(no_mangle)]
4900pub extern "C" fn allwright_plugin_api_version() -> u32 {
4901    ALLWRIGHT_PLUGIN_API_VERSION
4902}
4903
4904#[unsafe(no_mangle)]
4905pub extern "C" fn allwright_plugin_id() -> *const c_char {
4906    c"web".as_ptr()
4907}
4908
4909#[unsafe(no_mangle)]
4910pub unsafe extern "C" fn allwright_plugin_invoke(request_json: *const c_char) -> *mut c_char {
4911    if request_json.is_null() {
4912        return plugin_response(Err("plugin request pointer is null".to_string()));
4913    }
4914
4915    let request = match unsafe { CStr::from_ptr(request_json) }.to_str() {
4916        Ok(request) => request,
4917        Err(error) => {
4918            return plugin_response(Err(format!("plugin request is not valid UTF-8: {error}")));
4919        }
4920    };
4921
4922    let command: PluginCommand = match serde_json::from_str(request) {
4923        Ok(command) => command,
4924        Err(error) => {
4925            return plugin_response(Err(format!("failed to parse plugin request JSON: {error}")));
4926        }
4927    };
4928
4929    plugin_response(handle_plugin_command(command))
4930}
4931
4932#[unsafe(no_mangle)]
4933pub unsafe extern "C" fn allwright_plugin_free_string(value: *mut c_char) {
4934    if !value.is_null() {
4935        unsafe {
4936            let _ = CString::from_raw(value);
4937        }
4938    }
4939}
4940
4941#[cfg(test)]
4942mod tests {
4943    use super::{
4944        SelectorKind, chromium_profile_preferences, firefox_download_preferences, parse_selector,
4945        suppressed_file_dialog_behavior,
4946    };
4947
4948    #[test]
4949    fn bidi_sessions_dismiss_native_file_dialogs() {
4950        assert_eq!(
4951            suppressed_file_dialog_behavior().pointer("/file"),
4952            Some(&serde_json::Value::String("dismiss".to_string()))
4953        );
4954    }
4955
4956    #[test]
4957    fn chromium_automation_has_no_direct_cdp_fallbacks() {
4958        let source = include_str!("lib.rs");
4959        for (prefix, suffix) in [
4960            ("Via", "Cdp"),
4961            ("_via_", "cdp"),
4962            ("Target.", "getTargets"),
4963            ("Target.", "closeTarget"),
4964            ("Page.", "getFrameTree"),
4965            ("Page.", "captureScreenshot"),
4966            ("Input.", "dispatchMouseEvent"),
4967            ("Input.", "dispatchKeyEvent"),
4968        ] {
4969            let forbidden = format!("{prefix}{suffix}");
4970            assert!(
4971                !source.contains(&forbidden),
4972                "direct CDP automation token must not appear in the web surface: {forbidden}"
4973            );
4974        }
4975    }
4976
4977    #[test]
4978    fn browser_profiles_suppress_download_prompts_and_panels() {
4979        let chromium = chromium_profile_preferences();
4980        assert_eq!(
4981            chromium.pointer("/download/prompt_for_download"),
4982            Some(&serde_json::Value::Bool(false))
4983        );
4984        assert_eq!(
4985            chromium.pointer("/download_bubble/partial_view_enabled"),
4986            Some(&serde_json::Value::Bool(false))
4987        );
4988        assert_eq!(
4989            chromium.pointer("/profile/default_content_setting_values/automatic_downloads"),
4990            Some(&serde_json::Value::Number(1.into()))
4991        );
4992
4993        let firefox = firefox_download_preferences();
4994        assert!(firefox.contains(r#""browser.download.useDownloadDir", true"#));
4995        assert!(
4996            firefox.contains(r#""browser.download.always_ask_before_handling_new_types", false"#)
4997        );
4998        assert!(firefox.contains(r#""browser.download.alwaysOpenPanel", false"#));
4999    }
5000
5001    #[test]
5002    fn parse_selector_detects_explicit_xpath_prefix() {
5003        let parsed = parse_selector("click_element", "xpath=//button[normalize-space()='Save']")
5004            .expect("selector should parse");
5005        assert_eq!(parsed.kind, SelectorKind::XPath);
5006        assert_eq!(parsed.value, "//button[normalize-space()='Save']");
5007    }
5008
5009    #[test]
5010    fn parse_selector_detects_xpath_heuristically() {
5011        let parsed = parse_selector("click_element", "//div[@role='dialog']")
5012            .expect("selector should parse");
5013        assert_eq!(parsed.kind, SelectorKind::XPath);
5014    }
5015
5016    #[test]
5017    fn parse_selector_preserves_css_by_default() {
5018        let parsed = parse_selector("click_element", "button[data-testid='save']")
5019            .expect("selector should parse");
5020        assert_eq!(parsed.kind, SelectorKind::Css);
5021        assert_eq!(parsed.value, "button[data-testid='save']");
5022    }
5023
5024    #[test]
5025    fn parse_selector_decodes_json_quoted_xpath_payload() {
5026        let parsed = parse_selector(
5027            "click_element",
5028            r#"xpath="//button[normalize-space()='Save Changes']""#,
5029        )
5030        .expect("selector should parse");
5031        assert_eq!(parsed.kind, SelectorKind::XPath);
5032        assert_eq!(parsed.value, "//button[normalize-space()='Save Changes']");
5033    }
5034
5035    #[test]
5036    fn parse_selector_chain_handles_quoted_hybrid_segments() {
5037        let segments = super::parse_selector_chain(
5038            "click_element",
5039            r#"xpath="//div[@role='dialog']" css="button.primary""#,
5040        )
5041        .expect("selector chain should parse");
5042        assert_eq!(segments.len(), 2);
5043        assert_eq!(segments[0].kind, SelectorKind::XPath);
5044        assert_eq!(segments[0].value, "//div[@role='dialog']");
5045        assert_eq!(segments[1].kind, SelectorKind::Css);
5046        assert_eq!(segments[1].value, "button.primary");
5047    }
5048}