diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 274f99767..30500da60 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -468,20 +468,54 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result { - // screenshot [selector] [path] [--full/-f] + // screenshot [selector] [path] [--full/-f] [--burst N] [--interval Ms] [--gif path] // selector: @ref or CSS selector // path: file path (contains / or . or ends with known extension) let mut full_page = false; + let mut burst_count: Option = None; + let mut burst_interval: Option = None; + let mut gif_path: Option = None; + let mut skip_next = false; + let mut burst_requested = false; let positional: Vec<&str> = rest .iter() - .filter(|arg| match **arg { - "--full" | "-f" => { - full_page = true; - false + .enumerate() + .filter(|(i, arg)| { + if skip_next { + skip_next = false; + return false; + } + match **arg { + "--full" | "-f" => { + full_page = true; + false + } + "--burst" => { + burst_requested = true; + if let Some(next) = rest.get(i + 1) { + burst_count = next.parse().ok(); + } + skip_next = true; + false + } + "--interval" => { + if let Some(next) = rest.get(i + 1) { + burst_interval = next.parse().ok(); + } + skip_next = true; + false + } + "--gif" => { + if let Some(next) = rest.get(i + 1) { + gif_path = Some(next.to_string()); + } + skip_next = true; + false + } + _ => true, } - _ => true, }) - .copied() + .map(|(_, arg)| *arg) .collect(); let (selector, path) = match (positional.first(), positional.get(1)) { (Some(first), Some(second)) => { @@ -508,6 +542,38 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result (None, None), }; + + // Validate --burst was given a valid count + if burst_requested && burst_count.is_none() { + return Err(ParseError::InvalidValue { + message: "--burst requires a positive integer".to_string(), + usage: "screenshot --burst [--interval ] [--gif ]", + }); + } + + // If --burst is specified, route to burst_capture action + if let Some(count) = burst_count { + let mut cmd = json!({ + "id": id, + "action": "burst_capture", + "count": count, + "interval": burst_interval.unwrap_or(200), + }); + if let Some(ref fmt) = flags.screenshot_format { + cmd["format"] = json!(fmt); + } + if let Some(q) = flags.screenshot_quality { + cmd["quality"] = json!(q); + } + if let Some(ref dir) = flags.screenshot_dir { + cmd["outputDir"] = json!(dir); + } + if let Some(ref gp) = gif_path { + cmd["gifPath"] = json!(gp); + } + return Ok(cmd); + } + let mut cmd = json!({ "id": id, "action": "screenshot", "path": path, "selector": selector, @@ -538,6 +604,204 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result [output_dir] [--gif path] [--format jpeg|png] + // [--quality N] [--max-width N] [--max-height N] [--every-nth N] + "screencast" => { + let first = rest.first().ok_or_else(|| ParseError::MissingArguments { + context: "screencast".to_string(), + usage: "screencast [options]", + })?; + + // Interactive start/stop mode + if *first == "start" { + let mut format: Option = None; + let mut quality: Option = None; + let mut fps: Option = None; + let mut i = 1; + while i < rest.len() { + match rest[i] { + "--format" => { + i += 1; + format = rest.get(i).map(|s| s.to_string()); + } + "--quality" => { + i += 1; + quality = rest.get(i).and_then(|s| s.parse().ok()); + } + "--fps" => { + i += 1; + fps = rest.get(i).and_then(|s| s.parse().ok()); + } + _ => {} + } + i += 1; + } + let mut cmd = json!({ "id": id, "action": "screencast_rec_start" }); + if let Some(f) = format { + cmd["format"] = json!(f); + } + if let Some(q) = quality { + cmd["quality"] = json!(q); + } + if let Some(f) = fps { + cmd["fps"] = json!(f); + } + return Ok(cmd); + } + + if *first == "stop" { + let mut output_dir: Option = None; + let mut gif_path: Option = None; + let mut i = 1; + while i < rest.len() { + match rest[i] { + "--gif" => { + i += 1; + gif_path = rest.get(i).map(|s| s.to_string()); + } + other if !other.starts_with('-') && output_dir.is_none() => { + output_dir = Some(other.to_string()); + } + _ => {} + } + i += 1; + } + let mut cmd = json!({ "id": id, "action": "screencast_rec_stop" }); + if let Some(dir) = output_dir { + cmd["outputDir"] = json!(dir); + } + if let Some(gp) = gif_path { + cmd["gifPath"] = json!(gp); + } + return Ok(cmd); + } + + // Fixed-duration mode: screencast [options] + let duration: u64 = first + .parse() + .map_err(|_| ParseError::InvalidValue { + message: format!("'{}' is not a valid duration or subcommand", first), + usage: "screencast ", + })?; + + let mut output_dir: Option = None; + let mut gif_path: Option = None; + let mut format: Option = None; + let mut quality: Option = None; + let mut max_width: Option = None; + let mut max_height: Option = None; + let mut every_nth: Option = None; + + let mut i = 1; + while i < rest.len() { + match rest[i] { + "--gif" => { + i += 1; + gif_path = rest.get(i).map(|s| s.to_string()); + } + "--format" => { + i += 1; + format = rest.get(i).map(|s| s.to_string()); + } + "--quality" => { + i += 1; + quality = rest.get(i).and_then(|s| s.parse().ok()); + } + "--max-width" => { + i += 1; + max_width = rest.get(i).and_then(|s| s.parse().ok()); + } + "--max-height" => { + i += 1; + max_height = rest.get(i).and_then(|s| s.parse().ok()); + } + "--every-nth" => { + i += 1; + every_nth = rest.get(i).and_then(|s| s.parse().ok()); + } + other if !other.starts_with('-') && output_dir.is_none() => { + output_dir = Some(other.to_string()); + } + _ => {} + } + i += 1; + } + + let mut cmd = json!({ + "id": id, + "action": "screencast", + "duration": duration, + }); + if let Some(dir) = output_dir { + cmd["outputDir"] = json!(dir); + } + if let Some(gp) = gif_path { + cmd["gifPath"] = json!(gp); + } + if let Some(f) = format { + cmd["format"] = json!(f); + } + if let Some(q) = quality { + cmd["quality"] = json!(q); + } + if let Some(w) = max_width { + cmd["maxWidth"] = json!(w); + } + if let Some(h) = max_height { + cmd["maxHeight"] = json!(h); + } + if let Some(n) = every_nth { + cmd["everyNthFrame"] = json!(n); + } + Ok(cmd) + } + + // === Animation Inspection === + // animation list — list all running animations + // animation pause [index] — pause all or one animation + // animation resume [index] — resume all or one animation + // animation scrub [index] — scrub to 0.0–1.0 + // animation audit — performance/a11y audit + "animation" => { + let subcmd = rest.first().ok_or_else(|| ParseError::MissingArguments { + context: "animation".to_string(), + usage: "animation [args]", + })?; + match *subcmd { + "list" => Ok(json!({ "id": id, "action": "animation_list" })), + "pause" => { + let index: Option = rest.get(1).and_then(|s| s.parse().ok()); + Ok(json!({ "id": id, "action": "animation_pause", "index": index })) + } + "resume" | "play" => { + let index: Option = rest.get(1).and_then(|s| s.parse().ok()); + Ok(json!({ "id": id, "action": "animation_resume", "index": index })) + } + "scrub" => { + let progress_str = rest.get(1).ok_or_else(|| ParseError::MissingArguments { + context: "animation scrub".to_string(), + usage: "animation scrub [index]", + })?; + let progress: f64 = progress_str.parse().map_err(|_| { + ParseError::InvalidValue { + message: format!("'{}' is not a valid progress value", progress_str), + usage: "animation scrub <0.0-1.0> [index]", + } + })?; + let index: Option = rest.get(2).and_then(|s| s.parse().ok()); + Ok(json!({ "id": id, "action": "animation_scrub", "progress": progress, "index": index })) + } + "audit" => Ok(json!({ "id": id, "action": "animation_audit" })), + _ => Err(ParseError::InvalidValue { + message: format!("'{}' is not a valid animation subcommand", subcmd), + usage: "animation ", + }), + } + } + // === Snapshot === "snapshot" => { let mut cmd = json!({ "id": id, "action": "snapshot" }); diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index d62341a77..01c71096f 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -28,7 +28,9 @@ use super::interaction; use super::network::{self, DomainFilter, EventTracker}; use super::policy::{ActionPolicy, ConfirmActions, PolicyResult}; use super::providers; +use super::animation; use super::recording::{self, RecordingState}; +use super::screencast::{self, ScreencastRecording}; use super::screenshot::{self, ScreenshotOptions}; use super::snapshot::{self, SnapshotOptions}; use super::state; @@ -203,6 +205,7 @@ pub struct DaemonState { pub session_id: String, pub tracing_state: TracingState, pub recording_state: RecordingState, + pub screencast_recording: Option, event_rx: Option>, pub screencasting: bool, pub policy: Option, @@ -270,6 +273,7 @@ impl DaemonState { session_id: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()), tracing_state: TracingState::new(), recording_state: RecordingState::new(), + screencast_recording: None, event_rx: None, screencasting: false, policy: ActionPolicy::load_if_exists(), @@ -1067,6 +1071,9 @@ impl DaemonState { pending_acks.push(sid); } } + + // Frame collection for interactive recording is handled + // by a background task in ScreencastRecording. } "Page.javascriptDialogOpening" => { if let Ok(dialog_event) = @@ -1322,6 +1329,15 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { "recording_start" => handle_recording_start(cmd, state).await, "recording_stop" => handle_recording_stop(state).await, "recording_restart" => handle_recording_restart(cmd, state).await, + "burst_capture" => handle_burst_capture(cmd, state).await, + "screencast" => handle_screencast(cmd, state).await, + "screencast_rec_start" => handle_screencast_rec_start(cmd, state).await, + "screencast_rec_stop" => handle_screencast_rec_stop(cmd, state).await, + "animation_list" => handle_animation_list(state).await, + "animation_pause" => handle_animation_pause(cmd, state).await, + "animation_resume" => handle_animation_resume(cmd, state).await, + "animation_scrub" => handle_animation_scrub(cmd, state).await, + "animation_audit" => handle_animation_audit(state).await, "pdf" => handle_pdf(cmd, state).await, "tab_list" => handle_tab_list(state).await, "tab_new" => handle_tab_new(cmd, state).await, @@ -1425,6 +1441,16 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { _ => Err(format!("Not yet implemented: {}", action)), }; + // Log action to interactive screencast recording if active + if let Some(ref mut rec) = state.screencast_recording { + if !matches!( + action, + "screencast_rec_start" | "screencast_rec_stop" | "screencast" | "" + ) { + rec.log_action(action, cmd); + } + } + let mut resp = match result { Ok(data) => success_response(&id, data), Err(e) => error_response(&id, &super::browser::to_ai_friendly_error(&e)), @@ -2246,6 +2272,28 @@ async fn handle_close(state: &mut DaemonState) -> Result { } } } + // Auto-save interactive screencast recording before closing + if let Some(recording) = state.screencast_recording.take() { + let fallback_dir = dirs::home_dir() + .unwrap_or_else(std::env::temp_dir) + .join(".agent-browser") + .join("tmp") + .join("screencast"); + let dir = fallback_dir.to_string_lossy().to_string(); + match recording.finish(&dir, None).await { + Ok(result) => { + eprintln!( + "Screencast recording auto-saved: {} frames to {}", + result.frames.len(), + dir + ); + } + Err(e) => { + eprintln!("Warning: failed to auto-save screencast recording: {}", e); + } + } + } + if let Some(ref mut mgr) = state.browser { mgr.close().await?; } @@ -4041,6 +4089,263 @@ async fn handle_recording_restart(cmd: &Value, state: &mut DaemonState) -> Resul Ok(result) } +async fn handle_burst_capture(cmd: &Value, state: &DaemonState) -> Result { + let mgr = state.browser.as_ref().ok_or("Browser not launched")?; + let session_id = mgr.active_session_id()?.to_string(); + + let count = cmd + .get("count") + .and_then(|v| v.as_u64()) + .unwrap_or(10) as u32; + let interval = cmd + .get("interval") + .and_then(|v| v.as_u64()) + .unwrap_or(200); + let format = cmd + .get("format") + .and_then(|v| v.as_str()) + .unwrap_or("png"); + let quality = cmd.get("quality").and_then(|v| v.as_i64()).map(|q| q as i32); + let output_dir = cmd + .get("outputDir") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| { + // Default to ~/.agent-browser/tmp/burst/ + "" + }); + let output_dir = if output_dir.is_empty() { + let dir = dirs::home_dir() + .unwrap_or_else(std::env::temp_dir) + .join(".agent-browser") + .join("tmp") + .join("burst"); + dir.to_string_lossy().to_string() + } else { + output_dir.to_string() + }; + let gif_path = cmd.get("gifPath").and_then(|v| v.as_str()); + + let result = screencast::burst_capture( + &mgr.client, + &session_id, + count, + interval, + format, + quality, + &output_dir, + gif_path, + ) + .await?; + + let mut response = json!({ + "frames": result.frames, + "count": result.frames.len(), + "outputDir": output_dir, + }); + if let Some(gif) = result.gif { + response["gif"] = json!(gif); + } + Ok(response) +} + +async fn handle_screencast(cmd: &Value, state: &DaemonState) -> Result { + let mgr = state.browser.as_ref().ok_or("Browser not launched")?; + let session_id = mgr.active_session_id()?.to_string(); + + let duration = cmd + .get("duration") + .and_then(|v| v.as_u64()) + .ok_or("Missing 'duration' parameter")?; + let format = cmd + .get("format") + .and_then(|v| v.as_str()) + .unwrap_or("jpeg"); + let quality = cmd.get("quality").and_then(|v| v.as_i64()).map(|q| q as i32); + let max_width = cmd.get("maxWidth").and_then(|v| v.as_u64()).map(|w| w as u32); + let max_height = cmd + .get("maxHeight") + .and_then(|v| v.as_u64()) + .map(|h| h as u32); + let every_nth = cmd + .get("everyNthFrame") + .and_then(|v| v.as_u64()) + .map(|n| n as u32); + let output_dir = cmd + .get("outputDir") + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| { + let dir = dirs::home_dir() + .unwrap_or_else(std::env::temp_dir) + .join(".agent-browser") + .join("tmp") + .join("screencast"); + dir.to_string_lossy().to_string() + }); + let gif_path = cmd.get("gifPath").and_then(|v| v.as_str()); + + let result = screencast::screencast_capture( + &mgr.client, + &session_id, + duration, + format, + quality, + max_width, + max_height, + every_nth, + &output_dir, + gif_path, + ) + .await?; + + let mut response = json!({ + "frames": result.frames, + "count": result.frames.len(), + "outputDir": output_dir, + }); + if let Some(gif) = result.gif { + response["gif"] = json!(gif); + } + Ok(response) +} + +async fn handle_screencast_rec_start( + cmd: &Value, + state: &mut DaemonState, +) -> Result { + let mgr = state.browser.as_ref().ok_or("Browser not launched")?; + let session_id = mgr.active_session_id()?.to_string(); + + if state.screencast_recording.is_some() { + return Err("Screencast recording already active".to_string()); + } + + let format = cmd + .get("format") + .and_then(|v| v.as_str()) + .unwrap_or("jpeg"); + let quality = cmd.get("quality").and_then(|v| v.as_i64()).map(|q| q as i32); + let fps = cmd.get("fps").and_then(|v| v.as_u64()).map(|f| f as u32); + + // Use stored viewport as default for screencast dimensions + let (default_w, default_h) = if let Some(ref server) = state.stream_server { + server.viewport().await + } else { + (1280, 720) + }; + + stream::start_screencast( + &mgr.client, + &session_id, + format, + quality.unwrap_or(80), + default_w as i32, + default_h as i32, + ) + .await?; + + state.screencasting = true; + state.screencast_recording = Some(ScreencastRecording::new( + format, + quality, + fps, + mgr.client.clone(), + &session_id, + )); + + if let Some(ref server) = state.stream_server { + server.set_screencasting(true).await; + } + + Ok(json!({ "started": true, "format": format })) +} + +async fn handle_screencast_rec_stop( + cmd: &Value, + state: &mut DaemonState, +) -> Result { + let recording = state + .screencast_recording + .take() + .ok_or("No screencast recording active")?; + + // Stop CDP screencast if browser is still alive + if let Some(ref mgr) = state.browser { + if let Ok(session_id) = mgr.active_session_id() { + let _ = stream::stop_screencast(&mgr.client, session_id).await; + } + } + state.screencasting = false; + + if let Some(ref server) = state.stream_server { + server.set_screencasting(false).await; + } + + let output_dir = cmd + .get("outputDir") + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| { + let dir = dirs::home_dir() + .unwrap_or_else(std::env::temp_dir) + .join(".agent-browser") + .join("tmp") + .join("screencast"); + dir.to_string_lossy().to_string() + }); + let gif_path = cmd.get("gifPath").and_then(|v| v.as_str()); + + let result = recording.finish(&output_dir, gif_path).await?; + + let mut response = json!({ + "frames": result.frames, + "count": result.frames.len(), + "timeline": result.timeline, + "outputDir": output_dir, + }); + if let Some(gif) = result.gif { + response["gif"] = json!(gif); + } + Ok(response) +} + +async fn handle_animation_list(state: &DaemonState) -> Result { + let mgr = state.browser.as_ref().ok_or("Browser not launched")?; + let session_id = mgr.active_session_id()?.to_string(); + animation::list_animations(&mgr.client, &session_id).await +} + +async fn handle_animation_pause(cmd: &Value, state: &DaemonState) -> Result { + let mgr = state.browser.as_ref().ok_or("Browser not launched")?; + let session_id = mgr.active_session_id()?.to_string(); + let index = cmd.get("index").and_then(|v| v.as_u64()).map(|i| i as u32); + animation::pause_animations(&mgr.client, &session_id, index).await +} + +async fn handle_animation_resume(cmd: &Value, state: &DaemonState) -> Result { + let mgr = state.browser.as_ref().ok_or("Browser not launched")?; + let session_id = mgr.active_session_id()?.to_string(); + let index = cmd.get("index").and_then(|v| v.as_u64()).map(|i| i as u32); + animation::resume_animations(&mgr.client, &session_id, index).await +} + +async fn handle_animation_scrub(cmd: &Value, state: &DaemonState) -> Result { + let mgr = state.browser.as_ref().ok_or("Browser not launched")?; + let session_id = mgr.active_session_id()?.to_string(); + let progress = cmd + .get("progress") + .and_then(|v| v.as_f64()) + .ok_or("Missing 'progress' parameter")?; + let index = cmd.get("index").and_then(|v| v.as_u64()).map(|i| i as u32); + animation::scrub_animations(&mgr.client, &session_id, progress, index).await +} + +async fn handle_animation_audit(state: &DaemonState) -> Result { + let mgr = state.browser.as_ref().ok_or("Browser not launched")?; + let session_id = mgr.active_session_id()?.to_string(); + animation::audit_animations(&mgr.client, &session_id).await +} + async fn handle_pdf(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); diff --git a/cli/src/native/animation.rs b/cli/src/native/animation.rs new file mode 100644 index 000000000..4502b8eec --- /dev/null +++ b/cli/src/native/animation.rs @@ -0,0 +1,341 @@ +use serde_json::Value; + +use super::cdp::client::CdpClient; +use super::cdp::types::{EvaluateParams, EvaluateResult}; + +/// Query all running animations via the Web Animations API. +/// Returns structured JSON with animation details. +pub async fn list_animations(client: &CdpClient, session_id: &str) -> Result { + let expression = r#"(() => { + const animations = document.getAnimations(); + return animations.map((a, i) => { + const effect = a.effect; + const target = effect && effect.target; + const timing = effect && effect.getTiming ? effect.getTiming() : null; + const computed = effect && effect.getComputedTiming ? effect.getComputedTiming() : null; + let keyframes = null; + let keyframeError = null; + try { keyframes = effect && effect.getKeyframes ? effect.getKeyframes() : null; } catch(e) { keyframeError = e.message || String(e); } + + let targetDesc = null; + if (target) { + const tag = target.tagName ? target.tagName.toLowerCase() : ''; + const id = target.id ? '#' + target.id : ''; + const cls = target.className && typeof target.className === 'string' + ? '.' + target.className.trim().split(/\s+/).join('.') + : ''; + targetDesc = tag + id + cls; + } + + return { + index: i, + id: a.id || null, + animationName: a.animationName || null, + playState: a.playState, + currentTime: a.currentTime, + startTime: a.startTime, + playbackRate: a.playbackRate, + target: targetDesc, + duration: timing ? timing.duration : null, + delay: timing ? timing.delay : null, + endDelay: timing ? timing.endDelay : null, + iterations: timing ? timing.iterations : null, + direction: timing ? timing.direction : null, + easing: timing ? timing.easing : null, + fill: timing ? timing.fill : null, + progress: computed ? computed.progress : null, + activeDuration: computed ? computed.activeDuration : null, + localTime: computed ? computed.localTime : null, + keyframes: keyframes, + keyframeError: keyframeError, + type: a.constructor.name + }; + }); + })()"#; + + let result: EvaluateResult = client + .send_command_typed( + "Runtime.evaluate", + &EvaluateParams { + expression: expression.to_string(), + return_by_value: Some(true), + await_promise: Some(false), + }, + Some(session_id), + ) + .await?; + + if let Some(details) = &result.exception_details { + let text = details + .exception + .as_ref() + .and_then(|e| e.description.as_deref()) + .unwrap_or(&details.text); + return Err(format!("Failed to list animations: {}", text)); + } + + match result.result.value { + Some(v) => Ok(v), + None => Ok(serde_json::json!([])) + } +} + +/// Pause all animations or a specific animation by index. +pub async fn pause_animations( + client: &CdpClient, + session_id: &str, + index: Option, +) -> Result { + let expression = match index { + Some(i) => format!( + r#"(() => {{ + const anims = document.getAnimations(); + if ({i} >= anims.length) return {{ error: 'Index {i} out of range, ' + anims.length + ' animations found' }}; + anims[{i}].pause(); + return {{ paused: 1, index: {i} }}; + }})()"#, + i = i + ), + None => r#"(() => { + const anims = document.getAnimations(); + anims.forEach(a => a.pause()); + return { paused: anims.length }; + })()"# + .to_string(), + }; + + eval_and_return(client, session_id, &expression).await +} + +/// Resume all animations or a specific animation by index. +pub async fn resume_animations( + client: &CdpClient, + session_id: &str, + index: Option, +) -> Result { + let expression = match index { + Some(i) => format!( + r#"(() => {{ + const anims = document.getAnimations(); + if ({i} >= anims.length) return {{ error: 'Index {i} out of range, ' + anims.length + ' animations found' }}; + anims[{i}].play(); + return {{ resumed: 1, index: {i} }}; + }})()"#, + i = i + ), + None => r#"(() => { + const anims = document.getAnimations(); + anims.forEach(a => a.play()); + return { resumed: anims.length }; + })()"# + .to_string(), + }; + + eval_and_return(client, session_id, &expression).await +} + +/// Scrub all animations or a specific one to a given progress (0.0–1.0). +pub async fn scrub_animations( + client: &CdpClient, + session_id: &str, + progress: f64, + index: Option, +) -> Result { + let progress = progress.clamp(0.0, 1.0); + + let expression = match index { + Some(i) => format!( + r#"(() => {{ + const anims = document.getAnimations(); + if ({i} >= anims.length) return {{ error: 'Index {i} out of range, ' + anims.length + ' animations found' }}; + const a = anims[{i}]; + a.pause(); + const timing = a.effect && a.effect.getComputedTiming ? a.effect.getComputedTiming() : null; + const duration = timing ? timing.activeDuration : (a.effect && a.effect.getTiming ? a.effect.getTiming().duration : 0); + a.currentTime = duration * {progress}; + return {{ scrubbed: 1, index: {i}, progress: {progress}, currentTime: a.currentTime }}; + }})()"#, + i = i, + progress = progress + ), + None => format!( + r#"(() => {{ + const anims = document.getAnimations(); + let count = 0; + anims.forEach(a => {{ + a.pause(); + const timing = a.effect && a.effect.getComputedTiming ? a.effect.getComputedTiming() : null; + const duration = timing ? timing.activeDuration : (a.effect && a.effect.getTiming ? a.effect.getTiming().duration : 0); + a.currentTime = duration * {progress}; + count++; + }}); + return {{ scrubbed: count, progress: {progress} }}; + }})()"#, + progress = progress + ), + }; + + eval_and_return(client, session_id, &expression).await +} + +/// Audit animations for performance and a11y issues. +/// Checks for: animating layout properties, missing prefers-reduced-motion, +/// excessive duration, infinite iterations without purpose. +pub async fn audit_animations(client: &CdpClient, session_id: &str) -> Result { + let expression = r#"(() => { + const LAYOUT_PROPS = new Set([ + 'width', 'height', 'top', 'left', 'right', 'bottom', + 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', + 'border-width', 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', + 'font-size', 'line-height' + ]); + const PERF_GOOD = new Set(['transform', 'opacity', 'filter', 'clip-path']); + + const animations = document.getAnimations(); + const results = []; + + // Check prefers-reduced-motion + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + let reducedMotionStylesheet = false; + try { + for (const sheet of document.styleSheets) { + try { + for (const rule of sheet.cssRules) { + if (rule.conditionText && rule.conditionText.includes('prefers-reduced-motion')) { + reducedMotionStylesheet = true; + break; + } + } + } catch(e) { /* cross-origin */ } + if (reducedMotionStylesheet) break; + } + } catch(e) { /* stylesheet access may fail in restricted contexts */ } + + for (const anim of animations) { + const issues = []; + const effect = anim.effect; + const timing = effect && effect.getTiming ? effect.getTiming() : null; + + // Check animated properties for layout triggers + let keyframes = []; + try { keyframes = effect && effect.getKeyframes ? effect.getKeyframes() : []; } catch(e) { /* cross-origin or unsupported */ } + + const animatedProps = new Set(); + for (const kf of keyframes) { + for (const key of Object.keys(kf)) { + if (key !== 'offset' && key !== 'easing' && key !== 'composite' && key !== 'computedOffset') { + animatedProps.add(key); + } + } + } + + const layoutProps = []; + const perfGoodProps = []; + for (const prop of animatedProps) { + const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase(); + if (LAYOUT_PROPS.has(cssProp)) layoutProps.push(cssProp); + if (PERF_GOOD.has(cssProp)) perfGoodProps.push(cssProp); + } + + if (layoutProps.length > 0) { + issues.push({ + severity: 'warning', + type: 'layout-trigger', + message: 'Animating layout properties causes reflow: ' + layoutProps.join(', '), + suggestion: 'Use transform/opacity instead for smooth 60fps animations' + }); + } + + // Duration checks + if (timing && timing.duration > 5000) { + issues.push({ + severity: 'info', + type: 'long-duration', + message: 'Animation duration is ' + timing.duration + 'ms (>5s)', + suggestion: 'Consider shorter duration for better UX' + }); + } + + // Infinite iteration check + if (timing && timing.iterations === Infinity && !timing.fill) { + issues.push({ + severity: 'info', + type: 'infinite-no-fill', + message: 'Infinite animation with no fill mode' + }); + } + + const target = effect && effect.target; + let targetDesc = null; + if (target) { + const tag = target.tagName ? target.tagName.toLowerCase() : ''; + const id = target.id ? '#' + target.id : ''; + targetDesc = tag + id; + } + + results.push({ + animationName: anim.animationName || anim.id || null, + type: anim.constructor.name, + target: targetDesc, + playState: anim.playState, + duration: timing ? timing.duration : null, + iterations: timing ? timing.iterations : null, + easing: timing ? timing.easing : null, + animatedProperties: Array.from(animatedProps), + performanceGood: layoutProps.length === 0 && perfGoodProps.length > 0, + issues: issues + }); + } + + return { + totalAnimations: animations.length, + prefersReducedMotionActive: reducedMotion, + prefersReducedMotionHandled: reducedMotionStylesheet, + a11yWarning: (!reducedMotionStylesheet && animations.length > 0) + ? 'No prefers-reduced-motion media query found — users who prefer reduced motion will still see all animations' + : null, + animations: results + }; + })()"#; + + eval_and_return(client, session_id, expression).await +} + +async fn eval_and_return( + client: &CdpClient, + session_id: &str, + expression: &str, +) -> Result { + let result: EvaluateResult = client + .send_command_typed( + "Runtime.evaluate", + &EvaluateParams { + expression: expression.to_string(), + return_by_value: Some(true), + await_promise: Some(false), + }, + Some(session_id), + ) + .await?; + + if let Some(details) = &result.exception_details { + let text = details + .exception + .as_ref() + .and_then(|e| e.description.as_deref()) + .unwrap_or(&details.text); + return Err(format!("JS evaluation error: {}", text)); + } + + match result.result.value { + Some(v) => { + // Detect JS-returned error objects like { error: "..." } + if let Some(err_msg) = v.get("error").and_then(|e| e.as_str()) { + return Err(err_msg.to_string()); + } + Ok(v) + } + None => Ok(serde_json::json!(null)), + } +} diff --git a/cli/src/native/mod.rs b/cli/src/native/mod.rs index f86c816e8..c0d6b612a 100644 --- a/cli/src/native/mod.rs +++ b/cli/src/native/mod.rs @@ -1,6 +1,8 @@ #[allow(dead_code)] pub mod actions; #[allow(dead_code)] +pub mod animation; +#[allow(dead_code)] pub mod auth; #[allow(dead_code)] pub mod browser; @@ -27,6 +29,8 @@ pub mod providers; #[allow(dead_code)] pub mod recording; #[allow(dead_code)] +pub mod screencast; +#[allow(dead_code)] pub mod screenshot; #[allow(dead_code)] pub mod snapshot; diff --git a/cli/src/native/screencast.rs b/cli/src/native/screencast.rs new file mode 100644 index 000000000..0a8d83fd0 --- /dev/null +++ b/cli/src/native/screencast.rs @@ -0,0 +1,656 @@ +use serde::Serialize; +use serde_json::Value; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{oneshot, Mutex}; + +use super::cdp::client::CdpClient; +use super::cdp::types::{CaptureScreenshotParams, CaptureScreenshotResult}; + +/// Capture N screenshots at a fixed interval, saving each as a numbered file. +/// Returns a list of saved file paths plus optionally an animated GIF path. +pub async fn burst_capture( + client: &CdpClient, + session_id: &str, + count: u32, + interval_ms: u64, + format: &str, + quality: Option, + output_dir: &str, + gif_path: Option<&str>, +) -> Result { + let dir = PathBuf::from(output_dir); + std::fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create output dir {}: {}", output_dir, e))?; + + let ext = if format == "jpeg" { "jpg" } else { "png" }; + let params = CaptureScreenshotParams { + format: Some(format.to_string()), + quality: if format == "jpeg" { + quality.or(Some(80)) + } else { + None + }, + clip: None, + from_surface: Some(true), + capture_beyond_viewport: None, + }; + + let mut paths = Vec::with_capacity(count as usize); + let mut frames_for_gif: Vec> = Vec::new(); + let collect_gif = gif_path.is_some(); + + let mut interval = tokio::time::interval(Duration::from_millis(interval_ms)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + for i in 0..count { + interval.tick().await; + + let result: CaptureScreenshotResult = client + .send_command_typed("Page.captureScreenshot", ¶ms, Some(session_id)) + .await?; + + let bytes = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &result.data, + ) + .map_err(|e| format!("Base64 decode error on frame {}: {}", i, e))?; + + let frame_path = dir + .join(format!("frame-{:04}.{}", i, ext)) + .to_string_lossy() + .to_string(); + + std::fs::write(&frame_path, &bytes) + .map_err(|e| format!("Failed to write frame {}: {}", i, e))?; + + paths.push(frame_path); + + if collect_gif { + frames_for_gif.push(bytes); + } + } + + let gif_output = if let Some(gif_dest) = gif_path { + let delay_centisecs = (interval_ms as u16) / 10; + encode_gif(&frames_for_gif, gif_dest, delay_centisecs)?; + Some(gif_dest.to_string()) + } else { + None + }; + + Ok(BurstResult { + frames: paths, + gif: gif_output, + }) +} + +pub struct BurstResult { + pub frames: Vec, + pub gif: Option, +} + +/// Use CDP Page.startScreencast for efficient frame streaming. +/// Collects frames for `duration_ms` and saves them, optionally encoding a GIF. +pub async fn screencast_capture( + client: &CdpClient, + session_id: &str, + duration_ms: u64, + format: &str, + quality: Option, + max_width: Option, + max_height: Option, + every_nth_frame: Option, + output_dir: &str, + gif_path: Option<&str>, +) -> Result { + let dir = PathBuf::from(output_dir); + std::fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create output dir {}: {}", output_dir, e))?; + + let ext = if format == "jpeg" { "jpg" } else { "png" }; + + // Subscribe to CDP events before starting screencast + let mut event_rx = client.subscribe(); + + // Start screencast + let mut start_params = serde_json::json!({ + "format": format, + }); + if let Some(q) = quality { + start_params["quality"] = serde_json::json!(q); + } + if let Some(w) = max_width { + start_params["maxWidth"] = serde_json::json!(w); + } + if let Some(h) = max_height { + start_params["maxHeight"] = serde_json::json!(h); + } + if let Some(n) = every_nth_frame { + start_params["everyNthFrame"] = serde_json::json!(n); + } + + client + .send_command( + "Page.startScreencast", + Some(start_params), + Some(session_id), + ) + .await?; + + let mut frames: Vec> = Vec::new(); + let mut paths: Vec = Vec::new(); + let deadline = tokio::time::Instant::now() + Duration::from_millis(duration_ms); + + loop { + let timeout = tokio::time::sleep_until(deadline); + tokio::pin!(timeout); + + tokio::select! { + _ = &mut timeout => break, + event = event_rx.recv() => { + match event { + Ok(cdp_event) => { + if cdp_event.method == "Page.screencastFrame" { + let params = &cdp_event.params; + // Acknowledge the frame so Chrome keeps sending + let ack_session = cdp_event + .session_id + .as_deref() + .unwrap_or(session_id); + if let Some(frame_number) = params.get("sessionId").and_then(|v| v.as_i64()) { + let _ = client.send_command( + "Page.screencastFrameAck", + Some(serde_json::json!({ "sessionId": frame_number })), + Some(ack_session), + ).await; + } + + if let Some(data) = params.get("data").and_then(|v| v.as_str()) { + if let Ok(bytes) = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + data, + ) { + let idx = frames.len(); + let frame_path = dir + .join(format!("frame-{:04}.{}", idx, ext)) + .to_string_lossy() + .to_string(); + + std::fs::write(&frame_path, &bytes).map_err(|e| { + format!("Failed to write frame {}: {}", idx, e) + })?; + paths.push(frame_path); + frames.push(bytes); + } + } + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + } + } + } + } + + // Stop screencast — log but don't fail on cleanup error + if let Err(e) = client + .send_command_no_params("Page.stopScreencast", Some(session_id)) + .await + { + eprintln!("Warning: failed to stop screencast: {}", e); + } + + if frames.is_empty() { + return Err("No screencast frames captured".to_string()); + } + + let gif_output = if let Some(gif_dest) = gif_path { + // Estimate delay from duration and frame count + let delay_centisecs = if frames.len() > 1 { + ((duration_ms as f64 / frames.len() as f64) / 10.0).max(1.0) as u16 + } else { + 10 + }; + encode_gif(&frames, gif_dest, delay_centisecs)?; + Some(gif_dest.to_string()) + } else { + None + }; + + Ok(BurstResult { + frames: paths, + gif: gif_output, + }) +} + +/// Encode a sequence of image bytes (PNG or JPEG) into an animated GIF. +fn encode_gif(frames: &[Vec], output_path: &str, delay_centisecs: u16) -> Result<(), String> { + let refs: Vec<&[u8]> = frames.iter().map(|f| f.as_slice()).collect(); + encode_gif_refs(&refs, output_path, delay_centisecs) +} + +fn encode_gif_refs( + frames: &[&[u8]], + output_path: &str, + delay_centisecs: u16, +) -> Result<(), String> { + use image::codecs::gif::{GifEncoder, Repeat}; + use image::{Frame, RgbaImage}; + use std::fs::File; + + if frames.is_empty() { + return Err("No frames to encode".to_string()); + } + + let file = + File::create(output_path).map_err(|e| format!("Failed to create GIF file: {}", e))?; + + let mut encoder = GifEncoder::new_with_speed(file, 10); + encoder + .set_repeat(Repeat::Infinite) + .map_err(|e| format!("Failed to set GIF repeat: {}", e))?; + + for (i, frame_bytes) in frames.iter().enumerate() { + let img = image::load_from_memory(frame_bytes) + .map_err(|e| format!("Failed to decode frame {}: {}", i, e))?; + + let rgba: RgbaImage = img.to_rgba8(); + let delay = image::Delay::from_saturating_duration(Duration::from_millis( + delay_centisecs as u64 * 10, + )); + let frame = Frame::from_parts(rgba, 0, 0, delay); + encoder + .encode_frame(frame) + .map_err(|e| format!("Failed to encode frame {}: {}", i, e))?; + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Interactive screencast recording with action metadata +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct CapturedFrame { + bytes: Vec, + time_ms: u64, +} + +#[derive(Clone, Serialize)] +pub struct TimelineEntry { + #[serde(rename = "timeMs")] + pub time_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub action: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selector: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub direction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub frame: Option, +} + +pub struct ScreencastRecording { + start_time: Instant, + frames: Arc>>, + timeline: Vec, + format: String, + cancel_tx: Option>, + collector_task: Option>, +} + +impl ScreencastRecording { + /// Start a new recording with a background task that collects frames + /// from the CDP event broadcast channel. + /// `fps`: frames per second for polling fallback (default 2). + /// `quality`: JPEG quality 1-100 (default 60 for small GIFs). + pub fn new( + format: &str, + quality: Option, + fps: Option, + client: Arc, + session_id: &str, + ) -> Self { + let frames: Arc>> = Arc::new(Mutex::new(Vec::new())); + let frames_clone = frames.clone(); + let start_time = Instant::now(); + let client_arc = client.clone(); + let session_owned = session_id.to_string(); + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + let task_format = format.to_string(); + let task_quality = quality; + + let poll_ms = 1000 / (fps.unwrap_or(2).max(1)) as u64; + + let mut event_rx = client.subscribe(); + let task = tokio::spawn(async move { + let mut cancel_rx = std::pin::pin!(cancel_rx); + + let mut poll_interval = tokio::time::interval(Duration::from_millis(poll_ms)); + poll_interval + .set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + let is_jpeg = task_format == "jpeg"; + let poll_params = CaptureScreenshotParams { + format: Some(task_format), + quality: if is_jpeg { + task_quality.or(Some(60)) + } else { + None + }, + clip: None, + from_surface: Some(true), + capture_beyond_viewport: None, + }; + + loop { + tokio::select! { + _ = &mut cancel_rx => break, + event = event_rx.recv() => { + match event { + Ok(evt) if evt.method == "Page.screencastFrame" => { + if let Some(sid) = + evt.params.get("sessionId").and_then(|v| v.as_i64()) + { + if let Err(e) = client_arc + .send_command( + "Page.screencastFrameAck", + Some(serde_json::json!({ "sessionId": sid })), + Some(&session_owned), + ) + .await + { + eprintln!("Warning: screencast ACK failed: {}", e); + } + } + + if let Some(data) = + evt.params.get("data").and_then(|v| v.as_str()) + { + match base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + data, + ) { + Ok(bytes) => { + let time_ms = + start_time.elapsed().as_millis() as u64; + let mut guard = frames_clone.lock().await; + guard.push(CapturedFrame { bytes, time_ms }); + poll_interval.reset(); + } + Err(e) => { + eprintln!("Warning: screencast frame decode failed: {}", e); + } + } + } + } + Ok(_) => {} // other CDP events + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + eprintln!("Warning: screencast collector lagged, skipped {} events", n); + continue; + } + } + } + _ = poll_interval.tick() => { + if let Ok(result) = client_arc + .send_command_typed::<_, CaptureScreenshotResult>( + "Page.captureScreenshot", + &poll_params, + Some(&session_owned), + ) + .await + { + if let Ok(bytes) = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &result.data, + ) { + let time_ms = start_time.elapsed().as_millis() as u64; + let mut guard = frames_clone.lock().await; + guard.push(CapturedFrame { bytes, time_ms }); + } + } + } + } + } + }); + + let mut rec = Self { + start_time, + frames, + timeline: Vec::new(), + format: format.to_string(), + cancel_tx: Some(cancel_tx), + collector_task: Some(task), + }; + rec.timeline.push(TimelineEntry { + time_ms: 0, + action: None, + selector: None, + value: None, + url: None, + key: None, + direction: None, + duration: None, + event: Some("screencast_start".to_string()), + frame: Some(0), + }); + rec + } + + pub fn log_action(&mut self, action: &str, cmd: &Value) { + let time_ms = self.start_time.elapsed().as_millis() as u64; + + let selector = cmd + .get("selector") + .and_then(|v| v.as_str()) + .map(String::from); + let value = cmd + .get("value") + .or_else(|| cmd.get("text")) + .and_then(|v| v.as_str()) + .map(String::from); + let url = cmd.get("url").and_then(|v| v.as_str()).map(String::from); + let key = cmd.get("key").and_then(|v| v.as_str()).map(String::from); + let direction = cmd + .get("direction") + .and_then(|v| v.as_str()) + .map(String::from); + let duration = cmd + .get("duration") + .or_else(|| cmd.get("timeout")) + .and_then(|v| v.as_u64()); + + self.timeline.push(TimelineEntry { + time_ms, + action: Some(action.to_string()), + selector, + value, + url, + key, + direction, + duration, + event: None, + frame: None, + }); + } + + pub async fn finish( + mut self, + output_dir: &str, + gif_path: Option<&str>, + ) -> Result { + let stop_time = self.start_time.elapsed().as_millis() as u64; + + // Signal the collector to stop gracefully + if let Some(tx) = self.cancel_tx.take() { + let _ = tx.send(()); + } + + // Wait for the collector task to finish (with timeout) + if let Some(task) = self.collector_task.take() { + match tokio::time::timeout(Duration::from_millis(500), task).await { + Ok(Ok(())) => {} + Ok(Err(e)) => eprintln!("Warning: collector task panicked: {}", e), + Err(_) => eprintln!("Warning: collector task timed out, aborting"), + } + } + + let frames = match Arc::try_unwrap(self.frames) { + Ok(mutex) => mutex.into_inner(), + Err(arc) => { + let guard = arc.lock().await; + guard.clone() + } + }; + + save_recording_output(&frames, &self.format, &mut self.timeline, stop_time, output_dir, gif_path) + } +} + +fn save_recording_output( + frames: &[CapturedFrame], + format: &str, + timeline: &mut Vec, + stop_time: u64, + output_dir: &str, + gif_path: Option<&str>, +) -> Result { + let dir = PathBuf::from(output_dir); + std::fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create output dir: {}", e))?; + + let ext = if format == "jpeg" { "jpg" } else { "png" }; + let mut paths = Vec::with_capacity(frames.len()); + + for (i, frame) in frames.iter().enumerate() { + let frame_path = dir + .join(format!("frame-{:04}.{}", i, ext)) + .to_string_lossy() + .to_string(); + std::fs::write(&frame_path, &frame.bytes) + .map_err(|e| format!("Failed to write frame {}: {}", i, e))?; + paths.push(frame_path); + } + + // Correlate timeline entries with nearest frame by timestamp + for entry in timeline.iter_mut() { + if entry.frame.is_some() { + continue; + } + entry.frame = Some(nearest_frame_index(frames, entry.time_ms)); + } + + // Add stop event + timeline.push(TimelineEntry { + time_ms: stop_time, + action: None, + selector: None, + value: None, + url: None, + key: None, + direction: None, + duration: None, + event: Some("screencast_stop".to_string()), + frame: Some(frames.len().saturating_sub(1)), + }); + + let gif_output = if let Some(gif_dest) = gif_path { + if frames.is_empty() { + None + } else { + let frame_bytes: Vec<&[u8]> = frames.iter().map(|f| f.bytes.as_slice()).collect(); + let delay = if frames.len() > 1 { + let total_ms = frames.last().map(|f| f.time_ms).unwrap_or(stop_time); + ((total_ms as f64 / frames.len() as f64) / 10.0).max(1.0) as u16 + } else { + 10 + }; + encode_gif_refs(&frame_bytes, gif_dest, delay)?; + Some(gif_dest.to_string()) + } + } else { + None + }; + + Ok(ScreencastStopResult { + frames: paths, + timeline: timeline.clone(), + gif: gif_output, + }) +} + +fn nearest_frame_index(frames: &[CapturedFrame], target_ms: u64) -> usize { + if frames.is_empty() { + return 0; + } + let mut best = 0; + let mut best_diff = u64::MAX; + for (i, frame) in frames.iter().enumerate() { + let diff = if frame.time_ms > target_ms { + frame.time_ms - target_ms + } else { + target_ms - frame.time_ms + }; + if diff < best_diff { + best_diff = diff; + best = i; + } + } + best +} + +pub struct ScreencastStopResult { + pub frames: Vec, + pub timeline: Vec, + pub gif: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_gif_rejects_empty_frames() { + let result = encode_gif(&[], "/tmp/test_empty.gif", 10); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("No frames")); + } + + #[test] + fn encode_gif_produces_valid_file() { + // Create a minimal 2x2 red PNG frame + let mut buf = Vec::new(); + { + let mut img = image::RgbaImage::new(2, 2); + for pixel in img.pixels_mut() { + *pixel = image::Rgba([255, 0, 0, 255]); + } + let mut cursor = std::io::Cursor::new(&mut buf); + img.write_to(&mut cursor, image::ImageFormat::Png).unwrap(); + } + + let frames = vec![buf.clone(), buf]; + let path = "/tmp/agent_browser_test_encode.gif"; + let result = encode_gif(&frames, path, 10); + assert!(result.is_ok()); + + // Verify file exists and has GIF magic bytes + let data = std::fs::read(path).unwrap(); + assert!(data.starts_with(b"GIF")); + let _ = std::fs::remove_file(path); + } +}