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