Skip to main content

allwright_surface_web/
lib.rs

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}