1#![allow(missing_docs)]
2use serde::Serialize;
3use serde_json::Value;
4use std::path::PathBuf;
5
6use std::collections::HashMap;
7
8use super::cdp::client::CdpClient;
9use super::cdp::types::*;
10use super::element::RefMap;
11
12const ANNOTATION_OVERLAY_ID: &str = "__browser_automation_cli_annotations__";
13
14#[derive(Debug, Clone)]
15struct Rect {
16 x: f64,
17 y: f64,
18 width: f64,
19 height: f64,
20}
21
22#[derive(Debug, Clone)]
23struct RawAnnotation {
24 ref_id: String,
25 number: u64,
26 role: String,
27 name: Option<String>,
28 rect: Rect,
29}
30
31#[derive(Debug, Clone, Serialize)]
32pub struct AnnotationBox {
33 pub x: i64,
34 pub y: i64,
35 pub width: i64,
36 pub height: i64,
37}
38
39#[derive(Debug, Clone)]
40pub struct ScreenshotAnnotation {
41 pub ref_id: String,
42 pub number: u64,
43 pub role: String,
44 pub name: Option<String>,
45 pub box_: AnnotationBox,
46}
47
48#[derive(Debug, Clone)]
49pub struct ScreenshotResult {
50 pub path: String,
51 pub base64: String,
52 pub annotations: Vec<ScreenshotAnnotation>,
53}
54
55#[derive(Debug, Clone)]
56pub struct ScreenshotOptions {
57 pub selector: Option<String>,
58 pub path: Option<String>,
59 pub full_page: bool,
60 pub format: String,
61 pub quality: Option<i32>,
62 pub annotate: bool,
63 pub output_dir: Option<String>,
64}
65
66impl Default for ScreenshotOptions {
67 fn default() -> Self {
68 Self {
69 selector: None,
70 path: None,
71 full_page: false,
72 format: "png".to_string(),
73 quality: None,
74 annotate: false,
75 output_dir: None,
76 }
77 }
78}
79
80impl Serialize for ScreenshotAnnotation {
81 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
82 where
83 S: serde::Serializer,
84 {
85 use serde::ser::SerializeStruct;
86
87 let mut state = serializer.serialize_struct("ScreenshotAnnotation", 5)?;
88 state.serialize_field("ref", &self.ref_id)?;
89 state.serialize_field("number", &self.number)?;
90 state.serialize_field("role", &self.role)?;
91 if let Some(name) = &self.name {
92 state.serialize_field("name", name)?;
93 }
94 state.serialize_field("box", &self.box_)?;
95 state.end()
96 }
97}
98
99pub async fn take_screenshot(
102 client: &CdpClient,
103 session_id: &str,
104 ref_map: &RefMap,
105 options: &ScreenshotOptions,
106 iframe_sessions: &HashMap<String, String>,
107) -> Result<ScreenshotResult, String> {
108 let target_rect = if options.annotate {
109 match options.selector.as_deref() {
110 Some(selector) => {
111 get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions)
112 .await?
113 }
114 None => None,
115 }
116 } else {
117 None
118 };
119
120 let raw_annotations = if options.annotate {
121 collect_annotations(client, session_id, ref_map).await?
122 } else {
123 Vec::new()
124 };
125
126 let overlay_items = filter_annotations(raw_annotations, target_rect.as_ref());
127 let overlay_injected = if options.annotate && !overlay_items.is_empty() {
128 inject_annotation_overlay(client, session_id, &overlay_items).await?;
129 true
130 } else {
131 false
132 };
133
134 let base64 =
135 capture_screenshot_base64(client, session_id, ref_map, options, iframe_sessions).await;
136
137 if overlay_injected {
138 let _ = remove_annotation_overlay(client, session_id).await;
139 }
140
141 let base64 = base64?;
142 let annotations = if options.annotate {
143 let scroll = if options.full_page {
144 Some(get_scroll_offsets(client, session_id).await?)
145 } else {
146 None
147 };
148 project_annotations(&overlay_items, target_rect.as_ref(), scroll)
149 } else {
150 Vec::new()
151 };
152
153 let ext = if options.format == "jpeg" {
154 "jpg"
155 } else {
156 "png"
157 };
158 let path = save_screenshot(
159 &base64,
160 options.path.as_deref(),
161 ext,
162 options.output_dir.as_deref(),
163 )?;
164
165 Ok(ScreenshotResult {
166 path,
167 base64,
168 annotations,
169 })
170}
171
172async fn capture_screenshot_base64(
173 client: &CdpClient,
174 session_id: &str,
175 ref_map: &RefMap,
176 options: &ScreenshotOptions,
177 iframe_sessions: &HashMap<String, String>,
178) -> Result<String, String> {
179 let mut params = CaptureScreenshotParams {
180 format: Some(options.format.clone()),
181 quality: if options.format == "jpeg" {
182 options.quality.or(Some(80))
183 } else {
184 None
185 },
186 clip: None,
187 from_surface: Some(true),
188 capture_beyond_viewport: if options.full_page { Some(true) } else { None },
189 };
190
191 if options.full_page {
192 let metrics: Value = client
193 .send_command_no_params("Page.getLayoutMetrics", Some(session_id))
194 .await?;
195
196 let content_size = metrics
197 .get("contentSize")
198 .or_else(|| metrics.get("cssContentSize"));
199 if let Some(size) = content_size {
200 let width = size.get("width").and_then(|v| v.as_f64()).unwrap_or(1280.0);
201 let height = size.get("height").and_then(|v| v.as_f64()).unwrap_or(720.0);
202
203 params.clip = Some(Viewport {
204 x: 0.0,
205 y: 0.0,
206 width,
207 height,
208 scale: 1.0,
209 });
210 }
211 } else if let Some(ref selector) = options.selector {
212 if let Some(rect) =
213 get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions).await?
214 {
215 params.clip = Some(Viewport {
216 x: rect.x,
217 y: rect.y,
218 width: rect.width,
219 height: rect.height,
220 scale: 1.0,
221 });
222 }
223 }
224
225 let result: CaptureScreenshotResult = client
226 .send_command_typed("Page.captureScreenshot", ¶ms, Some(session_id))
227 .await?;
228
229 Ok(result.data)
230}
231
232async fn collect_annotations(
233 client: &CdpClient,
234 session_id: &str,
235 ref_map: &RefMap,
236) -> Result<Vec<RawAnnotation>, String> {
237 let entries = ref_map.entries_sorted();
238 if entries.is_empty() {
239 return Ok(Vec::new());
240 }
241
242 let with_backend_ids: Vec<(String, super::element::RefEntry, i64)> = entries
244 .iter()
245 .filter_map(|(ref_id, entry)| {
246 entry
247 .backend_node_id
248 .map(|bid| (ref_id.clone(), entry.clone(), bid))
249 })
250 .collect();
251
252 if with_backend_ids.is_empty() {
253 return Ok(Vec::new());
254 }
255
256 let resolve_futures: Vec<_> = with_backend_ids
258 .iter()
259 .map(|(_, _, backend_node_id)| {
260 client.send_command(
261 "DOM.resolveNode",
262 Some(serde_json::json!({
263 "backendNodeId": backend_node_id,
264 "objectGroup": "browser-automation-cli-annotate"
265 })),
266 Some(session_id),
267 )
268 })
269 .collect();
270
271 let resolve_results = futures_util::future::join_all(resolve_futures).await;
272
273 let mut resolved: Vec<(String, super::element::RefEntry, String)> = Vec::new();
275 for (i, result) in resolve_results.into_iter().enumerate() {
276 if let Ok(val) = result {
277 if let Some(oid) = val
278 .get("object")
279 .and_then(|o| o.get("objectId"))
280 .and_then(|v| v.as_str())
281 {
282 let (ref_id, entry, _) = &with_backend_ids[i];
283 resolved.push((ref_id.clone(), entry.clone(), oid.to_string()));
284 }
285 }
286 }
287
288 if resolved.is_empty() {
289 return Ok(Vec::new());
290 }
291
292 let rect_futures: Vec<_> = resolved
294 .iter()
295 .map(|(_, _, object_id)| get_rect_for_object(client, session_id, object_id))
296 .collect();
297
298 let rect_results = futures_util::future::join_all(rect_futures).await;
299
300 let mut annotations = Vec::new();
301 for (i, rect_result) in rect_results.into_iter().enumerate() {
302 let rect = match rect_result {
303 Ok(Some(r)) if r.width > 0.0 && r.height > 0.0 => r,
304 _ => continue,
305 };
306
307 let (ref_id, entry, _) = &resolved[i];
308 let number = ref_id
309 .strip_prefix('e')
310 .and_then(|n| n.parse::<u64>().ok())
311 .unwrap_or(0);
312
313 annotations.push(RawAnnotation {
314 ref_id: ref_id.clone(),
315 number,
316 role: entry.role.clone(),
317 name: (!entry.name.is_empty()).then_some(entry.name.clone()),
318 rect,
319 });
320 }
321
322 Ok(annotations)
323}
324
325async fn get_rect_for_selector(
326 client: &CdpClient,
327 session_id: &str,
328 ref_map: &RefMap,
329 selector: &str,
330 iframe_sessions: &HashMap<String, String>,
331) -> Result<Option<Rect>, String> {
332 let (object_id, effective_session_id) = super::element::resolve_element_object_id(
333 client,
334 session_id,
335 ref_map,
336 selector,
337 iframe_sessions,
338 )
339 .await?;
340 get_rect_for_object(client, &effective_session_id, &object_id).await
341}
342
343async fn get_rect_for_object(
344 client: &CdpClient,
345 session_id: &str,
346 object_id: &str,
347) -> Result<Option<Rect>, String> {
348 let result: EvaluateResult = client
349 .send_command_typed(
350 "Runtime.callFunctionOn",
351 &CallFunctionOnParams {
352 function_declaration: r#"function() {
353 const rect = this.getBoundingClientRect();
354 return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
355 }"#
356 .to_string(),
357 object_id: Some(object_id.to_string()),
358 arguments: None,
359 return_by_value: Some(true),
360 await_promise: Some(false),
361 },
362 Some(session_id),
363 )
364 .await?;
365
366 Ok(result.result.value.as_ref().and_then(parse_rect))
367}
368
369fn parse_rect(value: &Value) -> Option<Rect> {
370 Some(Rect {
371 x: value.get("x")?.as_f64()?,
372 y: value.get("y")?.as_f64()?,
373 width: value.get("width")?.as_f64()?,
374 height: value.get("height")?.as_f64()?,
375 })
376}
377
378fn filter_annotations(
379 annotations: Vec<RawAnnotation>,
380 target_rect: Option<&Rect>,
381) -> Vec<RawAnnotation> {
382 let mut items = annotations
383 .into_iter()
384 .filter(|annotation| match target_rect {
385 Some(target) => overlaps(&annotation.rect, target),
386 None => true,
387 })
388 .collect::<Vec<_>>();
389
390 items.sort_by_key(|annotation| annotation.number);
391 items
392}
393
394fn overlaps(left: &Rect, right: &Rect) -> bool {
395 let left_x2 = left.x + left.width;
396 let left_y2 = left.y + left.height;
397 let right_x2 = right.x + right.width;
398 let right_y2 = right.y + right.height;
399
400 left.x < right_x2 && left_x2 > right.x && left.y < right_y2 && left_y2 > right.y
401}
402
403async fn inject_annotation_overlay(
404 client: &CdpClient,
405 session_id: &str,
406 annotations: &[RawAnnotation],
407) -> Result<(), String> {
408 let overlay_data = annotations
409 .iter()
410 .map(|annotation| {
411 serde_json::json!({
412 "number": annotation.number,
413 "x": round(annotation.rect.x),
414 "y": round(annotation.rect.y),
415 "width": round(annotation.rect.width),
416 "height": round(annotation.rect.height),
417 })
418 })
419 .collect::<Vec<_>>();
420
421 let expression = format!(
422 r#"(() => {{
423 var items = {items};
424 var id = {overlay_id};
425 var existing = document.getElementById(id);
426 if (existing) existing.remove();
427 var sx = window.scrollX || 0;
428 var sy = window.scrollY || 0;
429 var c = document.createElement('div');
430 c.id = id;
431 c.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;pointer-events:none;z-index:2147483647;';
432 for (var i = 0; i < items.length; i++) {{
433 var it = items[i];
434 var dx = it.x + sx;
435 var dy = it.y + sy;
436 var b = document.createElement('div');
437 b.style.cssText = 'position:absolute;left:' + dx + 'px;top:' + dy + 'px;width:' + it.width + 'px;height:' + it.height + 'px;border:2px solid rgba(255,0,0,0.8);box-sizing:border-box;pointer-events:none;';
438 var l = document.createElement('div');
439 l.textContent = String(it.number);
440 var labelTop = dy < 14 ? '2px' : '-14px';
441 l.style.cssText = 'position:absolute;top:' + labelTop + ';left:-2px;background:rgba(255,0,0,0.9);color:#fff;font:bold 11px/14px monospace;padding:0 4px;border-radius:2px;white-space:nowrap;';
442 b.appendChild(l);
443 c.appendChild(b);
444 }}
445 document.documentElement.appendChild(c);
446 return true;
447 }})()"#,
448 items = serde_json::to_string(&overlay_data).unwrap_or_else(|_| "[]".to_string()),
449 overlay_id =
450 serde_json::to_string(ANNOTATION_OVERLAY_ID).unwrap_or_else(|_| "\"\"".to_string()),
451 );
452
453 let _: EvaluateResult = client
454 .send_command_typed(
455 "Runtime.evaluate",
456 &EvaluateParams {
457 expression,
458 return_by_value: Some(true),
459 await_promise: Some(false),
460 },
461 Some(session_id),
462 )
463 .await?;
464
465 Ok(())
466}
467
468async fn remove_annotation_overlay(client: &CdpClient, session_id: &str) -> Result<(), String> {
469 let expression = format!(
470 r#"(() => {{
471 var el = document.getElementById({overlay_id});
472 if (el) el.remove();
473 return true;
474 }})()"#,
475 overlay_id =
476 serde_json::to_string(ANNOTATION_OVERLAY_ID).unwrap_or_else(|_| "\"\"".to_string()),
477 );
478
479 let _: EvaluateResult = client
480 .send_command_typed(
481 "Runtime.evaluate",
482 &EvaluateParams {
483 expression,
484 return_by_value: Some(true),
485 await_promise: Some(false),
486 },
487 Some(session_id),
488 )
489 .await?;
490
491 Ok(())
492}
493
494async fn get_scroll_offsets(client: &CdpClient, session_id: &str) -> Result<(f64, f64), String> {
495 let result: EvaluateResult = client
496 .send_command_typed(
497 "Runtime.evaluate",
498 &EvaluateParams {
499 expression: "({x: window.scrollX || 0, y: window.scrollY || 0})".to_string(),
500 return_by_value: Some(true),
501 await_promise: Some(false),
502 },
503 Some(session_id),
504 )
505 .await?;
506
507 let value = result.result.value.unwrap_or(Value::Null);
508 let x = value.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
509 let y = value.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
510 Ok((x, y))
511}
512
513fn project_annotations(
514 annotations: &[RawAnnotation],
515 target_rect: Option<&Rect>,
516 scroll: Option<(f64, f64)>,
517) -> Vec<ScreenshotAnnotation> {
518 annotations
519 .iter()
520 .map(|annotation| {
521 let rect = if let Some(target) = target_rect {
522 Rect {
523 x: annotation.rect.x - target.x,
524 y: annotation.rect.y - target.y,
525 width: annotation.rect.width,
526 height: annotation.rect.height,
527 }
528 } else if let Some((scroll_x, scroll_y)) = scroll {
529 Rect {
530 x: annotation.rect.x + scroll_x,
531 y: annotation.rect.y + scroll_y,
532 width: annotation.rect.width,
533 height: annotation.rect.height,
534 }
535 } else {
536 annotation.rect.clone()
537 };
538
539 ScreenshotAnnotation {
540 ref_id: annotation.ref_id.clone(),
541 number: annotation.number,
542 role: annotation.role.clone(),
543 name: annotation.name.clone(),
544 box_: AnnotationBox {
545 x: round(rect.x),
546 y: round(rect.y),
547 width: round(rect.width),
548 height: round(rect.height),
549 },
550 }
551 })
552 .collect()
553}
554
555fn save_screenshot(
556 base64_data: &str,
557 explicit_path: Option<&str>,
558 ext: &str,
559 output_dir: Option<&str>,
560) -> Result<String, String> {
561 let save_path = match explicit_path {
562 Some(path) => path.to_string(),
563 None => {
564 let dir = match output_dir {
565 Some(d) => PathBuf::from(d),
566 None => get_screenshot_dir(),
567 };
568 let _ = std::fs::create_dir_all(&dir);
569 let timestamp = std::time::SystemTime::now()
570 .duration_since(std::time::UNIX_EPOCH)
571 .unwrap_or_default()
572 .as_millis();
573 let name = format!("screenshot-{}.{}", timestamp, ext);
574 dir.join(name).to_string_lossy().to_string()
575 }
576 };
577
578 let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data)
579 .map_err(|e| format!("Failed to decode screenshot: {}", e))?;
580
581 std::fs::write(&save_path, &bytes)
582 .map_err(|e| format!("Failed to save screenshot to {}: {}", save_path, e))?;
583
584 Ok(save_path)
585}
586
587fn round(value: f64) -> i64 {
588 value.round() as i64
589}
590
591fn get_screenshot_dir() -> PathBuf {
592 if let Some(home) = dirs::home_dir() {
593 home.join(".browser-automation-cli")
594 .join("tmp")
595 .join("screenshots")
596 } else {
597 std::env::temp_dir()
598 .join("browser-automation-cli")
599 .join("screenshots")
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 #[test]
608 fn filters_annotations_to_target_overlap() {
609 let annotations = vec![
610 RawAnnotation {
611 ref_id: "e1".to_string(),
612 number: 1,
613 role: "button".to_string(),
614 name: Some("Inside".to_string()),
615 rect: Rect {
616 x: 10.0,
617 y: 10.0,
618 width: 50.0,
619 height: 20.0,
620 },
621 },
622 RawAnnotation {
623 ref_id: "e2".to_string(),
624 number: 2,
625 role: "button".to_string(),
626 name: Some("Outside".to_string()),
627 rect: Rect {
628 x: 200.0,
629 y: 200.0,
630 width: 40.0,
631 height: 20.0,
632 },
633 },
634 ];
635
636 let target = Rect {
637 x: 0.0,
638 y: 0.0,
639 width: 100.0,
640 height: 100.0,
641 };
642
643 let filtered = filter_annotations(annotations, Some(&target));
644 assert_eq!(filtered.len(), 1);
645 assert_eq!(filtered[0].ref_id, "e1");
646 }
647
648 #[test]
649 fn projects_selector_annotations_relative_to_target() {
650 let annotations = vec![RawAnnotation {
651 ref_id: "e1".to_string(),
652 number: 1,
653 role: "button".to_string(),
654 name: Some("Inside".to_string()),
655 rect: Rect {
656 x: 25.0,
657 y: 35.0,
658 width: 40.0,
659 height: 20.0,
660 },
661 }];
662
663 let target = Rect {
664 x: 10.0,
665 y: 15.0,
666 width: 100.0,
667 height: 100.0,
668 };
669
670 let projected = project_annotations(&annotations, Some(&target), None);
671 assert_eq!(projected[0].box_.x, 15);
672 assert_eq!(projected[0].box_.y, 20);
673 }
674
675 #[test]
676 fn screenshot_options_accept_full_page_quality_element() {
677 let opts = ScreenshotOptions {
678 full_page: true,
679 quality: Some(72),
680 format: "jpeg".into(),
681 ..ScreenshotOptions::default()
682 };
683 assert!(opts.full_page);
684 assert_eq!(opts.quality, Some(72));
685 assert_eq!(opts.format, "jpeg");
686 }
687
688 #[test]
689 fn projects_full_page_annotations_to_document_space() {
690 let annotations = vec![RawAnnotation {
691 ref_id: "e1".to_string(),
692 number: 1,
693 role: "button".to_string(),
694 name: Some("Bottom".to_string()),
695 rect: Rect {
696 x: 5.0,
697 y: 12.0,
698 width: 40.0,
699 height: 20.0,
700 },
701 }];
702
703 let projected = project_annotations(&annotations, None, Some((10.0, 1000.0)));
704 assert_eq!(projected[0].box_.x, 15);
705 assert_eq!(projected[0].box_.y, 1012);
706 }
707}