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