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