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