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