wip adding screenshot tests

Richard Feldman created

Change summary

Cargo.lock                                 |   1 
crates/gpui/src/app.rs                     |   4 
crates/gpui/src/app/visual_test_context.rs | 435 ++++++++++++++++++++++++
crates/zed/Cargo.toml                      |   1 
crates/zed/src/zed.rs                      |   2 
crates/zed/src/zed/visual_tests.rs         | 361 +++++++++++++++++++
6 files changed, 804 insertions(+)

Detailed changes

Cargo.lock 🔗

@@ -20642,6 +20642,7 @@ dependencies = [
  "gpui",
  "gpui_tokio",
  "http_client",
+ "image",
  "image_viewer",
  "inspector_ui",
  "install_cli",

crates/gpui/src/app.rs 🔗

@@ -30,6 +30,8 @@ use smallvec::SmallVec;
 #[cfg(any(test, feature = "test-support"))]
 pub use test_context::*;
 use util::{ResultExt, debug_panic};
+#[cfg(all(target_os = "macos", any(test, feature = "test-support")))]
+pub use visual_test_context::*;
 
 #[cfg(any(feature = "inspector", debug_assertions))]
 use crate::InspectorElementRegistry;
@@ -52,6 +54,8 @@ mod context;
 mod entity_map;
 #[cfg(any(test, feature = "test-support"))]
 mod test_context;
+#[cfg(all(target_os = "macos", any(test, feature = "test-support")))]
+mod visual_test_context;
 
 /// The duration for which futures returned from [Context::on_app_quit] can run before the application fully quits.
 pub const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);

crates/gpui/src/app/visual_test_context.rs 🔗

