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