1use anyhow::{anyhow, Context, Result};
16use serde_json::{json, Value};
17
18use crate::bidi::BidiClient;
19use crate::dom::scripts::{
20 DOC_SIZE_JS, DOC_TOKEN_JS, REF_CENTER_JS, REF_CLIP_RECT_JS, REF_TYPE_JS, SNAPSHOT_TREE_JS,
21};
22use crate::errors::SessionError;
23use crate::session::input::Point;
24
25const SNAPSHOT_MAX_NODES: u64 = 20_000;
27const ENTER: &str = "\u{e007}";
29const DRAG_STEPS: usize = 5;
31
32fn remote_string(v: &Value) -> Result<String> {
34 v["value"]
35 .as_str()
36 .map(String::from)
37 .ok_or_else(|| anyhow!("script returned no string result: {v}"))
38}
39
40fn decode_ref_result(id: u64, op: &'static str, s: &str) -> Result<Value> {
43 let v: Value = serde_json::from_str(s)
44 .with_context(|| format!("{op} on node {id}: invalid helper result"))?;
45 if v["gone"].as_bool().unwrap_or(false) {
46 return Err(SessionError::NodeGone {
47 backend_node_id: id,
48 details: format!("{op}: ref id {id} is not in the page registry or was detached"),
49 }
50 .into());
51 }
52 if let Some(e) = v["error"].as_str() {
53 return Err(anyhow!("{op} on node {id}: {e}"));
54 }
55 Ok(v)
56}
57
58async fn call_ref(
59 c: &BidiClient,
60 ctx: &str,
61 id: u64,
62 op: &'static str,
63 script: &str,
64 extra: Vec<Value>,
65) -> Result<Value> {
66 let mut args = vec![json!(id)];
67 args.extend(extra);
68 let v = c.script_call_function(ctx, script, args).await?;
69 decode_ref_result(id, op, &remote_string(&v)?)
70}
71
72fn pointer_move(p: Point) -> Value {
73 json!({
74 "type": "pointerMove",
75 "x": p.x.round() as i64,
76 "y": p.y.round() as i64,
77 "origin": "viewport",
78 })
79}
80
81fn pointer_source(actions: Vec<Value>) -> Value {
82 json!({
83 "type": "pointer",
84 "id": "mouse",
85 "parameters": { "pointerType": "mouse" },
86 "actions": actions,
87 })
88}
89
90fn key_source(actions: Vec<Value>) -> Value {
91 json!({ "type": "key", "id": "kb", "actions": actions })
92}
93
94fn key_press(value: &str) -> [Value; 2] {
95 [
96 json!({ "type": "keyDown", "value": value }),
97 json!({ "type": "keyUp", "value": value }),
98 ]
99}
100
101pub async fn accessibility_tree(c: &BidiClient, ctx: &str) -> Result<Value> {
103 let v = c
104 .script_call_function(ctx, SNAPSHOT_TREE_JS, vec![json!(SNAPSHOT_MAX_NODES)])
105 .await?;
106 let tree: Value = serde_json::from_str(&remote_string(&v)?)
107 .context("accessibility walker returned invalid JSON")?;
108 if tree["truncated"].as_bool().unwrap_or(false) {
109 tracing::warn!(
110 target = %ctx,
111 "accessibility snapshot truncated at {SNAPSHOT_MAX_NODES} nodes"
112 );
113 }
114 Ok(tree)
115}
116
117pub async fn document_token(c: &BidiClient, ctx: &str) -> Result<u64> {
119 let v = c.script_evaluate(ctx, DOC_TOKEN_JS).await?;
120 let v = crate::bidi::unwrap_script_result(v)?;
121 remote_string(&v)?
122 .parse::<u64>()
123 .context("document token is not an integer")
124}
125
126pub async fn node_center(c: &BidiClient, ctx: &str, id: u64) -> Result<Point> {
128 let v = call_ref(c, ctx, id, "center", REF_CENTER_JS, vec![]).await?;
129 Ok(Point {
130 x: v["x"].as_f64().unwrap_or(0.0),
131 y: v["y"].as_f64().unwrap_or(0.0),
132 })
133}
134
135pub async fn click(c: &BidiClient, ctx: &str, id: u64) -> Result<Point> {
137 let p = node_center(c, ctx, id).await?;
138 c.input_perform_actions(
139 ctx,
140 json!([pointer_source(vec![
141 pointer_move(p),
142 json!({ "type": "pointerDown", "button": 0 }),
143 json!({ "type": "pointerUp", "button": 0 }),
144 ])]),
145 )
146 .await?;
147 let _ = c.input_release_actions(ctx).await;
148 Ok(p)
149}
150
151pub async fn hover(c: &BidiClient, ctx: &str, id: u64) -> Result<Point> {
153 let p = node_center(c, ctx, id).await?;
154 c.input_perform_actions(ctx, json!([pointer_source(vec![pointer_move(p)])]))
155 .await?;
156 Ok(p)
157}
158
159pub async fn type_text(
162 c: &BidiClient,
163 ctx: &str,
164 id: u64,
165 text: &str,
166 press_sequentially: bool,
167 submit: bool,
168) -> Result<()> {
169 let mode = if text.is_empty() {
170 "clear"
171 } else if press_sequentially {
172 "select"
173 } else {
174 "fill"
175 };
176 call_ref(
177 c,
178 ctx,
179 id,
180 "type",
181 REF_TYPE_JS,
182 vec![json!(text), json!(mode)],
183 )
184 .await?;
185 if press_sequentially && !text.is_empty() {
186 let mut keys = Vec::new();
187 for ch in text.chars() {
188 let v = match ch {
189 '\n' | '\r' => ENTER.to_string(),
190 other => other.to_string(),
191 };
192 keys.extend(key_press(&v));
193 }
194 c.input_perform_actions(ctx, json!([key_source(keys)]))
195 .await?;
196 }
197 if submit {
198 c.input_perform_actions(ctx, json!([key_source(key_press(ENTER).to_vec())]))
199 .await?;
200 }
201 let _ = c.input_release_actions(ctx).await;
202 Ok(())
203}
204
205pub async fn drag(c: &BidiClient, ctx: &str, from: u64, to: u64) -> Result<()> {
207 let a = node_center(c, ctx, from).await?;
208 let b = node_center(c, ctx, to).await?;
209 let mut actions = vec![
210 pointer_move(a),
211 json!({ "type": "pointerDown", "button": 0 }),
212 ];
213 for i in 1..=DRAG_STEPS {
214 let t = i as f64 / DRAG_STEPS as f64;
215 actions.push(pointer_move(Point {
216 x: a.x + (b.x - a.x) * t,
217 y: a.y + (b.y - a.y) * t,
218 }));
219 }
220 actions.push(pointer_move(b));
221 actions.push(json!({ "type": "pointerUp", "button": 0 }));
222 c.input_perform_actions(ctx, json!([pointer_source(actions)]))
223 .await?;
224 let _ = c.input_release_actions(ctx).await;
225 Ok(())
226}
227
228pub async fn node_clip_rect(c: &BidiClient, ctx: &str, id: u64) -> Result<Value> {
230 call_ref(c, ctx, id, "clip", REF_CLIP_RECT_JS, vec![]).await
231}
232
233pub async fn document_size(c: &BidiClient, ctx: &str) -> Result<(f64, f64)> {
235 let v = c.script_evaluate(ctx, DOC_SIZE_JS).await?;
236 let v = crate::bidi::unwrap_script_result(v)?;
237 let parsed: Value = serde_json::from_str(&remote_string(&v)?)
238 .context("document size helper returned invalid JSON")?;
239 Ok((
240 parsed["width"].as_f64().unwrap_or(0.0),
241 parsed["height"].as_f64().unwrap_or(0.0),
242 ))
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use futures_util::{SinkExt, StreamExt};
249 use std::sync::{Arc, Mutex};
250 use tokio_tungstenite::tungstenite::Message;
251
252 async fn spawn_mock(gone: bool) -> (String, Arc<Mutex<Vec<Value>>>) {
255 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
256 let addr = listener.local_addr().unwrap();
257 let seen = Arc::new(Mutex::new(Vec::<Value>::new()));
258 tokio::spawn({
259 let seen = seen.clone();
260 async move {
261 let (stream, _) = listener.accept().await.unwrap();
262 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
263 while let Some(Ok(Message::Text(t))) = ws.next().await {
264 let req: Value = serde_json::from_str(&t).unwrap();
265 seen.lock().unwrap().push(req.clone());
266 let id = req["id"].as_u64().unwrap();
267 let method = req["method"].as_str().unwrap_or("");
268 let decl = req["params"]["functionDeclaration"].as_str().unwrap_or("");
269 let expr = req["params"]["expression"].as_str().unwrap_or("");
270 let string = |s: String| json!({"type": "success", "result": {"type": "string", "value": s}, "realm": "R1"});
271 let result = match method {
272 "script.callFunction" if gone => string("{\"gone\":true}".into()),
273 "script.callFunction" if decl.contains("bc:center") => {
274 string("{\"x\":30.4,\"y\":20}".into())
275 }
276 "script.callFunction" if decl.contains("bc:clip") => {
277 string("{\"x\":10,\"y\":430,\"width\":100,\"height\":20}".into())
278 }
279 "script.callFunction" if decl.contains("bc:type") => {
280 string("{\"kind\":\"field\",\"method\":\"execCommand\"}".into())
281 }
282 "script.callFunction" if decl.contains("bc:snapshot") => string(
283 json!({"nodes": [
284 {"nodeId": "root", "backendDOMNodeId": 4294967296u64,
285 "role": {"value": "RootWebArea"}, "name": {"value": "T"}, "childIds": ["n1"]},
286 {"nodeId": "n1", "parentId": "root", "backendDOMNodeId": 1,
287 "role": {"value": "button"}, "name": {"value": "Go"}, "childIds": [],
288 "properties": [{"name": "focusable", "value": {"value": true}}]}
289 ], "truncated": false})
290 .to_string(),
291 ),
292 "script.evaluate" if expr.contains("__bcDocToken") => {
293 string("4294967296".into())
294 }
295 "script.evaluate" if expr.contains("scrollWidth") => {
296 string("{\"width\":1000,\"height\":3000}".into())
297 }
298 _ => json!({}),
299 };
300 ws.send(Message::Text(
301 json!({"type": "success", "id": id, "result": result}).to_string(),
302 ))
303 .await
304 .unwrap();
305 }
306 }
307 });
308 (format!("ws://{addr}"), seen)
309 }
310
311 fn input_calls(seen: &[Value]) -> Vec<Value> {
312 seen.iter()
313 .filter(|r| r["method"] == "input.performActions")
314 .map(|r| r["params"]["actions"].clone())
315 .collect()
316 }
317
318 #[tokio::test]
319 async fn click_moves_presses_releases_then_releases_actions() {
320 let (url, seen) = spawn_mock(false).await;
321 let c = BidiClient::connect(&url).await.unwrap();
322 let p = click(&c, "C1", 7).await.unwrap();
323 assert_eq!(p, Point { x: 30.4, y: 20.0 });
324 let seen = seen.lock().unwrap();
325 let first = &seen[0];
326 assert_eq!(first["method"], "script.callFunction");
327 assert_eq!(
328 first["params"]["arguments"][0],
329 json!({"type": "number", "value": 7})
330 );
331 let acts = input_calls(&seen);
332 assert_eq!(acts.len(), 1);
333 let pointer = &acts[0][0];
334 assert_eq!(pointer["type"], "pointer");
335 assert_eq!(pointer["parameters"]["pointerType"], "mouse");
336 assert_eq!(
337 pointer["actions"],
338 json!([
339 {"type": "pointerMove", "x": 30, "y": 20, "origin": "viewport"},
340 {"type": "pointerDown", "button": 0},
341 {"type": "pointerUp", "button": 0},
342 ])
343 );
344 assert_eq!(seen.last().unwrap()["method"], "input.releaseActions");
345 }
346
347 #[tokio::test]
348 async fn type_fill_then_enter() {
349 let (url, seen) = spawn_mock(false).await;
350 let c = BidiClient::connect(&url).await.unwrap();
351 type_text(&c, "C1", 3, "hi", false, true).await.unwrap();
352 let seen = seen.lock().unwrap();
353 assert_eq!(
354 seen[0]["params"]["arguments"],
355 json!([
356 {"type": "number", "value": 3},
357 {"type": "string", "value": "hi"},
358 {"type": "string", "value": "fill"}
359 ])
360 );
361 let acts = input_calls(&seen);
362 assert_eq!(acts.len(), 1, "fill sends no key actions; only Enter");
363 assert_eq!(acts[0][0]["type"], "key");
364 assert_eq!(acts[0][0]["actions"][0]["value"], ENTER);
365 assert_eq!(acts[0][0]["actions"][1]["type"], "keyUp");
366 }
367
368 #[tokio::test]
369 async fn type_sequentially_emits_per_char_keys_and_clear_sends_none() {
370 let (url, seen) = spawn_mock(false).await;
371 let c = BidiClient::connect(&url).await.unwrap();
372 type_text(&c, "C1", 3, "a\n", true, false).await.unwrap();
373 type_text(&c, "C1", 3, "", false, false).await.unwrap();
374 let seen = seen.lock().unwrap();
375 assert_eq!(seen[0]["params"]["arguments"][2]["value"], "select");
376 let acts = input_calls(&seen);
377 assert_eq!(acts.len(), 1);
378 let keys = &acts[0][0]["actions"];
379 assert_eq!(keys.as_array().unwrap().len(), 4);
380 assert_eq!(keys[0]["value"], "a");
381 assert_eq!(keys[2]["value"], ENTER);
382 let clear = seen
383 .iter()
384 .filter(|r| r["method"] == "script.callFunction")
385 .nth(1)
386 .unwrap();
387 assert_eq!(clear["params"]["arguments"][2]["value"], "clear");
388 }
389
390 #[tokio::test]
391 async fn drag_sequence() {
392 let (url, seen) = spawn_mock(false).await;
393 let c = BidiClient::connect(&url).await.unwrap();
394 drag(&c, "C1", 1, 2).await.unwrap();
395 let seen = seen.lock().unwrap();
396 let acts = input_calls(&seen);
397 let types: Vec<&str> = acts[0][0]["actions"]
398 .as_array()
399 .unwrap()
400 .iter()
401 .map(|a| a["type"].as_str().unwrap())
402 .collect();
403 assert_eq!(types[0], "pointerMove");
404 assert_eq!(types[1], "pointerDown");
405 assert_eq!(*types.last().unwrap(), "pointerUp");
406 assert_eq!(types.len(), 2 + DRAG_STEPS + 1 + 1);
407 }
408
409 #[tokio::test]
410 async fn gone_maps_to_node_gone() {
411 let (url, _seen) = spawn_mock(true).await;
412 let c = BidiClient::connect(&url).await.unwrap();
413 let err = hover(&c, "C1", 9).await.unwrap_err();
414 match err.downcast_ref::<SessionError>() {
415 Some(SessionError::NodeGone {
416 backend_node_id, ..
417 }) => {
418 assert_eq!(*backend_node_id, 9)
419 }
420 other => panic!("expected NodeGone, got {other:?}"),
421 }
422 }
423
424 #[tokio::test]
425 async fn tree_token_clip_and_document_size() {
426 let (url, _seen) = spawn_mock(false).await;
427 let c = BidiClient::connect(&url).await.unwrap();
428 let tree = accessibility_tree(&c, "C1").await.unwrap();
429 let parsed = crate::a11y::parse_full_ax_tree(&tree).unwrap();
430 assert_eq!(crate::a11y::document_token(&parsed), Some(4294967296));
431 assert_eq!(document_token(&c, "C1").await.unwrap(), 4294967296);
432 assert_eq!(
433 node_clip_rect(&c, "C1", 1).await.unwrap(),
434 json!({"x": 10, "y": 430, "width": 100, "height": 20})
435 );
436 assert_eq!(document_size(&c, "C1").await.unwrap(), (1000.0, 3000.0));
437 }
438}