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!(
2464 "Runtime.evaluate failed with exception details: {exception_details}"
2465 ));
2466 }
2467 Ok(result)
2468 }
2469
2470 async fn attach_to_target(&mut self, target_id: &str) -> Result<String, String> {
2471 let attached = self
2472 .send_command(
2473 "Target.attachToTarget",
2474 json!({
2475 "targetId": target_id,
2476 "flatten": true,
2477 }),
2478 None,
2479 )
2480 .await?;
2481 required_string(&attached, "/sessionId")
2482 }
2483
2484 async fn prepare_page_target_session(&mut self, target_id: &str) -> Result<String, String> {
2485 let session_id = self.attach_to_target(target_id).await?;
2486 self.send_command("Runtime.enable", json!({}), Some(&session_id))
2487 .await?;
2488 self.send_command("Page.enable", json!({}), Some(&session_id))
2489 .await?;
2490 Ok(session_id)
2491 }
2492
2493 async fn detach_from_target(&mut self, session_id: &str) -> Result<(), String> {
2494 self.send_command(
2495 "Target.detachFromTarget",
2496 json!({
2497 "sessionId": session_id,
2498 }),
2499 None,
2500 )
2501 .await?;
2502 Ok(())
2503 }
2504
2505 async fn dispatch_mouse_click(
2506 &mut self,
2507 session_id: &str,
2508 x: f64,
2509 y: f64,
2510 ) -> Result<(), String> {
2511 self.dispatch_mouse_move(session_id, x, y).await?;
2512 self.send_command(
2513 "Input.dispatchMouseEvent",
2514 json!({
2515 "type": "mousePressed",
2516 "x": x,
2517 "y": y,
2518 "button": "left",
2519 "buttons": 1,
2520 "clickCount": 1,
2521 }),
2522 Some(session_id),
2523 )
2524 .await?;
2525 self.send_command(
2526 "Input.dispatchMouseEvent",
2527 json!({
2528 "type": "mouseReleased",
2529 "x": x,
2530 "y": y,
2531 "button": "left",
2532 "buttons": 0,
2533 "clickCount": 1,
2534 }),
2535 Some(session_id),
2536 )
2537 .await?;
2538 Ok(())
2539 }
2540
2541 async fn dispatch_mouse_move(
2542 &mut self,
2543 session_id: &str,
2544 x: f64,
2545 y: f64,
2546 ) -> Result<(), String> {
2547 self.send_command(
2548 "Input.dispatchMouseEvent",
2549 json!({
2550 "type": "mouseMoved",
2551 "x": x,
2552 "y": y,
2553 "button": "none",
2554 "buttons": 0,
2555 }),
2556 Some(session_id),
2557 )
2558 .await?;
2559 Ok(())
2560 }
2561
2562 async fn dispatch_key_press(
2563 &mut self,
2564 session_id: &str,
2565 key: &str,
2566 text: Option<&str>,
2567 ) -> Result<(), String> {
2568 self.send_command(
2569 "Input.dispatchKeyEvent",
2570 json!({
2571 "type": "keyDown",
2572 "key": key,
2573 "text": text.unwrap_or(""),
2574 }),
2575 Some(session_id),
2576 )
2577 .await?;
2578 self.send_command(
2579 "Input.dispatchKeyEvent",
2580 json!({
2581 "type": "keyUp",
2582 "key": key,
2583 "text": text.unwrap_or(""),
2584 }),
2585 Some(session_id),
2586 )
2587 .await?;
2588 Ok(())
2589 }
2590
2591 async fn ensure_chromium_bidi_mapper(
2592 &mut self,
2593 existing_mapper_target_id: Option<&str>,
2594 ) -> Result<MapperConnectionInfo, String> {
2595 let (mut mapper_target_id, mut created_target) = match existing_mapper_target_id {
2596 Some(existing_mapper_target_id) if !existing_mapper_target_id.trim().is_empty() => {
2597 (existing_mapper_target_id.to_string(), false)
2598 }
2599 _ => (self.create_bidi_mapper_target().await?, true),
2600 };
2601
2602 let attached = match self
2603 .send_command(
2604 "Target.attachToTarget",
2605 json!({
2606 "targetId": mapper_target_id,
2607 "flatten": true,
2608 }),
2609 None,
2610 )
2611 .await
2612 {
2613 Ok(attached) => attached,
2614 Err(error)
2615 if !created_target
2616 && error.contains("Target.attachToTarget failed")
2617 && error.contains("No target with given id found") =>
2618 {
2619 mapper_target_id = self.create_bidi_mapper_target().await?;
2620 created_target = true;
2621 self.send_command(
2622 "Target.attachToTarget",
2623 json!({
2624 "targetId": mapper_target_id,
2625 "flatten": true,
2626 }),
2627 None,
2628 )
2629 .await?
2630 }
2631 Err(error) => return Err(error),
2632 };
2633 let mapper_session_id = required_string(&attached, "/sessionId")?;
2634
2635 self.send_command("Runtime.enable", json!({}), Some(&mapper_session_id))
2636 .await?;
2637 self.send_command(
2638 "Target.exposeDevToolsProtocol",
2639 json!({
2640 "bindingName": "cdp",
2641 "targetId": mapper_target_id,
2642 "inheritPermissions": true,
2643 }),
2644 None,
2645 )
2646 .await?;
2647 self.ensure_runtime_binding(&mapper_session_id, "sendBidiResponse")
2648 .await?;
2649
2650 if created_target
2651 || !self
2652 .mapper_runtime_is_initialized(&mapper_session_id)
2653 .await?
2654 {
2655 self.evaluate_expression(&mapper_session_id, CHROMIUM_BIDI_MAPPER_BUNDLE, false)
2656 .await?;
2657 self.evaluate_expression(
2658 &mapper_session_id,
2659 &format!(
2660 "window.runMapperInstance('{}')",
2661 js_single_quote(&mapper_target_id)
2662 ),
2663 true,
2664 )
2665 .await?;
2666 self.send_bidi_command(
2667 &mapper_session_id,
2668 &json!({
2669 "id": 1,
2670 "method": "session.new",
2671 "params": {
2672 "capabilities": {}
2673 }
2674 }),
2675 )
2676 .await?;
2677 }
2678
2679 Ok(MapperConnectionInfo {
2680 package_version: CHROMIUM_BIDI_NPM_VERSION.to_string(),
2681 mapper_target_id,
2682 mapper_session_id,
2683 })
2684 }
2685
2686 async fn create_bidi_mapper_target(&mut self) -> Result<String, String> {
2687 let created = self
2688 .send_command(
2689 "Target.createTarget",
2690 json!({
2691 "url": "about:blank#MAPPER_TARGET",
2692 "hidden": true,
2693 "background": true,
2694 }),
2695 None,
2696 )
2697 .await?;
2698 required_string(&created, "/targetId")
2699 }
2700
2701 async fn ensure_runtime_binding(&mut self, session_id: &str, name: &str) -> Result<(), String> {
2702 match self
2703 .send_command(
2704 "Runtime.addBinding",
2705 json!({
2706 "name": name,
2707 }),
2708 Some(session_id),
2709 )
2710 .await
2711 {
2712 Ok(_) => Ok(()),
2713 Err(error)
2714 if error.contains("already exists") || error.contains("Cannot add binding") =>
2715 {
2716 Ok(())
2717 }
2718 Err(error) => Err(error),
2719 }
2720 }
2721
2722 async fn mapper_runtime_is_initialized(&mut self, session_id: &str) -> Result<bool, String> {
2723 let result = self
2724 .evaluate_expression(
2725 session_id,
2726 "typeof window.runMapperInstance === 'function' && typeof window.onBidiMessage === 'function'",
2727 true,
2728 )
2729 .await?;
2730 Ok(result
2731 .pointer("/result/value")
2732 .and_then(Value::as_bool)
2733 .unwrap_or(false))
2734 }
2735
2736 async fn send_bidi_command(
2737 &mut self,
2738 mapper_session_id: &str,
2739 command: &Value,
2740 ) -> Result<Value, String> {
2741 let command_id = command
2742 .get("id")
2743 .and_then(Value::as_u64)
2744 .ok_or_else(|| "BiDi command is missing numeric id".to_string())?;
2745 let eval_id = self.next_id;
2746 self.next_id += 1;
2747 let command_json = command.to_string();
2748 let expression = format!(
2749 "onBidiMessage({})",
2750 serde_json::to_string(&command_json)
2751 .map_err(|error| format!("failed to encode BiDi command payload: {error}"))?
2752 );
2753
2754 let payload = json!({
2755 "id": eval_id,
2756 "method": "Runtime.evaluate",
2757 "params": {
2758 "expression": expression,
2759 "awaitPromise": true,
2760 },
2761 "sessionId": mapper_session_id,
2762 });
2763 self.socket
2764 .send(Message::Text(payload.to_string().into()))
2765 .await
2766 .map_err(|error| format!("failed to send mapper Runtime.evaluate call: {error}"))?;
2767
2768 loop {
2769 let message = self.next_json_message().await?;
2770
2771 if message.get("id").and_then(Value::as_u64) == Some(eval_id) {
2772 if let Some(error) = message.get("error") {
2773 return Err(format!("mapper Runtime.evaluate failed: {error}"));
2774 }
2775 if let Some(exception_details) = message.pointer("/result/exceptionDetails") {
2776 return Err(format!(
2777 "mapper Runtime.evaluate raised exception details: {exception_details}"
2778 ));
2779 }
2780 continue;
2781 }
2782
2783 if message.get("sessionId").and_then(Value::as_str) != Some(mapper_session_id) {
2784 continue;
2785 }
2786
2787 if message.get("method").and_then(Value::as_str) != Some("Runtime.bindingCalled") {
2788 continue;
2789 }
2790
2791 let params = message
2792 .get("params")
2793 .ok_or_else(|| "Runtime.bindingCalled message is missing params".to_string())?;
2794 if params.get("name").and_then(Value::as_str) != Some("sendBidiResponse") {
2795 continue;
2796 }
2797
2798 let payload = params
2799 .get("payload")
2800 .and_then(Value::as_str)
2801 .ok_or_else(|| "Runtime.bindingCalled payload is missing".to_string())?;
2802 let response = serde_json::from_str::<Value>(payload)
2803 .map_err(|error| format!("failed to parse BiDi mapper response JSON: {error}"))?;
2804 if response.get("id").and_then(Value::as_u64) != Some(command_id) {
2805 continue;
2806 }
2807
2808 match response.get("type").and_then(Value::as_str) {
2809 Some("success") => return Ok(response),
2810 Some("error") => {
2811 let message = response
2812 .get("message")
2813 .and_then(Value::as_str)
2814 .unwrap_or("unknown BiDi mapper error");
2815 let error = response
2816 .get("error")
2817 .and_then(Value::as_str)
2818 .unwrap_or("unknown error");
2819 return Err(format!("BiDi mapper returned {error}: {message}"));
2820 }
2821 Some(other) => {
2822 return Err(format!("unexpected BiDi response type {other}: {response}"));
2823 }
2824 None => {
2825 return Err(format!("BiDi mapper response is missing type: {response}"));
2826 }
2827 }
2828 }
2829 }
2830
2831 async fn resolve_bidi_context_id(
2832 &mut self,
2833 mapper_session_id: &str,
2834 existing_context_id: Option<&str>,
2835 current_url: Option<&str>,
2836 ) -> Result<String, String> {
2837 if let Some(existing_context_id) = existing_context_id {
2838 if !existing_context_id.trim().is_empty() {
2839 let probe = json!({
2840 "id": 1,
2841 "method": "browsingContext.getTree",
2842 "params": {
2843 "root": existing_context_id,
2844 "maxDepth": 0,
2845 }
2846 });
2847 match self.send_bidi_command(mapper_session_id, &probe).await {
2848 Ok(_) => return Ok(existing_context_id.to_string()),
2849 Err(error)
2850 if error.contains("no such frame")
2851 || error.contains("Context ")
2852 || error.contains("invalid argument") => {}
2853 Err(error) => return Err(error),
2854 }
2855 }
2856 }
2857
2858 let target_url = current_url
2859 .map(str::trim)
2860 .filter(|value| !value.is_empty())
2861 .map(str::to_string);
2862
2863 for attempt in 0..20 {
2864 let tree = self
2865 .send_bidi_command(
2866 mapper_session_id,
2867 &json!({
2868 "id": 2,
2869 "method": "browsingContext.getTree",
2870 "params": {
2871 "maxDepth": 0,
2872 }
2873 }),
2874 )
2875 .await?;
2876 let contexts = tree
2877 .pointer("/result/contexts")
2878 .and_then(Value::as_array)
2879 .ok_or_else(|| {
2880 format!("BiDi browsingContext.getTree returned no contexts: {tree}")
2881 })?;
2882
2883 if let Some(target_url) = target_url.as_deref() {
2884 if let Some(context_id) = contexts.iter().find_map(|context| {
2885 let url = context.get("url").and_then(Value::as_str)?;
2886 if url == target_url {
2887 context
2888 .get("context")
2889 .and_then(Value::as_str)
2890 .map(str::to_string)
2891 } else {
2892 None
2893 }
2894 }) {
2895 return Ok(context_id);
2896 }
2897 }
2898
2899 if let Some(context_id) = contexts
2900 .first()
2901 .and_then(|context| context.get("context"))
2902 .and_then(Value::as_str)
2903 .filter(|value| !value.trim().is_empty())
2904 .map(str::to_string)
2905 {
2906 return Ok(context_id);
2907 }
2908
2909 if attempt < 19 {
2910 sleep(Duration::from_millis(150)).await;
2911 } else {
2912 return Err(format!(
2913 "BiDi browsingContext.getTree returned no usable context ids after retries: {tree}"
2914 ));
2915 }
2916 }
2917
2918 Err("BiDi browsing context resolution exhausted retries unexpectedly".to_string())
2919 }
2920
2921 async fn navigate_and_wait_for_load(
2922 &mut self,
2923 session_id: &str,
2924 url: &str,
2925 ) -> Result<(), String> {
2926 let id = self.next_id;
2927 self.next_id += 1;
2928
2929 let payload = json!({
2930 "id": id,
2931 "method": "Page.navigate",
2932 "params": {
2933 "url": url,
2934 },
2935 "sessionId": session_id,
2936 });
2937 self.socket
2938 .send(Message::Text(payload.to_string().into()))
2939 .await
2940 .map_err(|error| format!("failed to send CDP command Page.navigate: {error}"))?;
2941
2942 let mut navigate_confirmed = false;
2943 let mut load_seen = false;
2944
2945 loop {
2946 let message = self.next_json_message().await?;
2947
2948 if message.get("id").and_then(Value::as_u64) == Some(id) {
2949 if let Some(error) = message.get("error") {
2950 return Err(format!("CDP command Page.navigate failed: {error}"));
2951 }
2952 navigate_confirmed = true;
2953 } else if message.get("sessionId").and_then(Value::as_str) == Some(session_id)
2954 && message.get("method").and_then(Value::as_str) == Some("Page.loadEventFired")
2955 {
2956 load_seen = true;
2957 }
2958
2959 if navigate_confirmed && load_seen {
2960 return Ok(());
2961 }
2962 }
2963 }
2964
2965 async fn next_json_message(&mut self) -> Result<Value, String> {
2966 loop {
2967 let message = self
2968 .socket
2969 .next()
2970 .await
2971 .ok_or_else(|| "CDP websocket closed unexpectedly".to_string())?
2972 .map_err(|error| format!("failed to read CDP websocket message: {error}"))?;
2973
2974 match message {
2975 Message::Text(text) => {
2976 let value = serde_json::from_str::<Value>(&text)
2977 .map_err(|error| format!("failed to parse CDP JSON message: {error}"))?;
2978 return Ok(value);
2979 }
2980 Message::Binary(bytes) => {
2981 let value = serde_json::from_slice::<Value>(&bytes)
2982 .map_err(|error| format!("failed to parse CDP JSON message: {error}"))?;
2983 return Ok(value);
2984 }
2985 Message::Ping(payload) => {
2986 self.socket
2987 .send(Message::Pong(payload))
2988 .await
2989 .map_err(|error| format!("failed to reply to CDP ping: {error}"))?;
2990 }
2991 Message::Pong(_) => {}
2992 Message::Frame(_) => {}
2993 Message::Close(frame) => {
2994 return Err(format!("CDP websocket closed: {frame:?}"));
2995 }
2996 }
2997 }
2998 }
2999}
3000
3001struct BidiConnection {
3002 socket: BidiSocket,
3003 next_id: u64,
3004}
3005
3006impl BidiConnection {
3007 async fn connect(bidi_websocket_url: &str) -> Result<Self, String> {
3008 let (socket, _) = connect_async(bidi_websocket_url).await.map_err(|error| {
3009 format!("failed to connect to WebDriver BiDi websocket {bidi_websocket_url}: {error}")
3010 })?;
3011 Ok(Self { socket, next_id: 1 })
3012 }
3013
3014 async fn send_command(&mut self, method: &str, params: Value) -> Result<Value, String> {
3015 let id = self.next_id;
3016 self.next_id += 1;
3017 let payload = json!({
3018 "id": id,
3019 "method": method,
3020 "params": params,
3021 });
3022 self.socket
3023 .send(Message::Text(payload.to_string().into()))
3024 .await
3025 .map_err(|error| format!("failed to send WebDriver BiDi command {method}: {error}"))?;
3026
3027 loop {
3028 let message = self.next_json_message().await?;
3029 if message.get("id").and_then(Value::as_u64) != Some(id) {
3030 continue;
3031 }
3032
3033 if let Some(error) = message.get("error").and_then(Value::as_str) {
3034 let message_text = message
3035 .get("message")
3036 .and_then(Value::as_str)
3037 .unwrap_or("unknown WebDriver BiDi error");
3038 return Err(format!(
3039 "WebDriver BiDi command {method} returned {error}: {message_text}"
3040 ));
3041 }
3042
3043 return Ok(message);
3044 }
3045 }
3046
3047 async fn wait_for_top_level_context(&mut self) -> Result<String, String> {
3048 for _ in 0..100 {
3049 let tree = self
3050 .send_command(
3051 "browsingContext.getTree",
3052 json!({
3053 "maxDepth": 0,
3054 }),
3055 )
3056 .await?;
3057 if let Some(context_id) = tree
3058 .pointer("/result/contexts")
3059 .and_then(Value::as_array)
3060 .and_then(|contexts| contexts.first())
3061 .and_then(|context| context.get("context"))
3062 .and_then(Value::as_str)
3063 .filter(|value| !value.trim().is_empty())
3064 {
3065 return Ok(context_id.to_string());
3066 }
3067 sleep(Duration::from_millis(50)).await;
3068 }
3069
3070 Err("timed out waiting for an initial Firefox browsing context".to_string())
3071 }
3072
3073 async fn next_json_message(&mut self) -> Result<Value, String> {
3074 loop {
3075 let message = self
3076 .socket
3077 .next()
3078 .await
3079 .ok_or_else(|| "WebDriver BiDi websocket closed unexpectedly".to_string())?
3080 .map_err(|error| {
3081 format!("failed to read WebDriver BiDi websocket message: {error}")
3082 })?;
3083
3084 match message {
3085 Message::Text(text) => {
3086 let value = serde_json::from_str::<Value>(&text).map_err(|error| {
3087 format!("failed to parse WebDriver BiDi JSON message: {error}")
3088 })?;
3089 return Ok(value);
3090 }
3091 Message::Binary(bytes) => {
3092 let value = serde_json::from_slice::<Value>(&bytes).map_err(|error| {
3093 format!("failed to parse WebDriver BiDi JSON message: {error}")
3094 })?;
3095 return Ok(value);
3096 }
3097 Message::Ping(payload) => {
3098 self.socket
3099 .send(Message::Pong(payload))
3100 .await
3101 .map_err(|error| {
3102 format!("failed to reply to WebDriver BiDi ping: {error}")
3103 })?;
3104 }
3105 Message::Pong(_) => {}
3106 Message::Frame(_) => {}
3107 Message::Close(frame) => {
3108 return Err(format!("WebDriver BiDi websocket closed: {frame:?}"));
3109 }
3110 }
3111 }
3112 }
3113}
3114
3115fn required_string(value: &Value, pointer: &str) -> Result<String, String> {
3116 value
3117 .pointer(pointer)
3118 .and_then(Value::as_str)
3119 .map(str::to_string)
3120 .filter(|value| !value.is_empty())
3121 .ok_or_else(|| format!("missing string field at JSON pointer {pointer}"))
3122}
3123
3124fn js_single_quote(value: &str) -> String {
3125 value.replace('\\', "\\\\").replace('\'', "\\'")
3126}
3127
3128fn json_u32(value: &Value, pointer: &str) -> Result<u32, String> {
3129 let raw = value
3130 .pointer(pointer)
3131 .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
3132
3133 if let Some(number) = raw.as_u64() {
3134 return u32::try_from(number)
3135 .map_err(|_| format!("numeric JSON value at {pointer} exceeds u32: {number}"));
3136 }
3137
3138 if let Some(number) = raw.as_i64() {
3139 return u32::try_from(number).map_err(|_| {
3140 format!("numeric JSON value at {pointer} is out of range for u32: {number}")
3141 });
3142 }
3143
3144 Err(format!(
3145 "expected numeric JSON value at {pointer}, found {raw}"
3146 ))
3147}
3148
3149fn json_f64(value: &Value, pointer: &str) -> Result<f64, String> {
3150 let raw = value
3151 .pointer(pointer)
3152 .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
3153
3154 raw.as_f64()
3155 .ok_or_else(|| format!("expected numeric JSON value at {pointer}, found {raw}"))
3156}
3157
3158fn json_string(value: &Value, pointer: &str) -> Result<String, String> {
3159 value
3160 .pointer(pointer)
3161 .and_then(Value::as_str)
3162 .map(str::to_string)
3163 .ok_or_else(|| format!("expected string JSON value at {pointer}"))
3164}
3165
3166fn json_string_literal(value: &str, context: &str) -> Result<String, String> {
3167 serde_json::to_string(value).map_err(|error| format!("failed to serialize {context}: {error}"))
3168}
3169
3170fn block_on_plugin_future<T, F>(future: F) -> Result<T, String>
3171where
3172 F: Future<Output = Result<T, String>>,
3173{
3174 let runtime = plugin_runtime()?;
3175 runtime.block_on(future)
3176}
3177
3178fn plugin_runtime() -> Result<&'static tokio::runtime::Runtime, String> {
3179 if let Some(runtime) = PLUGIN_RUNTIME.get() {
3180 return Ok(runtime);
3181 }
3182
3183 let runtime = tokio::runtime::Builder::new_multi_thread()
3184 .enable_all()
3185 .worker_threads(2)
3186 .thread_name("allwright-web-plugin")
3187 .build()
3188 .map_err(|error| format!("failed to create plugin runtime: {error}"))?;
3189 let _ = PLUGIN_RUNTIME.set(runtime);
3190 PLUGIN_RUNTIME
3191 .get()
3192 .ok_or_else(|| "failed to initialize plugin runtime".to_string())
3193}
3194
3195fn plugin_response(result: Result<PluginResult, String>) -> *mut c_char {
3196 let envelope = match result {
3197 Ok(result) => PluginEnvelope {
3198 ok: true,
3199 result: Some(result),
3200 error: None,
3201 },
3202 Err(error) => PluginEnvelope {
3203 ok: false,
3204 result: None,
3205 error: Some(error),
3206 },
3207 };
3208
3209 let json = match serde_json::to_string(&envelope) {
3210 Ok(json) => json,
3211 Err(error) => {
3212 let fallback = PluginEnvelope {
3213 ok: false,
3214 result: None,
3215 error: Some(format!("failed to serialize plugin response: {error}")),
3216 };
3217 serde_json::to_string(&fallback).unwrap_or_else(|_| {
3218 "{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response\"}"
3219 .to_string()
3220 })
3221 }
3222 };
3223
3224 CString::new(json).unwrap().into_raw()
3225}
3226
3227fn handle_plugin_command(command: PluginCommand) -> Result<PluginResult, String> {
3228 match command {
3229 PluginCommand::LaunchBrowser {
3230 browser_kind,
3231 browser_binary,
3232 } => {
3233 launch_browser(browser_kind, browser_binary.as_deref()).map(PluginResult::LaunchBrowser)
3234 }
3235 PluginCommand::OpenPage { browser_session } => block_on_plugin_future(async move {
3236 open_page(&browser_session)
3237 .await
3238 .map(PluginResult::OpenPage)
3239 }),
3240 PluginCommand::ClosePage {
3241 browser_session,
3242 page_session,
3243 } => block_on_plugin_future(async move {
3244 close_page(&browser_session, &page_session).await?;
3245 Ok(PluginResult::ClosePage)
3246 }),
3247 PluginCommand::NavigatePage {
3248 browser_session,
3249 page_session,
3250 url,
3251 } => block_on_plugin_future(async move {
3252 navigate_page(&browser_session, &page_session, &url)
3253 .await
3254 .map(PluginResult::NavigatePage)
3255 }),
3256 PluginCommand::ClickElement {
3257 browser_session,
3258 page_session,
3259 css_selector,
3260 } => block_on_plugin_future(async move {
3261 click_element(&browser_session, &page_session, &css_selector)
3262 .await
3263 .map(PluginResult::ClickElement)
3264 }),
3265 PluginCommand::CountElements {
3266 browser_session,
3267 page_session,
3268 css_selector,
3269 } => block_on_plugin_future(async move {
3270 count_elements(&browser_session, &page_session, &css_selector)
3271 .await
3272 .map(PluginResult::CountElements)
3273 }),
3274 PluginCommand::HighlightElements {
3275 browser_session,
3276 page_session,
3277 css_selector,
3278 duration_ms,
3279 } => block_on_plugin_future(async move {
3280 highlight_elements(&browser_session, &page_session, &css_selector, duration_ms)
3281 .await
3282 .map(PluginResult::HighlightElements)
3283 }),
3284 PluginCommand::FocusElement {
3285 browser_session,
3286 page_session,
3287 css_selector,
3288 } => block_on_plugin_future(async move {
3289 focus_element(&browser_session, &page_session, &css_selector)
3290 .await
3291 .map(PluginResult::FocusElement)
3292 }),
3293 PluginCommand::FillElement {
3294 browser_session,
3295 page_session,
3296 css_selector,
3297 value,
3298 } => block_on_plugin_future(async move {
3299 fill_element(&browser_session, &page_session, &css_selector, &value)
3300 .await
3301 .map(PluginResult::FillElement)
3302 }),
3303 PluginCommand::HoverElement {
3304 browser_session,
3305 page_session,
3306 css_selector,
3307 } => block_on_plugin_future(async move {
3308 hover_element(&browser_session, &page_session, &css_selector)
3309 .await
3310 .map(PluginResult::HoverElement)
3311 }),
3312 PluginCommand::PressKey {
3313 browser_session,
3314 page_session,
3315 css_selector,
3316 key,
3317 text,
3318 } => block_on_plugin_future(async move {
3319 press_key(
3320 &browser_session,
3321 &page_session,
3322 &css_selector,
3323 &key,
3324 text.as_deref(),
3325 )
3326 .await
3327 .map(PluginResult::PressKey)
3328 }),
3329 PluginCommand::GetTextContent {
3330 browser_session,
3331 page_session,
3332 css_selector,
3333 } => block_on_plugin_future(async move {
3334 get_text_content(&browser_session, &page_session, &css_selector)
3335 .await
3336 .map(PluginResult::GetTextContent)
3337 }),
3338 PluginCommand::GetInnerText {
3339 browser_session,
3340 page_session,
3341 css_selector,
3342 } => block_on_plugin_future(async move {
3343 get_inner_text(&browser_session, &page_session, &css_selector)
3344 .await
3345 .map(PluginResult::GetInnerText)
3346 }),
3347 PluginCommand::WaitForSelector {
3348 browser_session,
3349 page_session,
3350 css_selector,
3351 visible,
3352 } => block_on_plugin_future(async move {
3353 wait_for_selector(&browser_session, &page_session, &css_selector, visible)
3354 .await
3355 .map(PluginResult::WaitForSelector)
3356 }),
3357 PluginCommand::OpenChromeWindow { chrome_binary } => {
3358 open_chrome_window(chrome_binary.as_deref()).map(PluginResult::OpenChromeWindow)
3359 }
3360 PluginCommand::DiscoverInitialTab { cdp_websocket_url } => {
3361 block_on_plugin_future(async move {
3362 let result = discover_initial_tab(&cdp_websocket_url).await?;
3363 Ok(PluginResult::DiscoverInitialTab(result))
3364 })
3365 }
3366 PluginCommand::OpenChromeTab { cdp_websocket_url } => block_on_plugin_future(async move {
3367 let result = open_chrome_tab(&cdp_websocket_url).await?;
3368 Ok(PluginResult::OpenChromeTab(result))
3369 }),
3370 PluginCommand::CloseBrowserProcess { process_id } => {
3371 close_browser_process(process_id)?;
3372 Ok(PluginResult::CloseBrowserProcess)
3373 }
3374 PluginCommand::CloseChromeTab {
3375 cdp_websocket_url,
3376 target_id,
3377 } => block_on_plugin_future(async move {
3378 close_chrome_tab(&cdp_websocket_url, &target_id).await?;
3379 Ok(PluginResult::CloseChromeTab)
3380 }),
3381 PluginCommand::NavigateChromeTab {
3382 cdp_websocket_url,
3383 target_id,
3384 url,
3385 } => block_on_plugin_future(async move {
3386 let result = navigate_chrome_tab(&cdp_websocket_url, &target_id, &url).await?;
3387 Ok(PluginResult::NavigateChromeTab(result))
3388 }),
3389 PluginCommand::InjectChromiumBidiMapper { cdp_websocket_url } => {
3390 block_on_plugin_future(async move {
3391 let result = inject_chromium_bidi_mapper(&cdp_websocket_url).await?;
3392 Ok(PluginResult::InjectChromiumBidiMapper(result))
3393 })
3394 }
3395 PluginCommand::ResolveBidiContextForTab {
3396 cdp_websocket_url,
3397 mapper_target_id,
3398 browsing_context_id,
3399 url,
3400 } => block_on_plugin_future(async move {
3401 let (resolved_browsing_context_id, mapper) = resolve_bidi_context_for_tab(
3402 &cdp_websocket_url,
3403 mapper_target_id.as_deref(),
3404 browsing_context_id.as_deref(),
3405 url.as_deref(),
3406 )
3407 .await?;
3408 Ok(PluginResult::ResolveBidiContextForTab {
3409 browsing_context_id: resolved_browsing_context_id,
3410 mapper,
3411 })
3412 }),
3413 PluginCommand::ClickElementViaCdp {
3414 cdp_websocket_url,
3415 target_id,
3416 css_selector,
3417 } => block_on_plugin_future(async move {
3418 let result =
3419 click_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3420 Ok(PluginResult::ClickElementViaCdp(result))
3421 }),
3422 PluginCommand::CountElementsViaCdp {
3423 cdp_websocket_url,
3424 target_id,
3425 css_selector,
3426 } => block_on_plugin_future(async move {
3427 let result =
3428 count_elements_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3429 Ok(PluginResult::CountElementsViaCdp(result))
3430 }),
3431 PluginCommand::HighlightElementsViaCdp {
3432 cdp_websocket_url,
3433 target_id,
3434 css_selector,
3435 duration_ms,
3436 } => block_on_plugin_future(async move {
3437 let duration_ms = u32::try_from(duration_ms)
3438 .map_err(|_| format!("highlight duration {duration_ms} exceeds u32"))?;
3439 let result = highlight_elements_via_cdp(
3440 &cdp_websocket_url,
3441 &target_id,
3442 &css_selector,
3443 duration_ms,
3444 )
3445 .await?;
3446 Ok(PluginResult::HighlightElementsViaCdp(result))
3447 }),
3448 PluginCommand::FocusElementViaCdp {
3449 cdp_websocket_url,
3450 target_id,
3451 css_selector,
3452 } => block_on_plugin_future(async move {
3453 let result =
3454 focus_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3455 Ok(PluginResult::FocusElementViaCdp(result))
3456 }),
3457 PluginCommand::FillElementViaCdp {
3458 cdp_websocket_url,
3459 target_id,
3460 css_selector,
3461 value,
3462 } => block_on_plugin_future(async move {
3463 let result =
3464 fill_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector, &value).await?;
3465 Ok(PluginResult::FillElementViaCdp(result))
3466 }),
3467 PluginCommand::HoverElementViaCdp {
3468 cdp_websocket_url,
3469 target_id,
3470 css_selector,
3471 } => block_on_plugin_future(async move {
3472 let result =
3473 hover_element_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3474 Ok(PluginResult::HoverElementViaCdp(result))
3475 }),
3476 PluginCommand::PressKeyViaCdp {
3477 cdp_websocket_url,
3478 target_id,
3479 css_selector,
3480 key,
3481 text,
3482 } => block_on_plugin_future(async move {
3483 let result = press_key_via_cdp(
3484 &cdp_websocket_url,
3485 &target_id,
3486 &css_selector,
3487 &key,
3488 text.as_deref(),
3489 )
3490 .await?;
3491 Ok(PluginResult::PressKeyViaCdp(result))
3492 }),
3493 PluginCommand::GetTextContentViaCdp {
3494 cdp_websocket_url,
3495 target_id,
3496 css_selector,
3497 } => block_on_plugin_future(async move {
3498 let result =
3499 get_text_content_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3500 Ok(PluginResult::GetTextContentViaCdp(result))
3501 }),
3502 PluginCommand::GetInnerTextViaCdp {
3503 cdp_websocket_url,
3504 target_id,
3505 css_selector,
3506 } => block_on_plugin_future(async move {
3507 let result =
3508 get_inner_text_via_cdp(&cdp_websocket_url, &target_id, &css_selector).await?;
3509 Ok(PluginResult::GetInnerTextViaCdp(result))
3510 }),
3511 PluginCommand::WaitForSelectorViaCdp {
3512 cdp_websocket_url,
3513 target_id,
3514 css_selector,
3515 visible,
3516 } => block_on_plugin_future(async move {
3517 let result =
3518 wait_for_selector_via_cdp(&cdp_websocket_url, &target_id, &css_selector, visible)
3519 .await?;
3520 Ok(PluginResult::WaitForSelectorViaCdp(result))
3521 }),
3522 }
3523}
3524
3525#[unsafe(no_mangle)]
3526pub extern "C" fn allwright_plugin_api_version() -> u32 {
3527 ALLWRIGHT_PLUGIN_API_VERSION
3528}
3529
3530#[unsafe(no_mangle)]
3531pub extern "C" fn allwright_plugin_id() -> *const c_char {
3532 c"web".as_ptr()
3533}
3534
3535#[unsafe(no_mangle)]
3536pub unsafe extern "C" fn allwright_plugin_invoke(request_json: *const c_char) -> *mut c_char {
3537 if request_json.is_null() {
3538 return plugin_response(Err("plugin request pointer is null".to_string()));
3539 }
3540
3541 let request = match unsafe { CStr::from_ptr(request_json) }.to_str() {
3542 Ok(request) => request,
3543 Err(error) => {
3544 return plugin_response(Err(format!("plugin request is not valid UTF-8: {error}")));
3545 }
3546 };
3547
3548 let command: PluginCommand = match serde_json::from_str(request) {
3549 Ok(command) => command,
3550 Err(error) => {
3551 return plugin_response(Err(format!("failed to parse plugin request JSON: {error}")));
3552 }
3553 };
3554
3555 plugin_response(handle_plugin_command(command))
3556}
3557
3558#[unsafe(no_mangle)]
3559pub unsafe extern "C" fn allwright_plugin_free_string(value: *mut c_char) {
3560 if !value.is_null() {
3561 unsafe {
3562 let _ = CString::from_raw(value);
3563 }
3564 }
3565}
3566
3567#[cfg(test)]
3568mod tests {
3569 use super::{SelectorKind, parse_selector};
3570
3571 #[test]
3572 fn parse_selector_detects_explicit_xpath_prefix() {
3573 let parsed = parse_selector("click_element", "xpath=//button[normalize-space()='Save']")
3574 .expect("selector should parse");
3575 assert_eq!(parsed.kind, SelectorKind::XPath);
3576 assert_eq!(parsed.value, "//button[normalize-space()='Save']");
3577 }
3578
3579 #[test]
3580 fn parse_selector_detects_xpath_heuristically() {
3581 let parsed =
3582 parse_selector("click_element", "//div[@role='dialog']").expect("selector should parse");
3583 assert_eq!(parsed.kind, SelectorKind::XPath);
3584 }
3585
3586 #[test]
3587 fn parse_selector_preserves_css_by_default() {
3588 let parsed = parse_selector("click_element", "button[data-testid='save']")
3589 .expect("selector should parse");
3590 assert_eq!(parsed.kind, SelectorKind::Css);
3591 assert_eq!(parsed.value, "button[data-testid='save']");
3592 }
3593
3594 #[test]
3595 fn parse_selector_decodes_json_quoted_xpath_payload() {
3596 let parsed = parse_selector(
3597 "click_element",
3598 r#"xpath="//button[normalize-space()='Save Changes']""#,
3599 )
3600 .expect("selector should parse");
3601 assert_eq!(parsed.kind, SelectorKind::XPath);
3602 assert_eq!(parsed.value, "//button[normalize-space()='Save Changes']");
3603 }
3604
3605 #[test]
3606 fn parse_selector_chain_handles_quoted_hybrid_segments() {
3607 let segments = super::parse_selector_chain(
3608 "click_element",
3609 r#"xpath="//div[@role='dialog']" css="button.primary""#,
3610 )
3611 .expect("selector chain should parse");
3612 assert_eq!(segments.len(), 2);
3613 assert_eq!(segments[0].kind, SelectorKind::XPath);
3614 assert_eq!(segments[0].value, "//div[@role='dialog']");
3615 assert_eq!(segments[1].kind, SelectorKind::Css);
3616 assert_eq!(segments[1].value, "button.primary");
3617 }
3618}