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