@@ -0,0 +1,435 @@
+use crate::{
+    Action, AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, Bounds,
+    ClipboardItem, Context, Entity, ForegroundExecutor, Global, InputEvent, Keystroke, Modifiers,
+    MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, Point, Render,
+    Result, Size, Task, TextSystem, Window, WindowBounds, WindowHandle, WindowOptions,
+    app::GpuiMode, current_platform,
+};
+use anyhow::anyhow;
+use std::{future::Future, rc::Rc, sync::Arc, time::Duration};
+
+/// A test context that uses real macOS rendering instead of mocked rendering.
+/// This is used for visual tests that need to capture actual screenshots.
+///
+/// Unlike `TestAppContext` which uses `TestPlatform` with mocked rendering,
+/// `VisualTestAppContext` uses the real `MacPlatform` to produce actual rendered output.
+///
+/// Windows created through this context are positioned off-screen (at coordinates like -10000, -10000)
+/// so they are invisible to the user but still fully rendered by the compositor.
+#[derive(Clone)]
+pub struct VisualTestAppContext {
+    /// The underlying app cell
+    pub app: Rc<AppCell>,
+    /// The background executor for running async tasks
+    pub background_executor: BackgroundExecutor,
+    /// The foreground executor for running tasks on the main thread
+    pub foreground_executor: ForegroundExecutor,
+    platform: Rc<dyn Platform>,
+    text_system: Arc<TextSystem>,
+}
+
+impl VisualTestAppContext {
+    /// Creates a new `VisualTestAppContext` with real macOS platform rendering.
+    ///
+    /// This initializes the real macOS platform (not the test platform), which means:
+    /// - Windows are actually rendered by Metal/the compositor
+    /// - Screenshots can be captured via ScreenCaptureKit
+    /// - All platform APIs work as they do in production
+    pub fn new() -> Self {
+        let platform = current_platform(false);
+        let background_executor = platform.background_executor();
+        let foreground_executor = platform.foreground_executor();
+        let text_system = Arc::new(TextSystem::new(platform.text_system()));
+
+        let asset_source = Arc::new(());
+        let http_client = http_client::FakeHttpClient::with_404_response();
+
+        let mut app = App::new_app(platform.clone(), asset_source, http_client);
+        app.borrow_mut().mode = GpuiMode::test();
+
+        Self {
+            app,
+            background_executor,
+            foreground_executor,
+            platform,
+            text_system,
+        }
+    }
+
+    /// Opens a window positioned off-screen for invisible rendering.
+    ///
+    /// The window is positioned at (-10000, -10000) so it's not visible on any display,
+    /// but it's still fully rendered by the compositor and can be captured via ScreenCaptureKit.
+    ///
+    /// # Arguments
+    /// * `size` - The size of the window to create
+    /// * `build_root` - A closure that builds the root view for the window
+    pub fn open_offscreen_window<V: Render + 'static>(
+        &mut self,
+        size: Size<Pixels>,
+        build_root: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
+    ) -> Result<WindowHandle<V>> {
+        use crate::{point, px};
+
+        let bounds = Bounds {
+            origin: point(px(-10000.0), px(-10000.0)),
+            size,
+        };
+
+        let mut cx = self.app.borrow_mut();
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                focus: false,
+                show: true,
+                ..Default::default()
+            },
+            build_root,
+        )
+    }
+
+    /// Opens an off-screen window with default size (1280x800).
+    pub fn open_offscreen_window_default<V: Render + 'static>(
+        &mut self,
+        build_root: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
+    ) -> Result<WindowHandle<V>> {
+        use crate::{px, size};
+        self.open_offscreen_window(size(px(1280.0), px(800.0)), build_root)
+    }
+
+    /// Returns whether screen capture is supported on this platform.
+    pub fn is_screen_capture_supported(&self) -> bool {
+        self.platform.is_screen_capture_supported()
+    }
+
+    /// Returns the text system used by this context.
+    pub fn text_system(&self) -> &Arc<TextSystem> {
+        &self.text_system
+    }
+
+    /// Returns the background executor.
+    pub fn executor(&self) -> BackgroundExecutor {
+        self.background_executor.clone()
+    }
+
+    /// Returns the foreground executor.
+    pub fn foreground_executor(&self) -> ForegroundExecutor {
+        self.foreground_executor.clone()
+    }
+
+    /// Runs pending background tasks until there's nothing left to do.
+    pub fn run_until_parked(&self) {
+        self.background_executor.run_until_parked();
+    }
+
+    /// Updates the app state.
+    pub fn update<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
+        let mut app = self.app.borrow_mut();
+        f(&mut app)
+    }
+
+    /// Reads from the app state.
+    pub fn read<R>(&self, f: impl FnOnce(&App) -> R) -> R {
+        let app = self.app.borrow();
+        f(&app)
+    }
+
+    /// Updates a window.
+    pub fn update_window<T, F>(&mut self, window: AnyWindowHandle, f: F) -> Result<T>
+    where
+        F: FnOnce(AnyView, &mut Window, &mut App) -> T,
+    {
+        let mut lock = self.app.borrow_mut();
+        lock.update_window(window, f)
+    }
+
+    /// Spawns a task on the foreground executor.
+    pub fn spawn<F, R>(&self, f: F) -> Task<R>
+    where
+        F: Future<Output = R> + 'static,
+        R: 'static,
+    {
+        self.foreground_executor.spawn(f)
+    }
+
+    /// Checks if a global of type G exists.
+    pub fn has_global<G: Global>(&self) -> bool {
+        let app = self.app.borrow();
+        app.has_global::<G>()
+    }
+
+    /// Reads a global value.
+    pub fn read_global<G: Global, R>(&self, f: impl FnOnce(&G, &App) -> R) -> R {
+        let app = self.app.borrow();
+        f(app.global::<G>(), &app)
+    }
+
+    /// Sets a global value.
+    pub fn set_global<G: Global>(&mut self, global: G) {
+        let mut app = self.app.borrow_mut();
+        app.set_global(global);
+    }
+
+    /// Updates a global value.
+    pub fn update_global<G: Global, R>(&mut self, f: impl FnOnce(&mut G, &mut App) -> R) -> R {
+        let mut lock = self.app.borrow_mut();
+        lock.update(|cx| {
+            let mut global = cx.lease_global::<G>();
+            let result = f(&mut global, cx);
+            cx.end_global_lease(global);
+            result
+        })
+    }
+
+    /// Simulates a sequence of keystrokes on the given window.
+    ///
+    /// Keystrokes are specified as a space-separated string, e.g., "cmd-p escape".
+    pub fn simulate_keystrokes(&mut self, window: AnyWindowHandle, keystrokes: &str) {
+        for keystroke_text in keystrokes.split_whitespace() {
+            let keystroke = Keystroke::parse(keystroke_text)
+                .unwrap_or_else(|_| panic!("Invalid keystroke: {}", keystroke_text));
+            self.dispatch_keystroke(window, keystroke);
+        }
+        self.run_until_parked();
+    }
+
+    /// Dispatches a single keystroke to a window.
+    pub fn dispatch_keystroke(&mut self, window: AnyWindowHandle, keystroke: Keystroke) {
+        self.update_window(window, |_, window, cx| {
+            window.dispatch_keystroke(keystroke, cx);
+        })
+        .ok();
+    }
+
+    /// Simulates typing text input on the given window.
+    pub fn simulate_input(&mut self, window: AnyWindowHandle, input: &str) {
+        for char in input.chars() {
+            let key = char.to_string();
+            let keystroke = Keystroke {
+                modifiers: Modifiers::default(),
+                key: key.clone(),
+                key_char: Some(key),
+            };
+            self.dispatch_keystroke(window, keystroke);
+        }
+        self.run_until_parked();
+    }
+
+    /// Simulates a mouse move event.
+    pub fn simulate_mouse_move(
+        &mut self,
+        window: AnyWindowHandle,
+        position: Point<Pixels>,
+        button: impl Into<Option<MouseButton>>,
+        modifiers: Modifiers,
+    ) {
+        self.simulate_event(
+            window,
+            MouseMoveEvent {
+                position,
+                modifiers,
+                pressed_button: button.into(),
+            },
+        );
+    }
+
+    /// Simulates a mouse down event.
+    pub fn simulate_mouse_down(
+        &mut self,
+        window: AnyWindowHandle,
+        position: Point<Pixels>,
+        button: MouseButton,
+        modifiers: Modifiers,
+    ) {
+        self.simulate_event(
+            window,
+            MouseDownEvent {
+                position,
+                modifiers,
+                button,
+                click_count: 1,
+                first_mouse: false,
+            },
+        );
+    }
+
+    /// Simulates a mouse up event.
+    pub fn simulate_mouse_up(
+        &mut self,
+        window: AnyWindowHandle,
+        position: Point<Pixels>,
+        button: MouseButton,
+        modifiers: Modifiers,
+    ) {
+        self.simulate_event(
+            window,
+            MouseUpEvent {
+                position,
+                modifiers,
+                button,
+                click_count: 1,
+            },
+        );
+    }
+
+    /// Simulates a click (mouse down followed by mouse up).
+    pub fn simulate_click(
+        &mut self,
+        window: AnyWindowHandle,
+        position: Point<Pixels>,
+        modifiers: Modifiers,
+    ) {
+        self.simulate_mouse_down(window, position, MouseButton::Left, modifiers);
+        self.simulate_mouse_up(window, position, MouseButton::Left, modifiers);
+    }
+
+    /// Simulates an input event on the given window.
+    pub fn simulate_event<E: InputEvent>(&mut self, window: AnyWindowHandle, event: E) {
+        self.update_window(window, |_, window, cx| {
+            window.dispatch_event(event.to_platform_input(), cx);
+        })
+        .ok();
+        self.run_until_parked();
+    }
+
+    /// Dispatches an action to the given window.
+    pub fn dispatch_action(&mut self, window: AnyWindowHandle, action: impl Action) {
+        self.update_window(window, |_, window, cx| {
+            window.dispatch_action(action.boxed_clone(), cx);
+        })
+        .ok();
+        self.run_until_parked();
+    }
+
+    /// Writes to the clipboard.
+    pub fn write_to_clipboard(&self, item: ClipboardItem) {
+        self.platform.write_to_clipboard(item);
+    }
+
+    /// Reads from the clipboard.
+    pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
+        self.platform.read_from_clipboard()
+    }
+
+    /// Waits for a condition to become true, with a timeout.
+    pub async fn wait_for<T: 'static>(
+        &mut self,
+        entity: &Entity<T>,
+        predicate: impl Fn(&T) -> bool,
+        timeout: Duration,
+    ) -> Result<()> {
+        let start = std::time::Instant::now();
+        loop {
+            {
+                let app = self.app.borrow();
+                if predicate(entity.read(&app)) {
+                    return Ok(());
+                }
+            }
+
+            if start.elapsed() > timeout {
+                return Err(anyhow!("Timed out waiting for condition"));
+            }
+
+            self.run_until_parked();
+            self.background_executor
+                .timer(Duration::from_millis(10))
+                .await;
+        }
+    }
+}
+
+impl Default for VisualTestAppContext {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl AppContext for VisualTestAppContext {
+    type Result<T> = T;
+
+    fn new<T: 'static>(
+        &mut self,
+        build_entity: impl FnOnce(&mut Context<T>) -> T,
+    ) -> Self::Result<Entity<T>> {
+        let mut app = self.app.borrow_mut();
+        app.new(build_entity)
+    }
+
+    fn reserve_entity<T: 'static>(&mut self) -> Self::Result<crate::Reservation<T>> {
+        let mut app = self.app.borrow_mut();
+        app.reserve_entity()
+    }
+
+    fn insert_entity<T: 'static>(
+        &mut self,
+        reservation: crate::Reservation<T>,
+        build_entity: impl FnOnce(&mut Context<T>) -> T,
+    ) -> Self::Result<Entity<T>> {
+        let mut app = self.app.borrow_mut();
+        app.insert_entity(reservation, build_entity)
+    }
+
+    fn update_entity<T: 'static, R>(
+        &mut self,
+        handle: &Entity<T>,
+        update: impl FnOnce(&mut T, &mut Context<T>) -> R,
+    ) -> Self::Result<R> {
+        let mut app = self.app.borrow_mut();
+        app.update_entity(handle, update)
+    }
+
+    fn as_mut<'a, T>(&'a mut self, _: &Entity<T>) -> Self::Result<crate::GpuiBorrow<'a, T>>
+    where
+        T: 'static,
+    {
+        panic!("Cannot use as_mut with a visual test app context. Try calling update() first")
+    }
+
+    fn read_entity<T, R>(
+        &self,
+        handle: &Entity<T>,
+        read: impl FnOnce(&T, &App) -> R,
+    ) -> Self::Result<R>
+    where
+        T: 'static,
+    {
+        let app = self.app.borrow();
+        app.read_entity(handle, read)
+    }
+
+    fn update_window<T, F>(&mut self, window: AnyWindowHandle, f: F) -> Result<T>
+    where
+        F: FnOnce(AnyView, &mut Window, &mut App) -> T,
+    {
+        let mut lock = self.app.borrow_mut();
+        lock.update_window(window, f)
+    }
+
+    fn read_window<T, R>(
+        &self,
+        window: &WindowHandle<T>,
+        read: impl FnOnce(Entity<T>, &App) -> R,
+    ) -> Result<R>
+    where
+        T: 'static,
+    {
+        let app = self.app.borrow();
+        app.read_window(window, read)
+    }
+
+    fn background_spawn<R>(&self, future: impl Future<Output = R> + Send + 'static) -> Task<R>
+    where
+        R: Send + 'static,
+    {
+        self.background_executor.spawn(future)
+    }
+
+    fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result<R>
+    where
+        G: Global,
+    {
+        let app = self.app.borrow();
+        callback(app.global::<G>(), &app)
+    }
+}

