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