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