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