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