crates/zed/Cargo.toml 🔗

@@ -195,6 +195,7 @@ terminal_view = { workspace = true, features = ["test-support"] }
 tree-sitter-md.workspace = true
 tree-sitter-rust.workspace = true
 workspace = { workspace = true, features = ["test-support"] }
+image.workspace = true
 
 [package.metadata.bundle-dev]
 icon = ["resources/app-icon-dev@2x.png", "resources/app-icon-dev.png"]

crates/zed/src/zed.rs 🔗

@@ -6,6 +6,8 @@ pub(crate) mod mac_only_instance;
 mod migrate;
 mod open_listener;
 mod quick_action_bar;
+#[cfg(all(target_os = "macos", any(test, feature = "test-support")))]
+pub mod visual_tests;
 #[cfg(target_os = "windows")]
 pub(crate) mod windows_only_instance;
 

crates/zed/src/zed/visual_tests.rs 🔗

@@ -0,0 +1,361 @@
+#![allow(dead_code)]
+
+use anyhow::{Result, anyhow};
+use gpui::{AppContext as _, Empty, Size, VisualTestAppContext, WindowHandle, px, size};
+use image::{ImageBuffer, Rgba, RgbaImage};
+use std::path::Path;
+use std::sync::Arc;
+use workspace::AppState;
+
+/// Initialize a visual test context with all necessary Zed subsystems.
+pub fn init_visual_test(cx: &mut VisualTestAppContext) -> Arc<AppState> {
+    cx.update(|cx| {
+        env_logger::builder().is_test(true).try_init().ok();
+
+        let app_state = AppState::test(cx);
+
+        gpui_tokio::init(cx);
+        theme::init(theme::LoadThemes::JustBase, cx);
+        audio::init(cx);
+        workspace::init(app_state.clone(), cx);
+        release_channel::init(semver::Version::new(0, 0, 0), cx);
+        command_palette::init(cx);
+        editor::init(cx);
+        project_panel::init(cx);
+        outline_panel::init(cx);
+        terminal_view::init(cx);
+        image_viewer::init(cx);
+        search::init(cx);
+
+        app_state
+    })
+}
+
+/// Open a test workspace with the given app state.
+pub async fn open_test_workspace(
+    app_state: Arc<AppState>,
+    cx: &mut VisualTestAppContext,
+) -> Result<WindowHandle<workspace::Workspace>> {
+    let window_size = size(px(1280.0), px(800.0));
+
+    let project = cx.update(|cx| {
+        project::Project::local(
+            app_state.client.clone(),
+            app_state.node_runtime.clone(),
+            app_state.user_store.clone(),
+            app_state.languages.clone(),
+            app_state.fs.clone(),
+            None,
+            false,
+            cx,
+        )
+    });
+
+    let window = cx.open_offscreen_window(window_size, |window, cx| {
+        cx.new(|cx| workspace::Workspace::new(None, project.clone(), app_state.clone(), window, cx))
+    })?;
+
+    cx.run_until_parked();
+
+    Ok(window)
+}
+
+/// Returns the default window size for visual tests (1280x800).
+pub fn default_window_size() -> Size<gpui::Pixels> {
+    size(px(1280.0), px(800.0))
+}
+
+/// Result of comparing two screenshots.
+#[derive(Debug)]
+pub struct ScreenshotComparison {
+    /// Percentage of pixels that match (0.0 to 1.0)
+    pub match_percentage: f64,
+    /// Optional diff image highlighting differences (red = different, green = same)
+    pub diff_image: Option<RgbaImage>,
+    /// Number of pixels that differ
+    pub diff_pixel_count: u64,
+    /// Total number of pixels compared
+    pub total_pixels: u64,
+}
+
+impl ScreenshotComparison {
+    /// Returns true if the images match within the given tolerance.
+    pub fn matches(&self, tolerance: f64) -> bool {
+        self.match_percentage >= (1.0 - tolerance)
+    }
+}
+
+/// Compare two screenshots with tolerance for minor differences (e.g., anti-aliasing).
+///
+/// # Arguments
+/// * `actual` - The screenshot to test
+/// * `expected` - The baseline screenshot to compare against
+/// * `per_pixel_threshold` - Maximum color difference per channel (0-255) to consider pixels equal
+///
+/// # Returns
+/// A `ScreenshotComparison` containing match statistics and an optional diff image.
+pub fn compare_screenshots(
+    actual: &RgbaImage,
+    expected: &RgbaImage,
+    per_pixel_threshold: u8,
+) -> ScreenshotComparison {
+    let (width, height) = actual.dimensions();
+    let (exp_width, exp_height) = expected.dimensions();
+
+    if width != exp_width || height != exp_height {
+        return ScreenshotComparison {
+            match_percentage: 0.0,
+            diff_image: None,
+            diff_pixel_count: (width * height).max(exp_width * exp_height) as u64,
+            total_pixels: (width * height).max(exp_width * exp_height) as u64,
+        };
+    }
+
+    let total_pixels = (width * height) as u64;
+    let mut diff_pixel_count = 0u64;
+    let mut diff_image: RgbaImage = ImageBuffer::new(width, height);
+
+    for y in 0..height {
+        for x in 0..width {
+            let actual_pixel = actual.get_pixel(x, y);
+            let expected_pixel = expected.get_pixel(x, y);
+
+            let pixels_match =
+                pixels_are_similar(actual_pixel, expected_pixel, per_pixel_threshold);
+
+            if pixels_match {
+                diff_image.put_pixel(x, y, Rgba([0, 128, 0, 255]));
+            } else {
+                diff_pixel_count += 1;
+                diff_image.put_pixel(x, y, Rgba([255, 0, 0, 255]));
+            }
+        }
+    }
+
+    let matching_pixels = total_pixels - diff_pixel_count;
+    let match_percentage = if total_pixels > 0 {
+        matching_pixels as f64 / total_pixels as f64
+    } else {
+        1.0
+    };
+
+    ScreenshotComparison {
+        match_percentage,
+        diff_image: Some(diff_image),
+        diff_pixel_count,
+        total_pixels,
+    }
+}
+
+/// Check if two pixels are similar within a threshold.
+fn pixels_are_similar(a: &Rgba<u8>, b: &Rgba<u8>, threshold: u8) -> bool {
+    let threshold = threshold as i16;
+
+    let diff_r = (a[0] as i16 - b[0] as i16).abs();
+    let diff_g = (a[1] as i16 - b[1] as i16).abs();
+    let diff_b = (a[2] as i16 - b[2] as i16).abs();
+    let diff_a = (a[3] as i16 - b[3] as i16).abs();
+
+    diff_r <= threshold && diff_g <= threshold && diff_b <= threshold && diff_a <= threshold
+}
+
+/// Assert that a screenshot matches a baseline image within tolerance.
+///
+/// # Arguments
+/// * `actual` - The screenshot to test
+/// * `baseline_path` - Path to the baseline image file
+/// * `tolerance` - Percentage of pixels that can differ (0.0 to 1.0)
+/// * `per_pixel_threshold` - Maximum color difference per channel (0-255) to consider pixels equal
+///
+/// # Returns
+/// Ok(()) if the images match, Err with details if they don't.
+pub fn assert_screenshot_matches(
+    actual: &RgbaImage,
+    baseline_path: &Path,
+    tolerance: f64,
+    per_pixel_threshold: u8,
+) -> Result<()> {
+    if !baseline_path.exists() {
+        return Err(anyhow!(
+            "Baseline image not found at: {}. Run with UPDATE_BASELINES=1 to create it.",
+            baseline_path.display()
+        ));
+    }
+
+    let expected = image::open(baseline_path)
+        .map_err(|e| anyhow!("Failed to open baseline image: {}", e))?
+        .to_rgba8();
+
+    let comparison = compare_screenshots(actual, &expected, per_pixel_threshold);
+
+    if comparison.matches(tolerance) {
+        Ok(())
+    } else {
+        let diff_path = baseline_path.with_extension("diff.png");
+        if let Some(diff_image) = &comparison.diff_image {
+            diff_image.save(&diff_path).ok();
+        }
+
+        let actual_path = baseline_path.with_extension("actual.png");
+        actual.save(&actual_path).ok();
+
+        Err(anyhow!(
+            "Screenshot does not match baseline.\n\
+             Match: {:.2}% (required: {:.2}%)\n\
+             Differing pixels: {} / {}\n\
+             Baseline: {}\n\
+             Actual saved to: {}\n\
+             Diff saved to: {}",
+            comparison.match_percentage * 100.0,
+            (1.0 - tolerance) * 100.0,
+            comparison.diff_pixel_count,
+            comparison.total_pixels,
+            baseline_path.display(),
+            actual_path.display(),
+            diff_path.display()
+        ))
+    }
+}
+
+/// Save an image as the new baseline, creating parent directories if needed.
+pub fn save_baseline(image: &RgbaImage, baseline_path: &Path) -> Result<()> {
+    if let Some(parent) = baseline_path.parent() {
+        std::fs::create_dir_all(parent)
+            .map_err(|e| anyhow!("Failed to create baseline directory: {}", e))?;
+    }
+
+    image
+        .save(baseline_path)
+        .map_err(|e| anyhow!("Failed to save baseline image: {}", e))?;
+
+    Ok(())
+}
+
+/// Load an image from a file path.
+pub fn load_image(path: &Path) -> Result<RgbaImage> {
+    image::open(path)
+        .map_err(|e| anyhow!("Failed to load image from {}: {}", path.display(), e))
+        .map(|img| img.to_rgba8())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn create_test_image(width: u32, height: u32, color: Rgba<u8>) -> RgbaImage {
+        let mut img = ImageBuffer::new(width, height);
+        for pixel in img.pixels_mut() {
+            *pixel = color;
+        }
+        img
+    }
+
+    #[test]
+    fn test_identical_images_match() {
+        let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
+        let img2 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
+
+        let comparison = compare_screenshots(&img1, &img2, 0);
+
+        assert_eq!(comparison.match_percentage, 1.0);
+        assert_eq!(comparison.diff_pixel_count, 0);
+        assert!(comparison.matches(0.0));
+    }
+
+    #[test]
+    fn test_different_images_dont_match() {
+        let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
+        let img2 = create_test_image(100, 100, Rgba([0, 255, 0, 255]));
+
+        let comparison = compare_screenshots(&img1, &img2, 0);
+
+        assert_eq!(comparison.match_percentage, 0.0);
+        assert_eq!(comparison.diff_pixel_count, 10000);
+        assert!(!comparison.matches(0.5));
+    }
+
+    #[test]
+    fn test_similar_images_match_with_threshold() {
+        let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
+        let img2 = create_test_image(100, 100, Rgba([250, 5, 0, 255]));
+
+        let comparison_strict = compare_screenshots(&img1, &img2, 0);
+        assert_eq!(comparison_strict.match_percentage, 0.0);
+
+        let comparison_lenient = compare_screenshots(&img1, &img2, 10);
+        assert_eq!(comparison_lenient.match_percentage, 1.0);
+    }
+
+    #[test]
+    fn test_different_size_images() {
+        let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
+        let img2 = create_test_image(200, 200, Rgba([255, 0, 0, 255]));
+
+        let comparison = compare_screenshots(&img1, &img2, 0);
+
+        assert_eq!(comparison.match_percentage, 0.0);
+        assert!(comparison.diff_image.is_none());
+    }
+
+    #[test]
+    fn test_partial_difference() {
+        let mut img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
+        let img2 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
+
+        for x in 0..50 {
+            for y in 0..100 {
+                img1.put_pixel(x, y, Rgba([0, 255, 0, 255]));
+            }
+        }
+
+        let comparison = compare_screenshots(&img1, &img2, 0);
+
+        assert_eq!(comparison.match_percentage, 0.5);
+        assert_eq!(comparison.diff_pixel_count, 5000);
+        assert!(comparison.matches(0.5));
+        assert!(!comparison.matches(0.49));
+    }
+
+    #[test]
+    #[ignore]
+    fn test_visual_test_smoke() {
+        let mut cx = VisualTestAppContext::new();
+
+        let _window = cx
+            .open_offscreen_window_default(|_, cx| cx.new(|_| Empty))
+            .expect("Failed to open offscreen window");
+
+        cx.run_until_parked();
+    }
+
+    #[test]
+    #[ignore]
+    fn test_workspace_opens() {
+        let mut cx = VisualTestAppContext::new();
+        let app_state = init_visual_test(&mut cx);
+
+        smol::block_on(async {
+            app_state
+                .fs
+                .as_fake()
+                .insert_tree(
+                    "/project",
+                    serde_json::json!({
+                        "src": {
+                            "main.rs": "fn main() {\n    println!(\"Hello, world!\");\n}\n"
+                        }
+                    }),
+                )
+                .await;
+        });
+
+        let workspace_result = smol::block_on(open_test_workspace(app_state, &mut cx));
+        assert!(
+            workspace_result.is_ok(),
+            "Failed to open workspace: {:?}",
+            workspace_result.err()
+        );
+
+        cx.run_until_parked();
+    }
+}