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