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