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