Skip to main content

browser_control/session/
input.rs

1//! Native CDP input and geometry for ref-based interaction.
2//!
3//! Every function here works on an already-attached flat session (see
4//! [`crate::session::cdp_session::with_page_session`]) and a CDP
5//! `backendDOMNodeId`, which is what an element ref resolves to. Clicks go
6//! through `Input.dispatchMouseEvent` at the element's centre in viewport
7//! CSS pixels — the coordinate space `DOM.getContentQuads` already reports
8//! in, so no device-pixel-ratio maths is involved. Text goes through
9//! `Input.insertText`, which fires the same `beforeinput`/`input` events a
10//! paste does (Playwright's `fill` does the same).
11//!
12//! Deliberately not done in v1: occlusion checks (a click on a covered
13//! element lands on the overlay), auto-waiting, and `Page.bringToFront`
14//! (input works on background tabs, and the project keeps automated tabs
15//! in the background).
16
17use anyhow::{anyhow, Context, Result};
18use serde_json::{json, Value};
19
20use crate::cdp::CdpClient;
21use crate::dom::scripts::SELECT_ALL_JS;
22use crate::errors::{is_cdp_node_gone, SessionError};
23use crate::session::keys::{self, Chord};
24
25/// A point in viewport CSS pixels.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct Point {
28    pub x: f64,
29    pub y: f64,
30}
31
32/// Map a `DOM.*` failure for `backend_node_id` onto the typed
33/// [`SessionError::NodeGone`] when the message says the node left the
34/// document; otherwise attach context and pass through.
35fn node_err(backend_node_id: u64, op: &'static str) -> impl FnOnce(anyhow::Error) -> anyhow::Error {
36    move |e| {
37        let msg = format!("{e:#}");
38        if is_cdp_node_gone(&msg) {
39            SessionError::NodeGone {
40                backend_node_id,
41                details: msg,
42            }
43            .into()
44        } else {
45            e.context(format!("{op} on node {backend_node_id}"))
46        }
47    }
48}
49
50/// Viewport size from `Page.getLayoutMetrics` (`cssLayoutViewport`, with
51/// the pre-CSS field as fallback for older Chromium).
52fn viewport_size(metrics: &Value) -> (f64, f64) {
53    let vp = metrics
54        .get("cssLayoutViewport")
55        .or_else(|| metrics.get("layoutViewport"));
56    let w = vp
57        .and_then(|v| v.get("clientWidth"))
58        .and_then(Value::as_f64)
59        .unwrap_or(f64::MAX);
60    let h = vp
61        .and_then(|v| v.get("clientHeight"))
62        .and_then(Value::as_f64)
63        .unwrap_or(f64::MAX);
64    (w, h)
65}
66
67/// Scroll position from `Page.getLayoutMetrics`, in CSS pixels.
68fn viewport_offset(metrics: &Value) -> (f64, f64) {
69    let vp = metrics
70        .get("cssLayoutViewport")
71        .or_else(|| metrics.get("layoutViewport"));
72    let x = vp
73        .and_then(|v| v.get("pageX"))
74        .and_then(Value::as_f64)
75        .unwrap_or(0.0);
76    let y = vp
77        .and_then(|v| v.get("pageY"))
78        .and_then(Value::as_f64)
79        .unwrap_or(0.0);
80    (x, y)
81}
82
83/// Pick the centre of the first quad with a visible area after clipping
84/// to the viewport. Mirrors Puppeteer's `clickablePoint`.
85pub fn pick_point(quads: &Value, vw: f64, vh: f64) -> Option<Point> {
86    let quads = quads.as_array()?;
87    for q in quads {
88        let nums: Vec<f64> = q.as_array()?.iter().filter_map(Value::as_f64).collect();
89        if nums.len() != 8 {
90            continue;
91        }
92        let pts: Vec<(f64, f64)> = (0..4)
93            .map(|i| (nums[i * 2].clamp(0.0, vw), nums[i * 2 + 1].clamp(0.0, vh)))
94            .collect();
95        // Shoelace area of the clipped quad.
96        let mut area = 0.0;
97        for i in 0..4 {
98            let (x1, y1) = pts[i];
99            let (x2, y2) = pts[(i + 1) % 4];
100            area += x1 * y2 - x2 * y1;
101        }
102        if area.abs() / 2.0 <= 1.0 {
103            continue;
104        }
105        let x = pts.iter().map(|p| p.0).sum::<f64>() / 4.0;
106        let y = pts.iter().map(|p| p.1).sum::<f64>() / 4.0;
107        return Some(Point { x, y });
108    }
109    None
110}
111
112/// Scroll the node into view and return its clickable centre.
113pub async fn node_center(c: &CdpClient, sid: &str, backend_node_id: u64) -> Result<Point> {
114    let _ = c
115        .send_with_session("DOM.enable", json!({}), Some(sid))
116        .await;
117    c.send_with_session(
118        "DOM.scrollIntoViewIfNeeded",
119        json!({ "backendNodeId": backend_node_id }),
120        Some(sid),
121    )
122    .await
123    .map_err(node_err(backend_node_id, "scrollIntoViewIfNeeded"))?;
124    let quads = c
125        .send_with_session(
126            "DOM.getContentQuads",
127            json!({ "backendNodeId": backend_node_id }),
128            Some(sid),
129        )
130        .await
131        .map_err(node_err(backend_node_id, "getContentQuads"))?;
132    let metrics = c
133        .send_with_session("Page.getLayoutMetrics", json!({}), Some(sid))
134        .await?;
135    let (vw, vh) = viewport_size(&metrics);
136    pick_point(&quads["quads"], vw, vh)
137        .ok_or_else(|| anyhow!("element has no visible box (hidden, zero-size, or outside the viewport after scrolling)"))
138}
139
140async fn mouse(c: &CdpClient, sid: &str, kind: &str, p: Point, pressed: bool) -> Result<()> {
141    let mut params = json!({ "type": kind, "x": p.x, "y": p.y });
142    if pressed {
143        params["button"] = json!("left");
144        params["clickCount"] = json!(1);
145    }
146    c.send_with_session("Input.dispatchMouseEvent", params, Some(sid))
147        .await
148        .context("Input.dispatchMouseEvent")?;
149    Ok(())
150}
151
152/// Left-click the node's centre.
153pub async fn click(c: &CdpClient, sid: &str, backend_node_id: u64) -> Result<Point> {
154    let p = node_center(c, sid, backend_node_id).await?;
155    mouse(c, sid, "mouseMoved", p, false).await?;
156    mouse(c, sid, "mousePressed", p, true).await?;
157    mouse(c, sid, "mouseReleased", p, true).await?;
158    Ok(p)
159}
160
161/// Move the pointer over the node's centre.
162pub async fn hover(c: &CdpClient, sid: &str, backend_node_id: u64) -> Result<Point> {
163    let p = node_center(c, sid, backend_node_id).await?;
164    mouse(c, sid, "mouseMoved", p, false).await?;
165    Ok(p)
166}
167
168/// Focus the node, replace its current content with `text`, and
169/// optionally press Enter. `press_sequentially` inserts one character
170/// per event for inputs that react to each keystroke.
171pub async fn type_text(
172    c: &CdpClient,
173    sid: &str,
174    backend_node_id: u64,
175    text: &str,
176    press_sequentially: bool,
177    submit: bool,
178) -> Result<()> {
179    // Background tabs do not deliver focus events unless emulated.
180    let _ = c
181        .send_with_session(
182            "Emulation.setFocusEmulationEnabled",
183            json!({ "enabled": true }),
184            Some(sid),
185        )
186        .await;
187    c.send_with_session(
188        "DOM.focus",
189        json!({ "backendNodeId": backend_node_id }),
190        Some(sid),
191    )
192    .await
193    .map_err(node_err(backend_node_id, "focus"))?;
194    let resolved = c
195        .send_with_session(
196            "DOM.resolveNode",
197            json!({ "backendNodeId": backend_node_id }),
198            Some(sid),
199        )
200        .await
201        .map_err(node_err(backend_node_id, "resolveNode"))?;
202    let object_id = resolved["object"]["objectId"]
203        .as_str()
204        .ok_or_else(|| anyhow!("DOM.resolveNode returned no objectId"))?
205        .to_string();
206    let select = c
207        .send_with_session(
208            "Runtime.callFunctionOn",
209            json!({
210                "objectId": object_id,
211                "functionDeclaration": SELECT_ALL_JS,
212                "arguments": [{ "value": text.is_empty() }],
213                "returnByValue": true,
214            }),
215            Some(sid),
216        )
217        .await;
218    let _ = c
219        .send_with_session(
220            "Runtime.releaseObject",
221            json!({ "objectId": object_id }),
222            Some(sid),
223        )
224        .await;
225    select.context("selecting existing content")?;
226    if !text.is_empty() {
227        if press_sequentially {
228            for ch in text.chars() {
229                insert_text(c, sid, &ch.to_string()).await?;
230            }
231        } else {
232            insert_text(c, sid, text).await?;
233        }
234    }
235    if submit {
236        press_enter(c, sid).await?;
237    }
238    Ok(())
239}
240
241/// Type into whatever currently has focus, with no node id.
242///
243/// This is the sink for piped input (`browser-control type --stdin`): the
244/// caller focuses the field by other means — a ref-based click, or `Tab` —
245/// and the value arrives over a pipe without ever passing through the
246/// caller's own memory.
247///
248/// Existing content is selected first so the insert replaces rather than
249/// appends, matching `type_text`. If nothing has focus, the selection step is
250/// skipped and the text still goes to the page's default target.
251pub async fn type_focused(
252    c: &CdpClient,
253    sid: &str,
254    text: &str,
255    press_sequentially: bool,
256    submit: bool,
257) -> Result<()> {
258    // Background tabs do not deliver focus events unless emulated.
259    let _ = c
260        .send_with_session(
261            "Emulation.setFocusEmulationEnabled",
262            json!({ "enabled": true }),
263            Some(sid),
264        )
265        .await;
266    let resolved = c
267        .send_with_session(
268            "Runtime.evaluate",
269            json!({ "expression": "document.activeElement", "returnByValue": false }),
270            Some(sid),
271        )
272        .await
273        .context("resolving document.activeElement")?;
274    if let Some(object_id) = resolved["result"]["objectId"].as_str() {
275        let object_id = object_id.to_string();
276        let select = c
277            .send_with_session(
278                "Runtime.callFunctionOn",
279                json!({
280                    "objectId": object_id,
281                    "functionDeclaration": SELECT_ALL_JS,
282                    "arguments": [{ "value": text.is_empty() }],
283                    "returnByValue": true,
284                }),
285                Some(sid),
286            )
287            .await;
288        let _ = c
289            .send_with_session(
290                "Runtime.releaseObject",
291                json!({ "objectId": object_id }),
292                Some(sid),
293            )
294            .await;
295        select.context("selecting existing content")?;
296    }
297    if !text.is_empty() {
298        if press_sequentially {
299            for ch in text.chars() {
300                insert_text(c, sid, &ch.to_string()).await?;
301            }
302        } else {
303            insert_text(c, sid, text).await?;
304        }
305    }
306    if submit {
307        press_enter(c, sid).await?;
308    }
309    Ok(())
310}
311
312async fn insert_text(c: &CdpClient, sid: &str, text: &str) -> Result<()> {
313    c.send_with_session("Input.insertText", json!({ "text": text }), Some(sid))
314        .await
315        .context("Input.insertText")?;
316    Ok(())
317}
318
319/// Press and release Enter on the focused element. `text: "\r"` on the
320/// keyDown is what makes Chromium synthesise the `keypress` and submit
321/// forms, matching Puppeteer.
322pub async fn press_enter(c: &CdpClient, sid: &str) -> Result<()> {
323    press_key(c, sid, &Chord::plain(keys::ENTER)).await
324}
325
326/// Press and release a key, with any modifiers held around it.
327///
328/// Order is modifiers down, key down, key up, modifiers up **in reverse**.
329/// Releasing in reverse matters: a Shift left down leaks into whatever the
330/// page does next. The modifier release runs even when the key dispatch
331/// fails, so an error cannot strand the browser with Control held.
332///
333/// Keyboard input goes to whatever has focus, so unlike the other native
334/// actions this takes no node id.
335pub async fn press_key(c: &CdpClient, sid: &str, chord: &Chord) -> Result<()> {
336    let events = key_events(chord);
337    // Everything after the target key's keyUp is modifier release; those must
338    // still be sent if the key itself fails, or the browser is left with a
339    // modifier held down.
340    let release_from = events.len() - chord.modifiers.len();
341
342    let mut first_error = None;
343    for (i, ev) in events.iter().enumerate() {
344        if first_error.is_some() && i < release_from {
345            continue; // abandon the press, keep the release
346        }
347        if let Err(e) = c
348            .send_with_session("Input.dispatchKeyEvent", ev.clone(), Some(sid))
349            .await
350        {
351            let described = e.context(format!(
352                "Input.dispatchKeyEvent {} {}",
353                ev["type"].as_str().unwrap_or("?"),
354                ev["key"].as_str().unwrap_or("?")
355            ));
356            if first_error.is_none() {
357                first_error = Some(described);
358            }
359        }
360    }
361    match first_error {
362        Some(e) => Err(e),
363        None => Ok(()),
364    }
365}
366
367/// The full ordered event sequence for a chord.
368///
369/// Modifiers down in declaration order, the key down and up, then modifiers
370/// up **in reverse**. Reverse release matters: a Shift left down leaks into
371/// whatever the page does next.
372///
373/// Each event carries the modifier bitmask in force at that moment, the
374/// target key's included — omitting it there is the usual reason `Control+A`
375/// selects nothing.
376pub fn key_events(chord: &Chord) -> Vec<Value> {
377    let mut events = Vec::with_capacity(chord.modifiers.len() * 2 + 2);
378    let mut mask = 0u32;
379
380    for m in &chord.modifiers {
381        mask |= *m as u32;
382        events.push(event("rawKeyDown", &m.def(), mask));
383    }
384    // `keyDown` when the key inserts text, so Chromium synthesises the
385    // `keypress`; `rawKeyDown` when it does not.
386    let kind = if chord.key.text.is_some() {
387        "keyDown"
388    } else {
389        "rawKeyDown"
390    };
391    events.push(event(kind, &chord.key, mask));
392    events.push(event("keyUp", &chord.key, mask));
393
394    for m in chord.modifiers.iter().rev() {
395        mask &= !(*m as u32);
396        events.push(event("keyUp", &m.def(), mask));
397    }
398    events
399}
400
401fn event(kind: &str, key: &keys::KeyDef, modifiers: u32) -> Value {
402    let mut params = json!({
403        "type": kind,
404        "key": key.key,
405        "code": key.code,
406        "windowsVirtualKeyCode": key.vk,
407        "nativeVirtualKeyCode": key.vk,
408    });
409    if modifiers != 0 {
410        params["modifiers"] = json!(modifiers);
411    }
412    // Only a key that inserts text carries `text`, and only on the way down.
413    // Sending it for e.g. ArrowDown types a glyph instead of moving the caret.
414    if kind != "keyUp" {
415        if let Some(text) = key.text {
416            params["text"] = json!(text);
417            params["unmodifiedText"] = json!(text);
418        }
419    }
420    params
421}
422
423/// Pointer-event drag from one node's centre to another's. HTML5 native
424/// drag-and-drop (`draggable`) needs `Input.setInterceptDrags`; not in v1.
425pub async fn drag(c: &CdpClient, sid: &str, from: u64, to: u64) -> Result<()> {
426    let a = node_center(c, sid, from).await?;
427    let b = node_center(c, sid, to).await?;
428    mouse(c, sid, "mouseMoved", a, false).await?;
429    mouse(c, sid, "mousePressed", a, true).await?;
430    const STEPS: usize = 5;
431    for i in 1..=STEPS {
432        let t = i as f64 / STEPS as f64;
433        let p = Point {
434            x: a.x + (b.x - a.x) * t,
435            y: a.y + (b.y - a.y) * t,
436        };
437        mouse(c, sid, "mouseMoved", p, true).await?;
438    }
439    mouse(c, sid, "mouseReleased", b, true).await?;
440    Ok(())
441}
442
443/// The Document node's `backendNodeId`: changes on every navigation, so
444/// it identifies "the document the refs were taken from".
445pub async fn document_token(c: &CdpClient, sid: &str) -> Result<u64> {
446    let doc = c
447        .send_with_session("DOM.getDocument", json!({ "depth": 0 }), Some(sid))
448        .await
449        .context("DOM.getDocument")?;
450    doc["root"]["backendNodeId"]
451        .as_u64()
452        .ok_or_else(|| anyhow!("DOM.getDocument returned no root backendNodeId"))
453}
454
455/// Border box of the node in *document* coordinates, the same contract as
456/// [`crate::dom::scripts::GET_CLIP_RECT_JS`], for element screenshots.
457pub async fn node_clip_rect(c: &CdpClient, sid: &str, backend_node_id: u64) -> Result<Value> {
458    let _ = c
459        .send_with_session("DOM.enable", json!({}), Some(sid))
460        .await;
461    c.send_with_session(
462        "DOM.scrollIntoViewIfNeeded",
463        json!({ "backendNodeId": backend_node_id }),
464        Some(sid),
465    )
466    .await
467    .map_err(node_err(backend_node_id, "scrollIntoViewIfNeeded"))?;
468    let model = c
469        .send_with_session(
470            "DOM.getBoxModel",
471            json!({ "backendNodeId": backend_node_id }),
472            Some(sid),
473        )
474        .await
475        .map_err(node_err(backend_node_id, "getBoxModel"))?;
476    let border: Vec<f64> = model["model"]["border"]
477        .as_array()
478        .map(|a| a.iter().filter_map(Value::as_f64).collect())
479        .unwrap_or_default();
480    if border.len() != 8 {
481        return Err(anyhow!("element has no box model (hidden or detached)"));
482    }
483    let xs = [border[0], border[2], border[4], border[6]];
484    let ys = [border[1], border[3], border[5], border[7]];
485    let min_x = xs.iter().cloned().fold(f64::MAX, f64::min);
486    let max_x = xs.iter().cloned().fold(f64::MIN, f64::max);
487    let min_y = ys.iter().cloned().fold(f64::MAX, f64::min);
488    let max_y = ys.iter().cloned().fold(f64::MIN, f64::max);
489    if max_x - min_x <= 0.0 || max_y - min_y <= 0.0 {
490        return Err(anyhow!("element has zero area"));
491    }
492    let metrics = c
493        .send_with_session("Page.getLayoutMetrics", json!({}), Some(sid))
494        .await?;
495    let (sx, sy) = viewport_offset(&metrics);
496    Ok(json!({
497        "x": min_x + sx,
498        "y": min_y + sy,
499        "width": max_x - min_x,
500        "height": max_y - min_y,
501    }))
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use futures_util::{SinkExt, StreamExt};
508    use std::sync::Arc;
509    use tokio::sync::Mutex;
510    use tokio_tungstenite::tungstenite::Message;
511
512    /// Records every request and answers geometry methods with canned
513    /// values; everything else gets `{}`.
514    async fn spawn_mock(node_gone: bool) -> (String, Arc<Mutex<Vec<Value>>>) {
515        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
516        let addr = listener.local_addr().unwrap();
517        let seen = Arc::new(Mutex::new(Vec::<Value>::new()));
518        tokio::spawn({
519            let seen = seen.clone();
520            async move {
521                let (stream, _) = listener.accept().await.unwrap();
522                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
523                while let Some(Ok(Message::Text(t))) = ws.next().await {
524                    let req: Value = serde_json::from_str(&t).unwrap();
525                    seen.lock().await.push(req.clone());
526                    let id = req["id"].as_u64().unwrap();
527                    let method = req["method"].as_str().unwrap_or("");
528                    if node_gone && method.starts_with("DOM.") && method != "DOM.enable" {
529                        let resp = json!({"id": id, "error": {"code": -32000, "message": "No node with given id found"}});
530                        ws.send(Message::Text(resp.to_string())).await.unwrap();
531                        continue;
532                    }
533                    let result = match method {
534                        "DOM.getContentQuads" => json!({"quads": [
535                            // Off-screen quad (negative), then a real 100x20 box at (10,30).
536                            [-50, -50, -10, -50, -10, -40, -50, -40],
537                            [10, 30, 110, 30, 110, 50, 10, 50],
538                        ]}),
539                        "Page.getLayoutMetrics" => json!({"cssLayoutViewport": {
540                            "pageX": 0, "pageY": 400, "clientWidth": 800, "clientHeight": 600
541                        }}),
542                        "DOM.resolveNode" => json!({"object": {"objectId": "obj-1"}}),
543                        "DOM.getDocument" => json!({"root": {"backendNodeId": 4242}}),
544                        "DOM.getBoxModel" => {
545                            json!({"model": {"border": [10, 30, 110, 30, 110, 50, 10, 50]}})
546                        }
547                        _ => json!({}),
548                    };
549                    let resp = json!({"id": id, "result": result});
550                    ws.send(Message::Text(resp.to_string())).await.unwrap();
551                }
552            }
553        });
554        (format!("ws://{addr}"), seen)
555    }
556
557    fn methods(seen: &[Value]) -> Vec<String> {
558        seen.iter()
559            .filter_map(|v| v["method"].as_str().map(String::from))
560            .collect()
561    }
562
563    #[test]
564    fn pick_point_skips_offscreen_and_clips() {
565        let quads = json!([
566            [-50, -50, -10, -50, -10, -40, -50, -40],
567            [700, 10, 900, 10, 900, 30, 700, 30],
568        ]);
569        let p = pick_point(&quads, 800.0, 600.0).unwrap();
570        assert_eq!(p, Point { x: 750.0, y: 20.0 });
571        assert!(pick_point(&json!([[0, 0, 0, 0, 0, 0, 0, 0]]), 800.0, 600.0).is_none());
572        assert!(pick_point(&json!(null), 800.0, 600.0).is_none());
573    }
574
575    #[tokio::test]
576    async fn click_dispatches_move_press_release_at_centre() {
577        let (url, seen) = spawn_mock(false).await;
578        let c = CdpClient::connect(&url).await.unwrap();
579        let p = click(&c, "S1", 77).await.unwrap();
580        assert_eq!(p, Point { x: 60.0, y: 40.0 });
581        let calls = seen.lock().await;
582        assert_eq!(
583            methods(&calls),
584            vec![
585                "DOM.enable",
586                "DOM.scrollIntoViewIfNeeded",
587                "DOM.getContentQuads",
588                "Page.getLayoutMetrics",
589                "Input.dispatchMouseEvent",
590                "Input.dispatchMouseEvent",
591                "Input.dispatchMouseEvent",
592            ]
593        );
594        let press = &calls[5];
595        assert_eq!(press["sessionId"], "S1");
596        assert_eq!(press["params"]["type"], "mousePressed");
597        assert_eq!(press["params"]["button"], "left");
598        assert_eq!(press["params"]["x"], 60.0);
599        assert_eq!(press["params"]["y"], 40.0);
600        assert_eq!(calls[1]["params"]["backendNodeId"], 77);
601    }
602
603    #[tokio::test]
604    async fn type_text_focuses_selects_inserts_and_submits() {
605        let (url, seen) = spawn_mock(false).await;
606        let c = CdpClient::connect(&url).await.unwrap();
607        type_text(&c, "S1", 5, "hi", false, true).await.unwrap();
608        let calls = seen.lock().await;
609        assert_eq!(
610            methods(&calls),
611            vec![
612                "Emulation.setFocusEmulationEnabled",
613                "DOM.focus",
614                "DOM.resolveNode",
615                "Runtime.callFunctionOn",
616                "Runtime.releaseObject",
617                "Input.insertText",
618                "Input.dispatchKeyEvent",
619                "Input.dispatchKeyEvent",
620            ]
621        );
622        assert_eq!(calls[3]["params"]["arguments"][0]["value"], false);
623        assert_eq!(calls[5]["params"]["text"], "hi");
624        assert_eq!(calls[6]["params"]["text"], "\r");
625        assert_eq!(calls[7]["params"]["type"], "keyUp");
626    }
627
628    #[tokio::test]
629    async fn type_text_sequential_and_clear() {
630        let (url, seen) = spawn_mock(false).await;
631        let c = CdpClient::connect(&url).await.unwrap();
632        type_text(&c, "S1", 5, "ab", true, false).await.unwrap();
633        type_text(&c, "S1", 5, "", false, false).await.unwrap();
634        let calls = seen.lock().await;
635        let inserts: Vec<&Value> = calls
636            .iter()
637            .filter(|v| v["method"] == "Input.insertText")
638            .collect();
639        assert_eq!(inserts.len(), 2);
640        assert_eq!(inserts[0]["params"]["text"], "a");
641        assert_eq!(inserts[1]["params"]["text"], "b");
642        let clears: Vec<&Value> = calls
643            .iter()
644            .filter(|v| v["method"] == "Runtime.callFunctionOn")
645            .collect();
646        assert_eq!(clears[1]["params"]["arguments"][0]["value"], true);
647    }
648
649    #[tokio::test]
650    async fn drag_presses_moves_and_releases() {
651        let (url, seen) = spawn_mock(false).await;
652        let c = CdpClient::connect(&url).await.unwrap();
653        drag(&c, "S1", 1, 2).await.unwrap();
654        let calls = seen.lock().await;
655        let types: Vec<&str> = calls
656            .iter()
657            .filter(|v| v["method"] == "Input.dispatchMouseEvent")
658            .map(|v| v["params"]["type"].as_str().unwrap())
659            .collect();
660        assert_eq!(types[0], "mouseMoved");
661        assert_eq!(types[1], "mousePressed");
662        assert_eq!(*types.last().unwrap(), "mouseReleased");
663        assert_eq!(types.len(), 2 + 5 + 1);
664    }
665
666    #[tokio::test]
667    async fn node_gone_maps_to_typed_error() {
668        let (url, _seen) = spawn_mock(true).await;
669        let c = CdpClient::connect(&url).await.unwrap();
670        let err = click(&c, "S1", 9).await.unwrap_err();
671        match err.downcast_ref::<SessionError>() {
672            Some(SessionError::NodeGone {
673                backend_node_id, ..
674            }) => assert_eq!(*backend_node_id, 9),
675            other => panic!("expected NodeGone, got {other:?}"),
676        }
677    }
678
679    #[tokio::test]
680    async fn document_token_and_clip_rect() {
681        let (url, _seen) = spawn_mock(false).await;
682        let c = CdpClient::connect(&url).await.unwrap();
683        assert_eq!(document_token(&c, "S1").await.unwrap(), 4242);
684        let rect = node_clip_rect(&c, "S1", 3).await.unwrap();
685        // Viewport is scrolled 400px down, so document y = 30 + 400.
686        assert_eq!(
687            rect,
688            json!({"x": 10.0, "y": 430.0, "width": 100.0, "height": 20.0})
689        );
690    }
691}
692
693#[cfg(test)]
694mod key_tests {
695    use super::*;
696    use crate::session::keys::parse_chord;
697
698    fn kinds(events: &[Value]) -> Vec<(String, String)> {
699        events
700            .iter()
701            .map(|e| {
702                (
703                    e["type"].as_str().unwrap().to_string(),
704                    e["key"].as_str().unwrap().to_string(),
705                )
706            })
707            .collect()
708    }
709
710    #[test]
711    fn a_plain_named_key_is_two_events() {
712        let events = key_events(&parse_chord("ArrowDown").unwrap());
713        assert_eq!(
714            kinds(&events),
715            vec![
716                ("rawKeyDown".into(), "ArrowDown".into()),
717                ("keyUp".into(), "ArrowDown".into()),
718            ]
719        );
720    }
721
722    #[test]
723    fn a_non_inserting_key_carries_no_text() {
724        // With `text` set, Chromium types a private-use glyph instead of
725        // moving the caret.
726        for ev in key_events(&parse_chord("ArrowDown").unwrap()) {
727            assert!(ev.get("text").is_none(), "{ev}");
728        }
729    }
730
731    #[test]
732    fn enter_still_carries_the_carriage_return_that_submits_forms() {
733        let events = key_events(&parse_chord("Enter").unwrap());
734        assert_eq!(events[0]["type"], "keyDown");
735        assert_eq!(events[0]["text"], "\r");
736        assert_eq!(events[0]["unmodifiedText"], "\r");
737        assert_eq!(events[0]["windowsVirtualKeyCode"], 13);
738        // The release never inserts.
739        assert!(events[1].get("text").is_none());
740    }
741
742    #[test]
743    fn press_enter_payload_is_unchanged() {
744        // press_enter now goes through press_key; its wire format must not move.
745        let events = key_events(&Chord::plain(keys::ENTER));
746        assert_eq!(events.len(), 2);
747        assert_eq!(events[0]["key"], "Enter");
748        assert_eq!(events[0]["code"], "Enter");
749        assert_eq!(events[0]["nativeVirtualKeyCode"], 13);
750        assert_eq!(events[1]["type"], "keyUp");
751    }
752
753    #[test]
754    fn control_a_holds_the_modifier_across_the_key() {
755        let events = key_events(&parse_chord("Control+A").unwrap());
756        assert_eq!(
757            kinds(&events),
758            vec![
759                ("rawKeyDown".into(), "Control".into()),
760                ("keyDown".into(), "A".into()),
761                ("keyUp".into(), "A".into()),
762                ("keyUp".into(), "Control".into()),
763            ]
764        );
765        // The mask must be on the *key's* events too, not just the modifier's.
766        assert_eq!(events[1]["modifiers"], 2);
767        assert_eq!(events[2]["modifiers"], 2);
768        // ... and gone once the modifier is released.
769        assert!(events[3].get("modifiers").is_none());
770    }
771
772    #[test]
773    fn modifiers_release_in_reverse_order() {
774        // A modifier left down leaks into whatever the page does next.
775        let events = key_events(&parse_chord("Control+Shift+K").unwrap());
776        let seq = kinds(&events);
777        assert_eq!(seq[0].1, "Control");
778        assert_eq!(seq[1].1, "Shift");
779        assert_eq!(seq[4].1, "Shift");
780        assert_eq!(seq[5].1, "Control");
781        // Mask accumulates then unwinds.
782        assert_eq!(events[1]["modifiers"], 2 | 8);
783        assert_eq!(events[2]["modifiers"], 2 | 8);
784        assert_eq!(events[4]["modifiers"], 2); // Shift released, Control held
785    }
786
787    #[test]
788    fn printable_keys_insert_themselves() {
789        let events = key_events(&parse_chord("a").unwrap());
790        assert_eq!(events[0]["type"], "keyDown");
791        assert_eq!(events[0]["text"], "a");
792        assert_eq!(events[0]["code"], "KeyA");
793    }
794}