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