Skip to main content

allwright_surface_web/
lib.rs

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