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