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