1use allwright_plugin_sdk::{SurfaceFamily, SurfacePlugin, SurfacePluginDescriptor};
2use std::fs;
3use std::path::PathBuf;
4use std::process::Command;
5use std::thread;
6use std::time::{Duration as StdDuration, SystemTime, UNIX_EPOCH};
7
8use futures_util::{SinkExt, StreamExt};
9use serde_json::{Value, json};
10use tokio::net::TcpStream;
11use tokio::time::{Duration, sleep};
12use tokio_tungstenite::MaybeTlsStream;
13use tokio_tungstenite::WebSocketStream;
14use tokio_tungstenite::connect_async;
15use tokio_tungstenite::tungstenite::Message;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ChromeLaunchInfo {
19 pub browser: String,
20 pub note: String,
21 pub cdp_websocket_url: String,
22 pub user_data_dir: String,
23 pub process_id: u32,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ChromeTabInfo {
28 pub note: String,
29 pub target_id: String,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct TabNavigationInfo {
34 pub url: String,
35 pub note: String,
36 pub browsing_context_id: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ChromiumBidiMapperInfo {
41 pub package_version: String,
42 pub mapper_target_id: String,
43 pub mapper_session_id: String,
44 pub note: String,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ClickInfo {
49 pub css_selector: String,
50 pub note: String,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ElementCountInfo {
55 pub css_selector: String,
56 pub count: u32,
57 pub note: String,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct HighlightElementsInfo {
62 pub css_selector: String,
63 pub count: u32,
64 pub note: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct FocusInfo {
69 pub css_selector: String,
70 pub note: String,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct FillInfo {
75 pub css_selector: String,
76 pub value: String,
77 pub note: String,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct HoverInfo {
82 pub css_selector: String,
83 pub note: String,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct PressKeyInfo {
88 pub css_selector: String,
89 pub key: String,
90 pub note: String,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct TextInfo {
95 pub css_selector: String,
96 pub text: String,
97 pub note: String,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct WaitForSelectorInfo {
102 pub css_selector: String,
103 pub visible: bool,
104 pub note: String,
105}
106
107#[derive(Debug, Clone, PartialEq)]
108struct DiscoveredElements {
109 count: u32,
110 first_center: Option<ElementCenter>,
111}
112
113#[derive(Debug, Clone, PartialEq)]
114struct ElementCenter {
115 x: f64,
116 y: f64,
117}
118
119const CHROMIUM_BIDI_NPM_VERSION: &str = "17.0.2";
120const CHROMIUM_BIDI_MAPPER_BUNDLE: &str =
121 include_str!("../third_party/chromium-bidi/17.0.2/mapperTab.js");
122
123#[derive(Debug, Clone, Copy, Default)]
124pub struct WebPlugin;
125
126impl SurfacePlugin for WebPlugin {
127 fn descriptor(&self) -> SurfacePluginDescriptor {
128 SurfacePluginDescriptor {
129 id: "web",
130 family: SurfaceFamily::Web,
131 version: env!("CARGO_PKG_VERSION"),
132 description: "Web surface plugin for the allwright engine.",
133 }
134 }
135}
136
137pub fn open_chrome_window(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
138 launch_chrome_for_platform(chrome_binary.filter(|value| !value.trim().is_empty()))
139}
140
141pub async fn discover_initial_tab(cdp_websocket_url: &str) -> Result<ChromeTabInfo, String> {
142 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
143
144 for _ in 0..100 {
145 let targets = cdp
146 .send_command("Target.getTargets", json!({}), None)
147 .await?;
148 if let Some(page_target) = targets
149 .get("targetInfos")
150 .and_then(Value::as_array)
151 .and_then(|target_infos| {
152 target_infos.iter().find(|target_info| {
153 target_info.get("type").and_then(Value::as_str) == Some("page")
154 })
155 })
156 {
157 let target_id = required_string(page_target, "/targetId")?;
158 return Ok(ChromeTabInfo {
159 note: "tracked initial Chrome tab via CDP".to_string(),
160 target_id,
161 });
162 }
163
164 sleep(Duration::from_millis(50)).await;
165 }
166
167 Err("timed out waiting for the initial Chrome page target over CDP".to_string())
168}
169
170pub async fn open_chrome_tab(cdp_websocket_url: &str) -> Result<ChromeTabInfo, String> {
171 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
172 let create_target = cdp
173 .send_command(
174 "Target.createTarget",
175 json!({
176 "url": "about:blank",
177 "background": false,
178 }),
179 None,
180 )
181 .await?;
182 let target_id = required_string(&create_target, "/targetId")?;
183
184 Ok(ChromeTabInfo {
185 note: "opened Chrome tab via CDP".to_string(),
186 target_id,
187 })
188}
189
190pub fn close_browser_process(process_id: u32) -> Result<(), String> {
191 close_browser_process_for_platform(process_id)
192}
193
194pub async fn close_chrome_tab(cdp_websocket_url: &str, target_id: &str) -> Result<(), String> {
195 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
196 let response = cdp
197 .send_command(
198 "Target.closeTarget",
199 json!({
200 "targetId": target_id,
201 }),
202 None,
203 )
204 .await?;
205 let success = response
206 .get("success")
207 .and_then(Value::as_bool)
208 .unwrap_or(false);
209 if success {
210 Ok(())
211 } else {
212 Err(format!(
213 "CDP Target.closeTarget reported unsuccessful close for target {target_id}"
214 ))
215 }
216}
217
218pub async fn navigate_chrome_tab(
219 cdp_websocket_url: &str,
220 target_id: &str,
221 url: &str,
222) -> Result<TabNavigationInfo, String> {
223 if url.trim().is_empty() {
224 return Err("navigate command requires a non-empty url".to_string());
225 }
226
227 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
228 let attach = cdp
229 .send_command(
230 "Target.attachToTarget",
231 json!({
232 "targetId": target_id,
233 "flatten": true,
234 }),
235 None,
236 )
237 .await?;
238 let session_id = required_string(&attach, "/sessionId")?;
239
240 cdp.send_command("Page.enable", json!({}), Some(&session_id))
241 .await?;
242 cdp.navigate_and_wait_for_load(&session_id, url).await?;
243 let frame_tree = cdp
244 .send_command("Page.getFrameTree", json!({}), Some(&session_id))
245 .await?;
246 let browsing_context_id = required_string(&frame_tree, "/frameTree/frame/id")?;
247 cdp.send_command(
248 "Target.detachFromTarget",
249 json!({
250 "sessionId": session_id,
251 }),
252 None,
253 )
254 .await?;
255
256 Ok(TabNavigationInfo {
257 url: url.to_string(),
258 note: "navigated Chrome tab via CDP and observed Page.loadEventFired".to_string(),
259 browsing_context_id,
260 })
261}
262
263pub async fn inject_chromium_bidi_mapper(
264 cdp_websocket_url: &str,
265) -> Result<ChromiumBidiMapperInfo, String> {
266 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
267 let mapper_target = cdp
268 .send_command(
269 "Target.createTarget",
270 json!({
271 "url": "about:blank#MAPPER_TARGET",
272 "hidden": true,
273 "background": true,
274 }),
275 None,
276 )
277 .await?;
278 let mapper_target_id = required_string(&mapper_target, "/targetId")?;
279
280 let attached = cdp
281 .send_command(
282 "Target.attachToTarget",
283 json!({
284 "targetId": mapper_target_id,
285 "flatten": true,
286 }),
287 None,
288 )
289 .await?;
290 let mapper_session_id = required_string(&attached, "/sessionId")?;
291
292 cdp.send_command("Runtime.enable", json!({}), Some(&mapper_session_id))
293 .await?;
294 cdp.send_command(
295 "Target.exposeDevToolsProtocol",
296 json!({
297 "bindingName": "cdp",
298 "targetId": mapper_target_id,
299 "inheritPermissions": true,
300 }),
301 None,
302 )
303 .await?;
304 cdp.send_command(
305 "Runtime.addBinding",
306 json!({
307 "name": "sendBidiResponse",
308 }),
309 Some(&mapper_session_id),
310 )
311 .await?;
312 cdp.evaluate_expression(&mapper_session_id, CHROMIUM_BIDI_MAPPER_BUNDLE, false)
313 .await?;
314 cdp.evaluate_expression(
315 &mapper_session_id,
316 &format!(
317 "window.runMapperInstance('{}')",
318 js_single_quote(&mapper_target_id)
319 ),
320 true,
321 )
322 .await?;
323
324 Ok(ChromiumBidiMapperInfo {
325 package_version: CHROMIUM_BIDI_NPM_VERSION.to_string(),
326 mapper_target_id,
327 mapper_session_id,
328 note: format!(
329 "injected chromium-bidi mapper from pinned published chromium-bidi@{} artifact into hidden mapper target",
330 CHROMIUM_BIDI_NPM_VERSION
331 ),
332 })
333}
334
335pub async fn click_element_via_cdp(
336 cdp_websocket_url: &str,
337 target_id: &str,
338 css_selector: &str,
339) -> Result<ClickInfo, String> {
340 if css_selector.trim().is_empty() {
341 return Err("click_element command requires a non-empty css_selector".to_string());
342 }
343
344 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
345 let session_id = cdp.prepare_page_target_session(target_id).await?;
346 let discovered =
347 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
348 let center = discovered.first_center.ok_or_else(|| {
349 format!("element discovery did not return a clickable center for selector {css_selector}")
350 })?;
351 cdp.dispatch_mouse_click(&session_id, center.x, center.y)
352 .await?;
353 cdp.detach_from_target(&session_id).await?;
354
355 Ok(ClickInfo {
356 css_selector: css_selector.to_string(),
357 note: format!("clicked element via CDP mouse events using css selector {css_selector}"),
358 })
359}
360
361pub async fn count_elements_via_cdp(
362 cdp_websocket_url: &str,
363 target_id: &str,
364 css_selector: &str,
365) -> Result<ElementCountInfo, String> {
366 if css_selector.trim().is_empty() {
367 return Err("count_elements command requires a non-empty css_selector".to_string());
368 }
369
370 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
371 let session_id = cdp.prepare_page_target_session(target_id).await?;
372 let discovered =
373 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, false, false).await?;
374 cdp.detach_from_target(&session_id).await?;
375
376 Ok(ElementCountInfo {
377 css_selector: css_selector.to_string(),
378 count: discovered.count,
379 note: format!(
380 "counted {} element(s) matching css selector {css_selector}",
381 discovered.count
382 ),
383 })
384}
385
386pub async fn highlight_elements_via_cdp(
387 cdp_websocket_url: &str,
388 target_id: &str,
389 css_selector: &str,
390 duration_ms: u32,
391) -> Result<HighlightElementsInfo, String> {
392 if css_selector.trim().is_empty() {
393 return Err("highlight_elements command requires a non-empty css_selector".to_string());
394 }
395
396 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
397 let session_id = cdp.prepare_page_target_session(target_id).await?;
398 let discovered =
399 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, false, false).await?;
400
401 let selector_literal = serde_json::to_string(css_selector)
402 .map_err(|error| format!("failed to serialize css selector for highlight: {error}"))?;
403 let duration_ms = duration_ms.max(1);
404 cdp.evaluate_expression(
405 &session_id,
406 &format!(
407 "(() => {{
408 const selector = {selector_literal};
409 const durationMs = {duration_ms};
410 const elements = Array.from(document.querySelectorAll(selector));
411 for (const element of elements) {{
412 if (!(element instanceof HTMLElement)) {{
413 continue;
414 }}
415 element.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
416 const priorOutline = element.style.outline;
417 const priorOutlineOffset = element.style.outlineOffset;
418 const priorBackgroundColor = element.style.backgroundColor;
419 element.style.outline = '3px solid #ff5a36';
420 element.style.outlineOffset = '2px';
421 element.style.backgroundColor = 'rgba(255, 235, 59, 0.35)';
422 window.setTimeout(() => {{
423 element.style.outline = priorOutline;
424 element.style.outlineOffset = priorOutlineOffset;
425 element.style.backgroundColor = priorBackgroundColor;
426 }}, durationMs);
427 }}
428 return elements.length;
429 }})()"
430 ),
431 true,
432 )
433 .await?;
434 cdp.detach_from_target(&session_id).await?;
435
436 Ok(HighlightElementsInfo {
437 css_selector: css_selector.to_string(),
438 count: discovered.count,
439 note: format!(
440 "highlighted {} element(s) matching css selector {css_selector} for {duration_ms}ms",
441 discovered.count
442 ),
443 })
444}
445
446pub async fn focus_element_via_cdp(
447 cdp_websocket_url: &str,
448 target_id: &str,
449 css_selector: &str,
450) -> Result<FocusInfo, String> {
451 require_non_empty_selector("focus_element", css_selector)?;
452 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
453 let session_id = cdp.prepare_page_target_session(target_id).await?;
454 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
455 cdp.detach_from_target(&session_id).await?;
456
457 Ok(FocusInfo {
458 css_selector: css_selector.to_string(),
459 note: format!("focused element matching css selector {css_selector}"),
460 })
461}
462
463pub async fn fill_element_via_cdp(
464 cdp_websocket_url: &str,
465 target_id: &str,
466 css_selector: &str,
467 value: &str,
468) -> Result<FillInfo, String> {
469 require_non_empty_selector("fill_element", css_selector)?;
470 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
471 let session_id = cdp.prepare_page_target_session(target_id).await?;
472 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
473 let selector_literal = json_string_literal(css_selector, "fill selector")?;
474 let value_literal = json_string_literal(value, "fill value")?;
475 cdp.evaluate_expression(
476 &session_id,
477 &format!(
478 "(() => {{
479 const selector = {selector_literal};
480 const value = {value_literal};
481 const element = document.querySelector(selector);
482 if (!element) {{
483 throw new Error(`No element matches selector: ${{selector}}`);
484 }}
485 if (!(element instanceof HTMLElement)) {{
486 throw new Error(`Element is not focusable: ${{selector}}`);
487 }}
488 element.focus();
489 if ('value' in element) {{
490 element.value = value;
491 }} else if (element.isContentEditable) {{
492 element.textContent = value;
493 }} else {{
494 throw new Error(`Element does not support fill: ${{selector}}`);
495 }}
496 element.dispatchEvent(new Event('input', {{ bubbles: true }}));
497 element.dispatchEvent(new Event('change', {{ bubbles: true }}));
498 return true;
499 }})()"
500 ),
501 true,
502 )
503 .await?;
504 cdp.detach_from_target(&session_id).await?;
505
506 Ok(FillInfo {
507 css_selector: css_selector.to_string(),
508 value: value.to_string(),
509 note: format!("filled element matching css selector {css_selector}"),
510 })
511}
512
513pub async fn hover_element_via_cdp(
514 cdp_websocket_url: &str,
515 target_id: &str,
516 css_selector: &str,
517) -> Result<HoverInfo, String> {
518 require_non_empty_selector("hover_element", css_selector)?;
519 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
520 let session_id = cdp.prepare_page_target_session(target_id).await?;
521 let discovered =
522 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, false).await?;
523 let center = discovered.first_center.ok_or_else(|| {
524 format!("element discovery did not return a hover center for selector {css_selector}")
525 })?;
526 cdp.dispatch_mouse_move(&session_id, center.x, center.y)
527 .await?;
528 cdp.detach_from_target(&session_id).await?;
529
530 Ok(HoverInfo {
531 css_selector: css_selector.to_string(),
532 note: format!("hovered element matching css selector {css_selector}"),
533 })
534}
535
536pub async fn press_key_via_cdp(
537 cdp_websocket_url: &str,
538 target_id: &str,
539 css_selector: &str,
540 key: &str,
541 text: Option<&str>,
542) -> Result<PressKeyInfo, String> {
543 require_non_empty_selector("press_key", css_selector)?;
544 if key.trim().is_empty() {
545 return Err("press_key command requires a non-empty key".to_string());
546 }
547
548 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
549 let session_id = cdp.prepare_page_target_session(target_id).await?;
550 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, true).await?;
551 cdp.dispatch_key_press(&session_id, key, text).await?;
552 cdp.detach_from_target(&session_id).await?;
553
554 Ok(PressKeyInfo {
555 css_selector: css_selector.to_string(),
556 key: key.to_string(),
557 note: format!("pressed key {key} on element matching css selector {css_selector}"),
558 })
559}
560
561pub async fn get_text_content_via_cdp(
562 cdp_websocket_url: &str,
563 target_id: &str,
564 css_selector: &str,
565) -> Result<TextInfo, String> {
566 get_text_via_cdp(cdp_websocket_url, target_id, css_selector, "textContent")
567 .await
568 .map(|mut info| {
569 info.note = format!("resolved textContent for css selector {css_selector}");
570 info
571 })
572}
573
574pub async fn get_inner_text_via_cdp(
575 cdp_websocket_url: &str,
576 target_id: &str,
577 css_selector: &str,
578) -> Result<TextInfo, String> {
579 get_text_via_cdp(cdp_websocket_url, target_id, css_selector, "innerText")
580 .await
581 .map(|mut info| {
582 info.note = format!("resolved innerText for css selector {css_selector}");
583 info
584 })
585}
586
587pub async fn wait_for_selector_via_cdp(
588 cdp_websocket_url: &str,
589 target_id: &str,
590 css_selector: &str,
591 visible: bool,
592) -> Result<WaitForSelectorInfo, String> {
593 require_non_empty_selector("wait_for_selector", css_selector)?;
594 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
595 let session_id = cdp.prepare_page_target_session(target_id).await?;
596 let discovered =
597 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, false, false).await?;
598 let success = if visible {
599 discovered.first_center.is_some()
600 } else {
601 discovered.count > 0
602 };
603 cdp.detach_from_target(&session_id).await?;
604
605 if !success {
606 return Err(if visible {
607 format!("no visible element matches css selector {css_selector}")
608 } else {
609 format!("no element matches css selector {css_selector}")
610 });
611 }
612
613 Ok(WaitForSelectorInfo {
614 css_selector: css_selector.to_string(),
615 visible,
616 note: if visible {
617 format!("visible element matched css selector {css_selector}")
618 } else {
619 format!("element matched css selector {css_selector}")
620 },
621 })
622}
623
624async fn discover_elements_via_cdp(
625 cdp: &mut CdpConnection,
626 session_id: &str,
627 css_selector: &str,
628 require_match: bool,
629 focus_first_match: bool,
630) -> Result<DiscoveredElements, String> {
631 let selector_literal = serde_json::to_string(css_selector)
632 .map_err(|error| format!("failed to serialize css selector for discovery: {error}"))?;
633 let expression = format!(
634 "(() => {{
635 const selector = {selector_literal};
636 const elements = Array.from(document.querySelectorAll(selector));
637 const first = elements[0] ?? null;
638 if (first) {{
639 first.scrollIntoView({{ block: 'center', inline: 'center', behavior: 'instant' }});
640 if ({focus_first_match} && first instanceof HTMLElement) {{
641 first.focus();
642 }}
643 }}
644 if ({require_match} && !first) {{
645 throw new Error(`No element matches selector: ${{selector}}`);
646 }}
647 if (first && !(first instanceof Element)) {{
648 throw new Error(`Selector did not resolve to a DOM Element: ${{selector}}`);
649 }}
650 const rect = first ? first.getBoundingClientRect() : null;
651 if (first && (!rect.width || !rect.height)) {{
652 throw new Error(`Element matched by selector has zero size: ${{selector}}`);
653 }}
654 return {{
655 count: elements.length,
656 first: rect ? {{
657 x: rect.left + (rect.width / 2),
658 y: rect.top + (rect.height / 2)
659 }} : null
660 }};
661 }})()"
662 );
663 let result = cdp
664 .evaluate_expression(session_id, &expression, true)
665 .await?;
666 let count = json_u32(&result, "/result/value/count")?;
667 let first_center = if result.pointer("/result/value/first").is_some()
668 && !result
669 .pointer("/result/value/first")
670 .is_some_and(Value::is_null)
671 {
672 Some(ElementCenter {
673 x: json_f64(&result, "/result/value/first/x")?,
674 y: json_f64(&result, "/result/value/first/y")?,
675 })
676 } else {
677 None
678 };
679
680 Ok(DiscoveredElements {
681 count,
682 first_center,
683 })
684}
685
686async fn get_text_via_cdp(
687 cdp_websocket_url: &str,
688 target_id: &str,
689 css_selector: &str,
690 property: &str,
691) -> Result<TextInfo, String> {
692 require_non_empty_selector("get_text", css_selector)?;
693 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
694 let session_id = cdp.prepare_page_target_session(target_id).await?;
695 discover_elements_via_cdp(&mut cdp, &session_id, css_selector, true, false).await?;
696 let selector_literal = json_string_literal(css_selector, "text selector")?;
697 let property_literal = json_string_literal(property, "text property")?;
698 let result = cdp
699 .evaluate_expression(
700 &session_id,
701 &format!(
702 "(() => {{
703 const selector = {selector_literal};
704 const property = {property_literal};
705 const element = document.querySelector(selector);
706 if (!element) {{
707 throw new Error(`No element matches selector: ${{selector}}`);
708 }}
709 const value = element[property];
710 return typeof value === 'string' ? value : '';
711 }})()"
712 ),
713 true,
714 )
715 .await?;
716 cdp.detach_from_target(&session_id).await?;
717
718 Ok(TextInfo {
719 css_selector: css_selector.to_string(),
720 text: json_string(&result, "/result/value")?,
721 note: String::new(),
722 })
723}
724
725pub async fn resolve_bidi_context_for_tab(
726 cdp_websocket_url: &str,
727 existing_mapper_target_id: Option<&str>,
728 existing_context_id: Option<&str>,
729 current_url: Option<&str>,
730) -> Result<(String, ChromiumBidiMapperInfo), String> {
731 let mut cdp = CdpConnection::connect(cdp_websocket_url).await?;
732 let mapper = cdp
733 .ensure_chromium_bidi_mapper(existing_mapper_target_id)
734 .await?;
735 let context_id = cdp
736 .resolve_bidi_context_id(&mapper.mapper_session_id, existing_context_id, current_url)
737 .await?;
738
739 Ok((
740 context_id,
741 ChromiumBidiMapperInfo {
742 package_version: mapper.package_version,
743 mapper_target_id: mapper.mapper_target_id,
744 mapper_session_id: mapper.mapper_session_id,
745 note: "resolved BiDi browsing context for tab".to_string(),
746 },
747 ))
748}
749
750#[cfg(target_os = "macos")]
751fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
752 let browser_binary = chrome_binary.map(ToOwned::to_owned).unwrap_or_else(|| {
753 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".to_string()
754 });
755 launch_chrome_with_cdp(&browser_binary)
756}
757
758#[cfg(target_os = "linux")]
759fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
760 if let Some(chrome_binary) = chrome_binary {
761 return launch_chrome_with_cdp(chrome_binary);
762 }
763
764 let candidates = ["google-chrome", "chromium", "chromium-browser"];
765
766 for candidate in candidates {
767 match launch_chrome_with_cdp(candidate) {
768 Ok(launch_info) => return Ok(launch_info),
769 Err(error) => {
770 if error.contains("No such file or directory")
771 || error.contains("os error 2")
772 || error.contains("not found")
773 {
774 continue;
775 }
776 return Err(format!("failed to launch {candidate}: {error}"));
777 }
778 }
779 }
780
781 Err("could not find a Chrome-compatible browser binary".to_string())
782}
783
784#[cfg(target_os = "windows")]
785fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
786 if let Some(chrome_binary) = chrome_binary {
787 return launch_chrome_with_cdp(chrome_binary);
788 }
789
790 launch_chrome_with_cdp("chrome")
791}
792
793#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
794fn launch_chrome_for_platform(chrome_binary: Option<&str>) -> Result<ChromeLaunchInfo, String> {
795 match chrome_binary {
796 Some(chrome_binary) => launch_chrome_with_cdp(chrome_binary),
797 None => Err("Chrome launching is not supported on this platform yet".to_string()),
798 }
799}
800
801fn launch_chrome_with_cdp(browser_binary: &str) -> Result<ChromeLaunchInfo, String> {
802 let user_data_dir = create_chrome_user_data_dir()?;
803 let user_data_dir_str = user_data_dir.to_string_lossy().to_string();
804
805 let child = Command::new(browser_binary)
806 .args([
807 "--new-window",
808 "--remote-debugging-port=0",
809 "--no-first-run",
810 "--no-default-browser-check",
811 "--disable-sync",
812 &format!("--user-data-dir={user_data_dir_str}"),
813 ])
814 .spawn()
815 .map_err(|error| format!("failed to launch Chrome binary {browser_binary}: {error}"))?;
816 let process_id = child.id();
817
818 let cdp_websocket_url = wait_for_cdp_endpoint(&user_data_dir)?;
819
820 Ok(ChromeLaunchInfo {
821 browser: browser_binary.to_string(),
822 note: "launched Chrome window with CDP enabled".to_string(),
823 cdp_websocket_url,
824 user_data_dir: user_data_dir_str,
825 process_id,
826 })
827}
828
829fn create_chrome_user_data_dir() -> Result<PathBuf, String> {
830 let timestamp = SystemTime::now()
831 .duration_since(UNIX_EPOCH)
832 .map_err(|error| format!("failed to compute timestamp: {error}"))?
833 .as_millis();
834 let pid = std::process::id();
835 let dir = std::env::temp_dir().join(format!("allwright-chrome-{pid}-{timestamp}"));
836 fs::create_dir_all(&dir).map_err(|error| {
837 format!(
838 "failed to create Chrome user data dir {}: {error}",
839 dir.display()
840 )
841 })?;
842 Ok(dir)
843}
844
845fn wait_for_cdp_endpoint(user_data_dir: &std::path::Path) -> Result<String, String> {
846 let active_port_file = user_data_dir.join("DevToolsActivePort");
847
848 for _ in 0..100 {
849 if active_port_file.exists() {
850 let contents = fs::read_to_string(&active_port_file).map_err(|error| {
851 format!(
852 "failed to read DevToolsActivePort file {}: {error}",
853 active_port_file.display()
854 )
855 })?;
856 return parse_devtools_active_port(&contents);
857 }
858 thread::sleep(StdDuration::from_millis(50));
859 }
860
861 Err(format!(
862 "timed out waiting for DevToolsActivePort in {}",
863 user_data_dir.display()
864 ))
865}
866
867fn parse_devtools_active_port(contents: &str) -> Result<String, String> {
868 let mut lines = contents.lines();
869 let port = lines
870 .next()
871 .map(str::trim)
872 .filter(|value| !value.is_empty())
873 .ok_or_else(|| "DevToolsActivePort missing port line".to_string())?;
874 let browser_path = lines
875 .next()
876 .map(str::trim)
877 .filter(|value| !value.is_empty())
878 .ok_or_else(|| "DevToolsActivePort missing websocket path line".to_string())?;
879
880 Ok(format!("ws://127.0.0.1:{port}{browser_path}"))
881}
882
883pub async fn boot() -> String {
884 sleep(Duration::from_millis(15)).await;
885 "web ready".to_string()
886}
887
888#[cfg(unix)]
889fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
890 let status = Command::new("kill")
891 .args(["-TERM", &process_id.to_string()])
892 .status()
893 .map_err(|error| format!("failed to send TERM to process {process_id}: {error}"))?;
894
895 if status.success() {
896 Ok(())
897 } else {
898 Err(format!(
899 "kill -TERM returned non-zero exit status for process {process_id}: {status}"
900 ))
901 }
902}
903
904#[cfg(windows)]
905fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
906 let status = Command::new("taskkill")
907 .args(["/PID", &process_id.to_string(), "/T", "/F"])
908 .status()
909 .map_err(|error| format!("failed to terminate process {process_id}: {error}"))?;
910
911 if status.success() {
912 Ok(())
913 } else {
914 Err(format!(
915 "taskkill returned non-zero exit status for process {process_id}: {status}"
916 ))
917 }
918}
919
920#[cfg(not(any(unix, windows)))]
921fn close_browser_process_for_platform(process_id: u32) -> Result<(), String> {
922 Err(format!(
923 "browser process termination is not implemented for process {process_id} on this platform"
924 ))
925}
926
927type CdpSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
928
929struct MapperConnectionInfo {
930 package_version: String,
931 mapper_target_id: String,
932 mapper_session_id: String,
933}
934
935struct CdpConnection {
936 socket: CdpSocket,
937 next_id: u64,
938}
939
940impl CdpConnection {
941 async fn connect(cdp_websocket_url: &str) -> Result<Self, String> {
942 let (socket, _) = connect_async(cdp_websocket_url).await.map_err(|error| {
943 format!("failed to connect to CDP websocket {cdp_websocket_url}: {error}")
944 })?;
945 Ok(Self { socket, next_id: 1 })
946 }
947
948 async fn send_command(
949 &mut self,
950 method: &str,
951 params: Value,
952 session_id: Option<&str>,
953 ) -> Result<Value, String> {
954 let id = self.next_id;
955 self.next_id += 1;
956
957 let mut payload = json!({
958 "id": id,
959 "method": method,
960 "params": params,
961 });
962 if let Some(session_id) = session_id {
963 payload["sessionId"] = Value::String(session_id.to_string());
964 }
965
966 self.socket
967 .send(Message::Text(payload.to_string().into()))
968 .await
969 .map_err(|error| format!("failed to send CDP command {method}: {error}"))?;
970
971 loop {
972 let message = self.next_json_message().await?;
973 if message.get("id").and_then(Value::as_u64) != Some(id) {
974 continue;
975 }
976
977 if let Some(error) = message.get("error") {
978 return Err(format!("CDP command {method} failed: {error}"));
979 }
980
981 return Ok(message.get("result").cloned().unwrap_or(Value::Null));
982 }
983 }
984
985 async fn evaluate_expression(
986 &mut self,
987 session_id: &str,
988 expression: &str,
989 await_promise: bool,
990 ) -> Result<Value, String> {
991 let result = self
992 .send_command(
993 "Runtime.evaluate",
994 json!({
995 "expression": expression,
996 "awaitPromise": await_promise,
997 "returnByValue": true,
998 }),
999 Some(session_id),
1000 )
1001 .await?;
1002 if let Some(exception_details) = result.get("exceptionDetails") {
1003 return Err(format!(
1004 "Runtime.evaluate failed with exception details: {exception_details}"
1005 ));
1006 }
1007 Ok(result)
1008 }
1009
1010 async fn attach_to_target(&mut self, target_id: &str) -> Result<String, String> {
1011 let attached = self
1012 .send_command(
1013 "Target.attachToTarget",
1014 json!({
1015 "targetId": target_id,
1016 "flatten": true,
1017 }),
1018 None,
1019 )
1020 .await?;
1021 required_string(&attached, "/sessionId")
1022 }
1023
1024 async fn prepare_page_target_session(&mut self, target_id: &str) -> Result<String, String> {
1025 let session_id = self.attach_to_target(target_id).await?;
1026 self.send_command("Runtime.enable", json!({}), Some(&session_id))
1027 .await?;
1028 self.send_command("Page.enable", json!({}), Some(&session_id))
1029 .await?;
1030 Ok(session_id)
1031 }
1032
1033 async fn detach_from_target(&mut self, session_id: &str) -> Result<(), String> {
1034 self.send_command(
1035 "Target.detachFromTarget",
1036 json!({
1037 "sessionId": session_id,
1038 }),
1039 None,
1040 )
1041 .await?;
1042 Ok(())
1043 }
1044
1045 async fn dispatch_mouse_click(
1046 &mut self,
1047 session_id: &str,
1048 x: f64,
1049 y: f64,
1050 ) -> Result<(), String> {
1051 self.dispatch_mouse_move(session_id, x, y).await?;
1052 self.send_command(
1053 "Input.dispatchMouseEvent",
1054 json!({
1055 "type": "mousePressed",
1056 "x": x,
1057 "y": y,
1058 "button": "left",
1059 "buttons": 1,
1060 "clickCount": 1,
1061 }),
1062 Some(session_id),
1063 )
1064 .await?;
1065 self.send_command(
1066 "Input.dispatchMouseEvent",
1067 json!({
1068 "type": "mouseReleased",
1069 "x": x,
1070 "y": y,
1071 "button": "left",
1072 "buttons": 0,
1073 "clickCount": 1,
1074 }),
1075 Some(session_id),
1076 )
1077 .await?;
1078 Ok(())
1079 }
1080
1081 async fn dispatch_mouse_move(
1082 &mut self,
1083 session_id: &str,
1084 x: f64,
1085 y: f64,
1086 ) -> Result<(), String> {
1087 self.send_command(
1088 "Input.dispatchMouseEvent",
1089 json!({
1090 "type": "mouseMoved",
1091 "x": x,
1092 "y": y,
1093 "button": "none",
1094 "buttons": 0,
1095 }),
1096 Some(session_id),
1097 )
1098 .await?;
1099 Ok(())
1100 }
1101
1102 async fn dispatch_key_press(
1103 &mut self,
1104 session_id: &str,
1105 key: &str,
1106 text: Option<&str>,
1107 ) -> Result<(), String> {
1108 self.send_command(
1109 "Input.dispatchKeyEvent",
1110 json!({
1111 "type": "keyDown",
1112 "key": key,
1113 "text": text.unwrap_or(""),
1114 }),
1115 Some(session_id),
1116 )
1117 .await?;
1118 self.send_command(
1119 "Input.dispatchKeyEvent",
1120 json!({
1121 "type": "keyUp",
1122 "key": key,
1123 "text": text.unwrap_or(""),
1124 }),
1125 Some(session_id),
1126 )
1127 .await?;
1128 Ok(())
1129 }
1130
1131 async fn ensure_chromium_bidi_mapper(
1132 &mut self,
1133 existing_mapper_target_id: Option<&str>,
1134 ) -> Result<MapperConnectionInfo, String> {
1135 let (mut mapper_target_id, mut created_target) = match existing_mapper_target_id {
1136 Some(existing_mapper_target_id) if !existing_mapper_target_id.trim().is_empty() => {
1137 (existing_mapper_target_id.to_string(), false)
1138 }
1139 _ => (self.create_bidi_mapper_target().await?, true),
1140 };
1141
1142 let attached = match self
1143 .send_command(
1144 "Target.attachToTarget",
1145 json!({
1146 "targetId": mapper_target_id,
1147 "flatten": true,
1148 }),
1149 None,
1150 )
1151 .await
1152 {
1153 Ok(attached) => attached,
1154 Err(error)
1155 if !created_target
1156 && error.contains("Target.attachToTarget failed")
1157 && error.contains("No target with given id found") =>
1158 {
1159 mapper_target_id = self.create_bidi_mapper_target().await?;
1160 created_target = true;
1161 self.send_command(
1162 "Target.attachToTarget",
1163 json!({
1164 "targetId": mapper_target_id,
1165 "flatten": true,
1166 }),
1167 None,
1168 )
1169 .await?
1170 }
1171 Err(error) => return Err(error),
1172 };
1173 let mapper_session_id = required_string(&attached, "/sessionId")?;
1174
1175 self.send_command("Runtime.enable", json!({}), Some(&mapper_session_id))
1176 .await?;
1177 self.send_command(
1178 "Target.exposeDevToolsProtocol",
1179 json!({
1180 "bindingName": "cdp",
1181 "targetId": mapper_target_id,
1182 "inheritPermissions": true,
1183 }),
1184 None,
1185 )
1186 .await?;
1187 self.ensure_runtime_binding(&mapper_session_id, "sendBidiResponse")
1188 .await?;
1189
1190 if created_target
1191 || !self
1192 .mapper_runtime_is_initialized(&mapper_session_id)
1193 .await?
1194 {
1195 self.evaluate_expression(&mapper_session_id, CHROMIUM_BIDI_MAPPER_BUNDLE, false)
1196 .await?;
1197 self.evaluate_expression(
1198 &mapper_session_id,
1199 &format!(
1200 "window.runMapperInstance('{}')",
1201 js_single_quote(&mapper_target_id)
1202 ),
1203 true,
1204 )
1205 .await?;
1206 self.send_bidi_command(
1207 &mapper_session_id,
1208 &json!({
1209 "id": 1,
1210 "method": "session.new",
1211 "params": {
1212 "capabilities": {}
1213 }
1214 }),
1215 )
1216 .await?;
1217 }
1218
1219 Ok(MapperConnectionInfo {
1220 package_version: CHROMIUM_BIDI_NPM_VERSION.to_string(),
1221 mapper_target_id,
1222 mapper_session_id,
1223 })
1224 }
1225
1226 async fn create_bidi_mapper_target(&mut self) -> Result<String, String> {
1227 let created = self
1228 .send_command(
1229 "Target.createTarget",
1230 json!({
1231 "url": "about:blank#MAPPER_TARGET",
1232 "hidden": true,
1233 "background": true,
1234 }),
1235 None,
1236 )
1237 .await?;
1238 required_string(&created, "/targetId")
1239 }
1240
1241 async fn ensure_runtime_binding(&mut self, session_id: &str, name: &str) -> Result<(), String> {
1242 match self
1243 .send_command(
1244 "Runtime.addBinding",
1245 json!({
1246 "name": name,
1247 }),
1248 Some(session_id),
1249 )
1250 .await
1251 {
1252 Ok(_) => Ok(()),
1253 Err(error)
1254 if error.contains("already exists") || error.contains("Cannot add binding") =>
1255 {
1256 Ok(())
1257 }
1258 Err(error) => Err(error),
1259 }
1260 }
1261
1262 async fn mapper_runtime_is_initialized(&mut self, session_id: &str) -> Result<bool, String> {
1263 let result = self
1264 .evaluate_expression(
1265 session_id,
1266 "typeof window.runMapperInstance === 'function' && typeof window.onBidiMessage === 'function'",
1267 true,
1268 )
1269 .await?;
1270 Ok(result
1271 .pointer("/result/value")
1272 .and_then(Value::as_bool)
1273 .unwrap_or(false))
1274 }
1275
1276 async fn send_bidi_command(
1277 &mut self,
1278 mapper_session_id: &str,
1279 command: &Value,
1280 ) -> Result<Value, String> {
1281 let command_id = command
1282 .get("id")
1283 .and_then(Value::as_u64)
1284 .ok_or_else(|| "BiDi command is missing numeric id".to_string())?;
1285 let eval_id = self.next_id;
1286 self.next_id += 1;
1287 let command_json = command.to_string();
1288 let expression = format!(
1289 "onBidiMessage({})",
1290 serde_json::to_string(&command_json)
1291 .map_err(|error| format!("failed to encode BiDi command payload: {error}"))?
1292 );
1293
1294 let payload = json!({
1295 "id": eval_id,
1296 "method": "Runtime.evaluate",
1297 "params": {
1298 "expression": expression,
1299 "awaitPromise": true,
1300 },
1301 "sessionId": mapper_session_id,
1302 });
1303 self.socket
1304 .send(Message::Text(payload.to_string().into()))
1305 .await
1306 .map_err(|error| format!("failed to send mapper Runtime.evaluate call: {error}"))?;
1307
1308 loop {
1309 let message = self.next_json_message().await?;
1310
1311 if message.get("id").and_then(Value::as_u64) == Some(eval_id) {
1312 if let Some(error) = message.get("error") {
1313 return Err(format!("mapper Runtime.evaluate failed: {error}"));
1314 }
1315 if let Some(exception_details) = message.pointer("/result/exceptionDetails") {
1316 return Err(format!(
1317 "mapper Runtime.evaluate raised exception details: {exception_details}"
1318 ));
1319 }
1320 continue;
1321 }
1322
1323 if message.get("sessionId").and_then(Value::as_str) != Some(mapper_session_id) {
1324 continue;
1325 }
1326
1327 if message.get("method").and_then(Value::as_str) != Some("Runtime.bindingCalled") {
1328 continue;
1329 }
1330
1331 let params = message
1332 .get("params")
1333 .ok_or_else(|| "Runtime.bindingCalled message is missing params".to_string())?;
1334 if params.get("name").and_then(Value::as_str) != Some("sendBidiResponse") {
1335 continue;
1336 }
1337
1338 let payload = params
1339 .get("payload")
1340 .and_then(Value::as_str)
1341 .ok_or_else(|| "Runtime.bindingCalled payload is missing".to_string())?;
1342 let response = serde_json::from_str::<Value>(payload)
1343 .map_err(|error| format!("failed to parse BiDi mapper response JSON: {error}"))?;
1344 if response.get("id").and_then(Value::as_u64) != Some(command_id) {
1345 continue;
1346 }
1347
1348 match response.get("type").and_then(Value::as_str) {
1349 Some("success") => return Ok(response),
1350 Some("error") => {
1351 let message = response
1352 .get("message")
1353 .and_then(Value::as_str)
1354 .unwrap_or("unknown BiDi mapper error");
1355 let error = response
1356 .get("error")
1357 .and_then(Value::as_str)
1358 .unwrap_or("unknown error");
1359 return Err(format!("BiDi mapper returned {error}: {message}"));
1360 }
1361 Some(other) => {
1362 return Err(format!("unexpected BiDi response type {other}: {response}"));
1363 }
1364 None => {
1365 return Err(format!("BiDi mapper response is missing type: {response}"));
1366 }
1367 }
1368 }
1369 }
1370
1371 async fn resolve_bidi_context_id(
1372 &mut self,
1373 mapper_session_id: &str,
1374 existing_context_id: Option<&str>,
1375 current_url: Option<&str>,
1376 ) -> Result<String, String> {
1377 if let Some(existing_context_id) = existing_context_id {
1378 if !existing_context_id.trim().is_empty() {
1379 let probe = json!({
1380 "id": 1,
1381 "method": "browsingContext.getTree",
1382 "params": {
1383 "root": existing_context_id,
1384 "maxDepth": 0,
1385 }
1386 });
1387 match self.send_bidi_command(mapper_session_id, &probe).await {
1388 Ok(_) => return Ok(existing_context_id.to_string()),
1389 Err(error)
1390 if error.contains("no such frame")
1391 || error.contains("Context ")
1392 || error.contains("invalid argument") => {}
1393 Err(error) => return Err(error),
1394 }
1395 }
1396 }
1397
1398 let target_url = current_url
1399 .map(str::trim)
1400 .filter(|value| !value.is_empty())
1401 .map(str::to_string);
1402
1403 for attempt in 0..20 {
1404 let tree = self
1405 .send_bidi_command(
1406 mapper_session_id,
1407 &json!({
1408 "id": 2,
1409 "method": "browsingContext.getTree",
1410 "params": {
1411 "maxDepth": 0,
1412 }
1413 }),
1414 )
1415 .await?;
1416 let contexts = tree
1417 .pointer("/result/contexts")
1418 .and_then(Value::as_array)
1419 .ok_or_else(|| {
1420 format!("BiDi browsingContext.getTree returned no contexts: {tree}")
1421 })?;
1422
1423 if let Some(target_url) = target_url.as_deref() {
1424 if let Some(context_id) = contexts.iter().find_map(|context| {
1425 let url = context.get("url").and_then(Value::as_str)?;
1426 if url == target_url {
1427 context
1428 .get("context")
1429 .and_then(Value::as_str)
1430 .map(str::to_string)
1431 } else {
1432 None
1433 }
1434 }) {
1435 return Ok(context_id);
1436 }
1437 }
1438
1439 if let Some(context_id) = contexts
1440 .first()
1441 .and_then(|context| context.get("context"))
1442 .and_then(Value::as_str)
1443 .filter(|value| !value.trim().is_empty())
1444 .map(str::to_string)
1445 {
1446 return Ok(context_id);
1447 }
1448
1449 if attempt < 19 {
1450 sleep(Duration::from_millis(150)).await;
1451 } else {
1452 return Err(format!(
1453 "BiDi browsingContext.getTree returned no usable context ids after retries: {tree}"
1454 ));
1455 }
1456 }
1457
1458 Err("BiDi browsing context resolution exhausted retries unexpectedly".to_string())
1459 }
1460
1461 async fn navigate_and_wait_for_load(
1462 &mut self,
1463 session_id: &str,
1464 url: &str,
1465 ) -> Result<(), String> {
1466 let id = self.next_id;
1467 self.next_id += 1;
1468
1469 let payload = json!({
1470 "id": id,
1471 "method": "Page.navigate",
1472 "params": {
1473 "url": url,
1474 },
1475 "sessionId": session_id,
1476 });
1477 self.socket
1478 .send(Message::Text(payload.to_string().into()))
1479 .await
1480 .map_err(|error| format!("failed to send CDP command Page.navigate: {error}"))?;
1481
1482 let mut navigate_confirmed = false;
1483 let mut load_seen = false;
1484
1485 loop {
1486 let message = self.next_json_message().await?;
1487
1488 if message.get("id").and_then(Value::as_u64) == Some(id) {
1489 if let Some(error) = message.get("error") {
1490 return Err(format!("CDP command Page.navigate failed: {error}"));
1491 }
1492 navigate_confirmed = true;
1493 } else if message.get("sessionId").and_then(Value::as_str) == Some(session_id)
1494 && message.get("method").and_then(Value::as_str) == Some("Page.loadEventFired")
1495 {
1496 load_seen = true;
1497 }
1498
1499 if navigate_confirmed && load_seen {
1500 return Ok(());
1501 }
1502 }
1503 }
1504
1505 async fn next_json_message(&mut self) -> Result<Value, String> {
1506 loop {
1507 let message = self
1508 .socket
1509 .next()
1510 .await
1511 .ok_or_else(|| "CDP websocket closed unexpectedly".to_string())?
1512 .map_err(|error| format!("failed to read CDP websocket message: {error}"))?;
1513
1514 match message {
1515 Message::Text(text) => {
1516 let value = serde_json::from_str::<Value>(&text)
1517 .map_err(|error| format!("failed to parse CDP JSON message: {error}"))?;
1518 return Ok(value);
1519 }
1520 Message::Binary(bytes) => {
1521 let value = serde_json::from_slice::<Value>(&bytes)
1522 .map_err(|error| format!("failed to parse CDP JSON message: {error}"))?;
1523 return Ok(value);
1524 }
1525 Message::Ping(payload) => {
1526 self.socket
1527 .send(Message::Pong(payload))
1528 .await
1529 .map_err(|error| format!("failed to reply to CDP ping: {error}"))?;
1530 }
1531 Message::Pong(_) => {}
1532 Message::Frame(_) => {}
1533 Message::Close(frame) => {
1534 return Err(format!("CDP websocket closed: {frame:?}"));
1535 }
1536 }
1537 }
1538 }
1539}
1540
1541fn required_string(value: &Value, pointer: &str) -> Result<String, String> {
1542 value
1543 .pointer(pointer)
1544 .and_then(Value::as_str)
1545 .map(str::to_string)
1546 .filter(|value| !value.is_empty())
1547 .ok_or_else(|| format!("missing string field at JSON pointer {pointer}"))
1548}
1549
1550fn js_single_quote(value: &str) -> String {
1551 value.replace('\\', "\\\\").replace('\'', "\\'")
1552}
1553
1554fn json_u32(value: &Value, pointer: &str) -> Result<u32, String> {
1555 let raw = value
1556 .pointer(pointer)
1557 .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
1558
1559 if let Some(number) = raw.as_u64() {
1560 return u32::try_from(number)
1561 .map_err(|_| format!("numeric JSON value at {pointer} exceeds u32: {number}"));
1562 }
1563
1564 if let Some(number) = raw.as_i64() {
1565 return u32::try_from(number).map_err(|_| {
1566 format!("numeric JSON value at {pointer} is out of range for u32: {number}")
1567 });
1568 }
1569
1570 Err(format!(
1571 "expected numeric JSON value at {pointer}, found {raw}"
1572 ))
1573}
1574
1575fn json_f64(value: &Value, pointer: &str) -> Result<f64, String> {
1576 let raw = value
1577 .pointer(pointer)
1578 .ok_or_else(|| format!("missing JSON value at {pointer}: {value}"))?;
1579
1580 raw.as_f64()
1581 .ok_or_else(|| format!("expected numeric JSON value at {pointer}, found {raw}"))
1582}
1583
1584fn json_string(value: &Value, pointer: &str) -> Result<String, String> {
1585 value
1586 .pointer(pointer)
1587 .and_then(Value::as_str)
1588 .map(str::to_string)
1589 .ok_or_else(|| format!("expected string JSON value at {pointer}"))
1590}
1591
1592fn json_string_literal(value: &str, context: &str) -> Result<String, String> {
1593 serde_json::to_string(value).map_err(|error| format!("failed to serialize {context}: {error}"))
1594}
1595
1596fn require_non_empty_selector(command_name: &str, css_selector: &str) -> Result<(), String> {
1597 if css_selector.trim().is_empty() {
1598 Err(format!(
1599 "{command_name} command requires a non-empty css_selector"
1600 ))
1601 } else {
1602 Ok(())
1603 }
1604}