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