Screenshot testing (#45259)

Richard Feldman and Amp created

## Screenshot testing

Adds visual testing infrastructure for GPUI that captures screenshots by
rendering directly to Metal textures. The

The screenshots end up in `target/visual_tests/` and look like this:

<img width="2560" height="1600" alt="workspace_with_editor2"
src="https://github.com/user-attachments/assets/54112343-4af1-4347-9bab-f099de97dd29"
/>
<img width="2560" height="1600" alt="project_panel2"
src="https://github.com/user-attachments/assets/0cd54b61-dace-4398-a28e-0b4d7c2968f6"
/>


### Key Features

- **Direct texture capture**: Screenshots are captured by rendering the
scene to a Metal texture and reading pixels directly from GPU memory,
rather than using ScreenCaptureKit
- **No visibility requirements**: Windows don't need to be visible on
screen since we read directly from the render pipeline
- **Deterministic output**: Captures exactly what GPUI renders, not what
the OS compositor displays
- **No permissions needed**: Doesn't require Screen Recording permission
like ScreenCaptureKit would

### Running the Visual Tests

```bash
# Run visual tests (compares against baselines)
cargo run -p zed --bin visual_test_runner --features visual-tests

# Update baseline images (when UI intentionally changes)
UPDATE_BASELINE=1 cargo run -p zed --bin visual_test_runner --features visual-tests

# View the captured screenshots
open target/visual_tests/
```

### Implementation

- `Window::render_to_image()` - Renders the current scene to a texture
and returns an `RgbaImage`
- `MetalRenderer::render_to_image()` - Core implementation that renders
to a non-framebuffer-only texture
- `VisualTestAppContext` - Test context that uses real macOS platform
rendering
- `VisualTestAppContext::capture_screenshot()` - Synchronous screenshot
capture using direct texture rendering

### Usage

```rust
let mut cx = VisualTestAppContext::new();
let window = cx.open_window(...)?;
let screenshot: RgbaImage = cx.capture_screenshot(window.into())?;
```

Release Notes:

- N/A

---------

Co-authored-by: Amp <amp@ampcode.com>

Change summary

.gitignore                                       |   5 
CONTRIBUTING.md                                  |   2 
Cargo.lock                                       |   3 
crates/gpui/src/app.rs                           |   4 
crates/gpui/src/app/visual_test_context.rs       | 453 +++++++++++
crates/gpui/src/executor.rs                      |  35 
crates/gpui/src/platform.rs                      |  10 
crates/gpui/src/platform/blade/blade_renderer.rs |  12 
crates/gpui/src/platform/mac/metal_renderer.rs   |  96 ++
crates/gpui/src/platform/mac/window.rs           |  10 
crates/gpui/src/window.rs                        |   9 
crates/zed/Cargo.toml                            |  38 
crates/zed/src/visual_test_runner.rs             | 695 ++++++++++++++++++
crates/zed/src/zed.rs                            |   2 
crates/zed/src/zed/visual_tests.rs               | 539 +++++++++++++
docs/src/development/macos.md                    |  29 
docs/src/development/windows.md                  |   2 
17 files changed, 1,940 insertions(+), 4 deletions(-)

Detailed changes

.gitignore 🔗

@@ -44,3 +44,8 @@ crates/docs_preprocessor/actions.json
 
 # `nix build` output
 /result
+
+# Visual test baseline images (these will be stored outside
+# the repo in the future, but we don't haven't decided exactly
+# where yet, so for now they get generated into a gitignored dir.)
+/crates/zed/test_fixtures/visual_tests/

CONTRIBUTING.md 🔗

@@ -43,7 +43,7 @@ submitted. If you'd like your PR to have the best chance of being merged:
   effort. If there isn't already a GitHub issue for your feature with staff
   confirmation that we want it, start with a GitHub discussion rather than a PR.
 - Include a clear description of **what you're solving**, and why it's important.
-- Include **tests**.
+- Include **tests**. For UI changes, consider updating visual regression tests (see [Building Zed for macOS](./docs/src/development/macos.md#visual-regression-tests)).
 - If it changes the UI, attach **screenshots** or screen recordings.
 - Make the PR about **one thing only**, e.g. if it's a bugfix, don't add two
   features and a refactoring on top of that.

Cargo.lock 🔗

@@ -20683,6 +20683,7 @@ dependencies = [
  "clap",
  "cli",
  "client",
+ "clock",
  "codestral",
  "collab_ui",
  "collections",
@@ -20717,6 +20718,7 @@ dependencies = [
  "gpui",
  "gpui_tokio",
  "http_client",
+ "image",
  "image_viewer",
  "inspector_ui",
  "install_cli",
@@ -20783,6 +20785,7 @@ dependencies = [
  "task",
  "tasks_ui",
  "telemetry",
+ "tempfile",
  "terminal_view",
  "theme",
  "theme_extension",

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,453 @@
+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 image::RgbaImage;
+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;
+        }
+    }
+
+    /// Captures a screenshot of the specified window using direct texture capture.
+    ///
+    /// This renders the scene to a Metal texture and reads the pixels directly,
+    /// which does not require the window to be visible on screen.
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn capture_screenshot(&mut self, window: AnyWindowHandle) -> Result<RgbaImage> {
+        self.update_window(window, |_, window, _cx| window.render_to_image())?
+    }
+
+    /// Waits for animations to complete by waiting a couple of frames.
+    pub async fn wait_for_animations(&self) {
+        self.background_executor
+            .timer(Duration::from_millis(32))
+            .await;
+        self.run_until_parked();
+    }
+}
+
+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/gpui/src/executor.rs 🔗

@@ -497,6 +497,7 @@ impl BackgroundExecutor {
         timeout: Option<Duration>,
     ) -> Result<Fut::Output, impl Future<Output = Fut::Output> + use<Fut>> {
         use std::sync::atomic::AtomicBool;
+        use std::time::Instant;
 
         use parking::Parker;
 
@@ -504,8 +505,40 @@ impl BackgroundExecutor {
         if timeout == Some(Duration::ZERO) {
             return Err(future);
         }
+
+        // When using a real platform (e.g., MacPlatform for visual tests that need actual
+        // Metal rendering), there's no test dispatcher. In this case, we block the thread
+        // directly by polling the future and parking until woken. This is required for
+        // VisualTestAppContext which uses real platform rendering but still needs blocking
+        // behavior for code paths like editor initialization that call block_with_timeout.
         let Some(dispatcher) = self.dispatcher.as_test() else {
-            return Err(future);
+            let deadline = timeout.map(|timeout| Instant::now() + timeout);
+
+            let parker = Parker::new();
+            let unparker = parker.unparker();
+            let waker = waker_fn(move || {
+                unparker.unpark();
+            });
+            let mut cx = std::task::Context::from_waker(&waker);
+
+            loop {
+                match future.as_mut().poll(&mut cx) {
+                    Poll::Ready(result) => return Ok(result),
+                    Poll::Pending => {
+                        let timeout = deadline
+                            .map(|deadline| deadline.saturating_duration_since(Instant::now()));
+                        if let Some(timeout) = timeout {
+                            if !parker.park_timeout(timeout)
+                                && deadline.is_some_and(|deadline| deadline < Instant::now())
+                            {
+                                return Err(future);
+                            }
+                        } else {
+                            parker.park();
+                        }
+                    }
+                }
+            }
         };
 
         let mut max_ticks = if timeout.is_some() {

crates/gpui/src/platform.rs 🔗

@@ -47,6 +47,8 @@ use crate::{
 use anyhow::Result;
 use async_task::Runnable;
 use futures::channel::oneshot;
+#[cfg(any(test, feature = "test-support"))]
+use image::RgbaImage;
 use image::codecs::gif::GifDecoder;
 use image::{AnimationDecoder as _, Frame};
 use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
@@ -570,6 +572,14 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
     fn as_test(&mut self) -> Option<&mut TestWindow> {
         None
     }
+
+    /// Renders the given scene to a texture and returns the pixel data as an RGBA image.
+    /// This does not present the frame to screen - useful for visual testing where we want
+    /// to capture what would be rendered without displaying it or requiring the window to be visible.
+    #[cfg(any(test, feature = "test-support"))]
+    fn render_to_image(&self, _scene: &Scene) -> Result<RgbaImage> {
+        anyhow::bail!("render_to_image not implemented for this platform")
+    }
 }
 
 /// This type is public so that our test macro can generate and use it, but it should not

crates/gpui/src/platform/blade/blade_renderer.rs 🔗

@@ -7,9 +7,13 @@ use crate::{
     PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, Underline,
     get_gamma_correction_ratios,
 };
+#[cfg(any(test, feature = "test-support"))]
+use anyhow::Result;
 use blade_graphics as gpu;
 use blade_util::{BufferBelt, BufferBeltDescriptor};
 use bytemuck::{Pod, Zeroable};
+#[cfg(any(test, feature = "test-support"))]
+use image::RgbaImage;
 #[cfg(target_os = "macos")]
 use media::core_video::CVMetalTextureCache;
 use std::sync::Arc;
@@ -917,6 +921,14 @@ impl BladeRenderer {
         self.wait_for_gpu();
         self.last_sync_point = Some(sync_point);
     }
+
+    /// Renders the scene to a texture and returns the pixel data as an RGBA image.
+    /// This is not yet implemented for BladeRenderer.
+    #[cfg(any(test, feature = "test-support"))]
+    #[allow(dead_code)]
+    pub fn render_to_image(&mut self, _scene: &Scene) -> Result<RgbaImage> {
+        anyhow::bail!("render_to_image is not yet implemented for BladeRenderer")
+    }
 }
 
 fn create_path_intermediate_texture(

crates/gpui/src/platform/mac/metal_renderer.rs 🔗

@@ -11,6 +11,8 @@ use cocoa::{
     foundation::{NSSize, NSUInteger},
     quartzcore::AutoresizingMask,
 };
+#[cfg(any(test, feature = "test-support"))]
+use image::RgbaImage;
 
 use core_foundation::base::TCFType;
 use core_video::{
@@ -156,6 +158,9 @@ impl MetalRenderer {
         // https://developer.apple.com/documentation/metal/managing-your-game-window-for-metal-in-macos
         layer.set_opaque(!transparent);
         layer.set_maximum_drawable_count(3);
+        // Allow texture reading for visual tests (captures screenshots without ScreenCaptureKit)
+        #[cfg(any(test, feature = "test-support"))]
+        layer.set_framebuffer_only(false);
         unsafe {
             let _: () = msg_send![&*layer, setAllowsNextDrawableTimeout: NO];
             let _: () = msg_send![&*layer, setNeedsDisplayOnBoundsChange: YES];
@@ -428,6 +433,97 @@ impl MetalRenderer {
         }
     }
 
+    /// Renders the scene to a texture and returns the pixel data as an RGBA image.
+    /// This does not present the frame to screen - useful for visual testing
+    /// where we want to capture what would be rendered without displaying it.
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn render_to_image(&mut self, scene: &Scene) -> Result<RgbaImage> {
+        let layer = self.layer.clone();
+        let viewport_size = layer.drawable_size();
+        let viewport_size: Size<DevicePixels> = size(
+            (viewport_size.width.ceil() as i32).into(),
+            (viewport_size.height.ceil() as i32).into(),
+        );
+        let drawable = layer
+            .next_drawable()
+            .ok_or_else(|| anyhow::anyhow!("Failed to get drawable for render_to_image"))?;
+
+        loop {
+            let mut instance_buffer = self.instance_buffer_pool.lock().acquire(&self.device);
+
+            let command_buffer =
+                self.draw_primitives(scene, &mut instance_buffer, drawable, viewport_size);
+
+            match command_buffer {
+                Ok(command_buffer) => {
+                    let instance_buffer_pool = self.instance_buffer_pool.clone();
+                    let instance_buffer = Cell::new(Some(instance_buffer));
+                    let block = ConcreteBlock::new(move |_| {
+                        if let Some(instance_buffer) = instance_buffer.take() {
+                            instance_buffer_pool.lock().release(instance_buffer);
+                        }
+                    });
+                    let block = block.copy();
+                    command_buffer.add_completed_handler(&block);
+
+                    // Commit and wait for completion without presenting
+                    command_buffer.commit();
+                    command_buffer.wait_until_completed();
+
+                    // Read pixels from the texture
+                    let texture = drawable.texture();
+                    let width = texture.width() as u32;
+                    let height = texture.height() as u32;
+                    let bytes_per_row = width as usize * 4;
+                    let buffer_size = height as usize * bytes_per_row;
+
+                    let mut pixels = vec![0u8; buffer_size];
+
+                    let region = metal::MTLRegion {
+                        origin: metal::MTLOrigin { x: 0, y: 0, z: 0 },
+                        size: metal::MTLSize {
+                            width: width as u64,
+                            height: height as u64,
+                            depth: 1,
+                        },
+                    };
+
+                    texture.get_bytes(
+                        pixels.as_mut_ptr() as *mut std::ffi::c_void,
+                        bytes_per_row as u64,
+                        region,
+                        0,
+                    );
+
+                    // Convert BGRA to RGBA (swap B and R channels)
+                    for chunk in pixels.chunks_exact_mut(4) {
+                        chunk.swap(0, 2);
+                    }
+
+                    return RgbaImage::from_raw(width, height, pixels).ok_or_else(|| {
+                        anyhow::anyhow!("Failed to create RgbaImage from pixel data")
+                    });
+                }
+                Err(err) => {
+                    log::error!(
+                        "failed to render: {}. retrying with larger instance buffer size",
+                        err
+                    );
+                    let mut instance_buffer_pool = self.instance_buffer_pool.lock();
+                    let buffer_size = instance_buffer_pool.buffer_size;
+                    if buffer_size >= 256 * 1024 * 1024 {
+                        anyhow::bail!("instance buffer size grew too large: {}", buffer_size);
+                    }
+                    instance_buffer_pool.reset(buffer_size * 2);
+                    log::info!(
+                        "increased instance buffer size to {}",
+                        instance_buffer_pool.buffer_size
+                    );
+                }
+            }
+        }
+    }
+
     fn draw_primitives(
         &mut self,
         scene: &Scene,

crates/gpui/src/platform/mac/window.rs 🔗

@@ -8,6 +8,8 @@ use crate::{
     WindowBounds, WindowControlArea, WindowKind, WindowParams, dispatch_get_main_queue,
     dispatch_sys::dispatch_async_f, platform::PlatformInputHandler, point, px, size,
 };
+#[cfg(any(test, feature = "test-support"))]
+use anyhow::Result;
 use block::ConcreteBlock;
 use cocoa::{
     appkit::{
@@ -25,6 +27,8 @@ use cocoa::{
         NSUserDefaults,
     },
 };
+#[cfg(any(test, feature = "test-support"))]
+use image::RgbaImage;
 
 use core_graphics::display::{CGDirectDisplayID, CGPoint, CGRect};
 use ctor::ctor;
@@ -1598,6 +1602,12 @@ impl PlatformWindow for MacWindow {
             let _: () = msg_send![window, performWindowDragWithEvent: event];
         }
     }
+
+    #[cfg(any(test, feature = "test-support"))]
+    fn render_to_image(&self, scene: &crate::Scene) -> Result<RgbaImage> {
+        let mut this = self.0.lock();
+        this.renderer.render_to_image(scene)
+    }
 }
 
 impl rwh::HasWindowHandle for MacWindow {

crates/gpui/src/window.rs 🔗

@@ -1822,6 +1822,15 @@ impl Window {
         self.platform_window.bounds()
     }
 
+    /// Renders the current frame's scene to a texture and returns the pixel data as an RGBA image.
+    /// This does not present the frame to screen - useful for visual testing where we want
+    /// to capture what would be rendered without displaying it or requiring the window to be visible.
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn render_to_image(&self) -> anyhow::Result<image::RgbaImage> {
+        self.platform_window
+            .render_to_image(&self.rendered_frame.scene)
+    }
+
     /// Set the content size of the window.
     pub fn resize(&mut self, size: Size<Pixels>) {
         self.platform_window.resize(size);

crates/zed/Cargo.toml 🔗

@@ -6,17 +6,47 @@ version = "0.219.0"
 publish.workspace = true
 license = "GPL-3.0-or-later"
 authors = ["Zed Team <hi@zed.dev>"]
+default-run = "zed"
 
 [lints]
 workspace = true
 
 [features]
 tracy = ["ztracing/tracy"]
+test-support = [
+    "gpui/test-support",
+    "gpui/screen-capture",
+    "dep:image",
+    "dep:semver",
+    "workspace/test-support",
+    "project/test-support",
+    "editor/test-support",
+    "terminal_view/test-support",
+    "image_viewer/test-support",
+]
+visual-tests = [
+    "gpui/test-support",
+    "gpui/screen-capture",
+    "dep:image",
+    "dep:semver",
+    "dep:tempfile",
+    "workspace/test-support",
+    "project/test-support",
+    "editor/test-support",
+    "terminal_view/test-support",
+    "image_viewer/test-support",
+    "clock/test-support",
+]
 
 [[bin]]
 name = "zed"
 path = "src/main.rs"
 
+[[bin]]
+name = "visual_test_runner"
+path = "src/visual_test_runner.rs"
+required-features = ["visual-tests"]
+
 [dependencies]
 acp_tools.workspace = true
 activity_indicator.workspace = true
@@ -71,6 +101,10 @@ gpui = { workspace = true, features = [
     "font-kit",
     "windows-manifest",
 ] }
+image = { workspace = true, optional = true }
+semver = { workspace = true, optional = true }
+tempfile = { workspace = true, optional = true }
+clock = { workspace = true, optional = true }
 gpui_tokio.workspace = true
 rayon.workspace = true
 
@@ -181,7 +215,7 @@ ashpd.workspace = true
 call = { workspace = true, features = ["test-support"] }
 dap = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
-gpui = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support", "screen-capture"] }
 image_viewer = { workspace = true, features = ["test-support"] }
 itertools.workspace = true
 language = { workspace = true, features = ["test-support"] }
@@ -192,11 +226,11 @@ 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
 agent_ui = { workspace = true, features = ["test-support"] }
 agent_ui_v2 = { workspace = true, features = ["test-support"] }
 search = { workspace = true, features = ["test-support"] }
 
-
 [package.metadata.bundle-dev]
 icon = ["resources/app-icon-dev@2x.png", "resources/app-icon-dev.png"]
 identifier = "dev.zed.Zed-Dev"

crates/zed/src/visual_test_runner.rs 🔗

@@ -0,0 +1,695 @@
+//! Visual Test Runner
+//!
+//! This binary runs visual regression tests for Zed's UI. It captures screenshots
+//! of real Zed windows and compares them against baseline images.
+//!
+//! ## How It Works
+//!
+//! This tool uses direct texture capture - it renders the scene to a Metal texture
+//! and reads the pixels back directly. This approach:
+//! - Does NOT require Screen Recording permission
+//! - Does NOT require the window to be visible on screen
+//! - Captures raw GPUI output without system window chrome
+//!
+//! ## Usage
+//!
+//! Run the visual tests:
+//!   cargo run -p zed --bin visual_test_runner --features visual-tests
+//!
+//! Update baseline images (when UI intentionally changes):
+//!   UPDATE_BASELINE=1 cargo run -p zed --bin visual_test_runner --features visual-tests
+//!
+//! ## Environment Variables
+//!
+//!   UPDATE_BASELINE - Set to update baseline images instead of comparing
+//!   VISUAL_TEST_OUTPUT_DIR - Directory to save test output (default: target/visual_tests)
+
+use anyhow::{Context, Result};
+use gpui::{
+    AppContext as _, Application, Bounds, Window, WindowBounds, WindowHandle, WindowOptions, point,
+    px, size,
+};
+use image::RgbaImage;
+use project_panel::ProjectPanel;
+use settings::SettingsStore;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use workspace::{AppState, Workspace};
+
+/// Baseline images are stored relative to this file
+const BASELINE_DIR: &str = "crates/zed/test_fixtures/visual_tests";
+
+/// Threshold for image comparison (0.0 to 1.0)
+/// Images must match at least this percentage to pass
+const MATCH_THRESHOLD: f64 = 0.99;
+
+fn main() {
+    env_logger::builder()
+        .filter_level(log::LevelFilter::Info)
+        .init();
+
+    let update_baseline = std::env::var("UPDATE_BASELINE").is_ok();
+
+    if update_baseline {
+        println!("=== Visual Test Runner (UPDATE MODE) ===\n");
+        println!("Baseline images will be updated.\n");
+    } else {
+        println!("=== Visual Test Runner ===\n");
+    }
+
+    // Create a temporary directory for test files
+    let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
+    let project_path = temp_dir.path().join("project");
+    std::fs::create_dir_all(&project_path).expect("Failed to create project directory");
+
+    // Create test files in the real filesystem
+    create_test_files(&project_path);
+
+    let test_result = std::panic::catch_unwind(|| {
+        let project_path = project_path;
+        Application::new().run(move |cx| {
+            // Initialize settings store first (required by theme and other subsystems)
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+
+            // Create AppState using the production-like initialization
+            let app_state = init_app_state(cx);
+
+            // Initialize all Zed subsystems
+            gpui_tokio::init(cx);
+            theme::init(theme::LoadThemes::JustBase, cx);
+            client::init(&app_state.client, 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);
+            call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
+            title_bar::init(cx);
+            project_panel::init(cx);
+            outline_panel::init(cx);
+            terminal_view::init(cx);
+            image_viewer::init(cx);
+            search::init(cx);
+
+            // Open a real Zed workspace window
+            let window_size = size(px(1280.0), px(800.0));
+            // Window can be hidden since we use direct texture capture (reading pixels from
+            // Metal texture) instead of ScreenCaptureKit which requires visible windows.
+            let bounds = Bounds {
+                origin: point(px(0.0), px(0.0)),
+                size: window_size,
+            };
+
+            // Create a project for the workspace
+            let project = 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 workspace_window: WindowHandle<Workspace> = cx
+                .open_window(
+                    WindowOptions {
+                        window_bounds: Some(WindowBounds::Windowed(bounds)),
+                        focus: false,
+                        show: false,
+                        ..Default::default()
+                    },
+                    |window, cx| {
+                        cx.new(|cx| {
+                            Workspace::new(None, project.clone(), app_state.clone(), window, cx)
+                        })
+                    },
+                )
+                .expect("Failed to open workspace window");
+
+            // Add the test project as a worktree directly to the project
+            let add_worktree_task = workspace_window
+                .update(cx, |workspace, _window, cx| {
+                    workspace.project().update(cx, |project, cx| {
+                        project.find_or_create_worktree(&project_path, true, cx)
+                    })
+                })
+                .expect("Failed to update workspace");
+
+            // Spawn async task to set up the UI and capture screenshot
+            cx.spawn(async move |mut cx| {
+                // Wait for the worktree to be added
+                if let Err(e) = add_worktree_task.await {
+                    eprintln!("Failed to add worktree: {:?}", e);
+                }
+
+                // Wait for UI to settle
+                cx.background_executor()
+                    .timer(std::time::Duration::from_millis(500))
+                    .await;
+
+                // Create and add the project panel to the workspace
+                let panel_task = cx.update(|cx| {
+                    workspace_window
+                        .update(cx, |_workspace, window, cx| {
+                            let weak_workspace = cx.weak_entity();
+                            window.spawn(cx, async move |cx| {
+                                ProjectPanel::load(weak_workspace, cx.clone()).await
+                            })
+                        })
+                        .ok()
+                });
+
+                if let Ok(Some(task)) = panel_task {
+                    if let Ok(panel) = task.await {
+                        cx.update(|cx| {
+                            workspace_window
+                                .update(cx, |workspace, window, cx| {
+                                    workspace.add_panel(panel, window, cx);
+                                })
+                                .ok();
+                        })
+                        .ok();
+                    }
+                }
+
+                // Wait for panel to be added
+                cx.background_executor()
+                    .timer(std::time::Duration::from_millis(500))
+                    .await;
+
+                // Open the project panel
+                cx.update(|cx| {
+                    workspace_window
+                        .update(cx, |workspace, window, cx| {
+                            workspace.open_panel::<ProjectPanel>(window, cx);
+                        })
+                        .ok();
+                })
+                .ok();
+
+                // Wait for project panel to render
+                cx.background_executor()
+                    .timer(std::time::Duration::from_millis(500))
+                    .await;
+
+                // Open main.rs in the editor
+                let open_file_task = cx.update(|cx| {
+                    workspace_window
+                        .update(cx, |workspace, window, cx| {
+                            let worktree = workspace.project().read(cx).worktrees(cx).next();
+                            if let Some(worktree) = worktree {
+                                let worktree_id = worktree.read(cx).id();
+                                let rel_path: std::sync::Arc<util::rel_path::RelPath> =
+                                    util::rel_path::rel_path("src/main.rs").into();
+                                let project_path: project::ProjectPath =
+                                    (worktree_id, rel_path).into();
+                                Some(workspace.open_path(project_path, None, true, window, cx))
+                            } else {
+                                None
+                            }
+                        })
+                        .ok()
+                        .flatten()
+                });
+
+                if let Ok(Some(task)) = open_file_task {
+                    if let Ok(item) = task.await {
+                        // Focus the opened item to dismiss the welcome screen
+                        cx.update(|cx| {
+                            workspace_window
+                                .update(cx, |workspace, window, cx| {
+                                    let pane = workspace.active_pane().clone();
+                                    pane.update(cx, |pane, cx| {
+                                        if let Some(index) = pane.index_for_item(item.as_ref()) {
+                                            pane.activate_item(index, true, true, window, cx);
+                                        }
+                                    });
+                                })
+                                .ok();
+                        })
+                        .ok();
+
+                        // Wait for item activation to render
+                        cx.background_executor()
+                            .timer(std::time::Duration::from_millis(500))
+                            .await;
+                    }
+                }
+
+                // Request a window refresh to ensure all pending effects are processed
+                cx.refresh().ok();
+
+                // Wait for UI to fully stabilize
+                cx.background_executor()
+                    .timer(std::time::Duration::from_secs(2))
+                    .await;
+
+                // Track test results
+                let mut passed = 0;
+                let mut failed = 0;
+                let mut updated = 0;
+
+                // Run Test 1: Project Panel (with project panel visible)
+                println!("\n--- Test 1: project_panel ---");
+                let test_result = run_visual_test(
+                    "project_panel",
+                    workspace_window.into(),
+                    &mut cx,
+                    update_baseline,
+                )
+                .await;
+
+                match test_result {
+                    Ok(TestResult::Passed) => {
+                        println!("✓ project_panel: PASSED");
+                        passed += 1;
+                    }
+                    Ok(TestResult::BaselineUpdated(path)) => {
+                        println!("✓ project_panel: Baseline updated at {}", path.display());
+                        updated += 1;
+                    }
+                    Err(e) => {
+                        eprintln!("✗ project_panel: FAILED - {}", e);
+                        failed += 1;
+                    }
+                }
+
+                // Close the project panel for the second test
+                cx.update(|cx| {
+                    workspace_window
+                        .update(cx, |workspace, window, cx| {
+                            workspace.close_panel::<ProjectPanel>(window, cx);
+                        })
+                        .ok();
+                })
+                .ok();
+
+                // Refresh and wait for panel to close
+                cx.refresh().ok();
+                cx.background_executor()
+                    .timer(std::time::Duration::from_millis(500))
+                    .await;
+
+                // Run Test 2: Workspace with Editor (without project panel)
+                println!("\n--- Test 2: workspace_with_editor ---");
+                let test_result = run_visual_test(
+                    "workspace_with_editor",
+                    workspace_window.into(),
+                    &mut cx,
+                    update_baseline,
+                )
+                .await;
+
+                match test_result {
+                    Ok(TestResult::Passed) => {
+                        println!("✓ workspace_with_editor: PASSED");
+                        passed += 1;
+                    }
+                    Ok(TestResult::BaselineUpdated(path)) => {
+                        println!(
+                            "✓ workspace_with_editor: Baseline updated at {}",
+                            path.display()
+                        );
+                        updated += 1;
+                    }
+                    Err(e) => {
+                        eprintln!("✗ workspace_with_editor: FAILED - {}", e);
+                        failed += 1;
+                    }
+                }
+
+                // Print summary
+                println!("\n=== Test Summary ===");
+                println!("Passed: {}", passed);
+                println!("Failed: {}", failed);
+                if updated > 0 {
+                    println!("Baselines Updated: {}", updated);
+                }
+
+                if failed > 0 {
+                    eprintln!("\n=== Visual Tests FAILED ===");
+                    cx.update(|cx| cx.quit()).ok();
+                    std::process::exit(1);
+                } else {
+                    println!("\n=== All Visual Tests PASSED ===");
+                }
+
+                cx.update(|cx| cx.quit()).ok();
+            })
+            .detach();
+        });
+    });
+
+    // Keep temp_dir alive until we're done
+    drop(temp_dir);
+
+    if test_result.is_err() {
+        std::process::exit(1);
+    }
+}
+
+enum TestResult {
+    Passed,
+    BaselineUpdated(PathBuf),
+}
+
+async fn run_visual_test(
+    test_name: &str,
+    window: gpui::AnyWindowHandle,
+    cx: &mut gpui::AsyncApp,
+    update_baseline: bool,
+) -> Result<TestResult> {
+    // Capture the screenshot using direct texture capture (no ScreenCaptureKit needed)
+    let screenshot = cx.update(|cx| capture_screenshot(window, cx))??;
+
+    // Get paths
+    let baseline_path = get_baseline_path(test_name);
+    let output_dir = std::env::var("VISUAL_TEST_OUTPUT_DIR")
+        .unwrap_or_else(|_| "target/visual_tests".to_string());
+    let actual_path = Path::new(&output_dir).join(format!("{}.png", test_name));
+
+    // Create output directory
+    if let Some(parent) = actual_path.parent() {
+        std::fs::create_dir_all(parent)?;
+    }
+
+    // Save the actual screenshot
+    screenshot.save(&actual_path)?;
+    println!("Screenshot saved to: {}", actual_path.display());
+
+    if update_baseline {
+        // Update the baseline
+        if let Some(parent) = baseline_path.parent() {
+            std::fs::create_dir_all(parent)?;
+        }
+        screenshot.save(&baseline_path)?;
+        return Ok(TestResult::BaselineUpdated(baseline_path));
+    }
+
+    // Compare against baseline
+    if !baseline_path.exists() {
+        return Err(anyhow::anyhow!(
+            "Baseline image not found: {}\n\
+             Run with UPDATE_BASELINE=1 to create it.",
+            baseline_path.display()
+        ));
+    }
+
+    let baseline = image::open(&baseline_path)
+        .context("Failed to load baseline image")?
+        .to_rgba8();
+
+    let comparison = compare_images(&baseline, &screenshot);
+
+    println!(
+        "Image comparison: {:.2}% match ({} different pixels out of {})",
+        comparison.match_percentage * 100.0,
+        comparison.diff_pixel_count,
+        comparison.total_pixels
+    );
+
+    if comparison.match_percentage >= MATCH_THRESHOLD {
+        Ok(TestResult::Passed)
+    } else {
+        // Save the diff image for debugging
+        if let Some(diff_image) = comparison.diff_image {
+            let diff_path = Path::new(&output_dir).join(format!("{}_diff.png", test_name));
+            diff_image.save(&diff_path)?;
+            println!("Diff image saved to: {}", diff_path.display());
+        }
+
+        Err(anyhow::anyhow!(
+            "Screenshot does not match baseline.\n\
+             Match: {:.2}% (threshold: {:.2}%)\n\
+             Actual: {}\n\
+             Baseline: {}\n\
+             \n\
+             Run with UPDATE_BASELINE=1 to update the baseline if this change is intentional.",
+            comparison.match_percentage * 100.0,
+            MATCH_THRESHOLD * 100.0,
+            actual_path.display(),
+            baseline_path.display()
+        ))
+    }
+}
+
+fn get_baseline_path(test_name: &str) -> PathBuf {
+    // Find the workspace root by looking for Cargo.toml
+    let mut path = std::env::current_dir().expect("Failed to get current directory");
+    while !path.join("Cargo.toml").exists() || !path.join("crates").exists() {
+        if !path.pop() {
+            panic!("Could not find workspace root");
+        }
+    }
+    path.join(BASELINE_DIR).join(format!("{}.png", test_name))
+}
+
+struct ImageComparison {
+    match_percentage: f64,
+    diff_image: Option<RgbaImage>,
+    diff_pixel_count: u64,
+    total_pixels: u64,
+}
+
+fn compare_images(baseline: &RgbaImage, actual: &RgbaImage) -> ImageComparison {
+    // Check dimensions
+    if baseline.dimensions() != actual.dimensions() {
+        return ImageComparison {
+            match_percentage: 0.0,
+            diff_image: None,
+            diff_pixel_count: baseline.width() as u64 * baseline.height() as u64,
+            total_pixels: baseline.width() as u64 * baseline.height() as u64,
+        };
+    }
+
+    let (width, height) = baseline.dimensions();
+    let total_pixels = width as u64 * height as u64;
+    let mut diff_count: u64 = 0;
+    let mut diff_image = RgbaImage::new(width, height);
+
+    for y in 0..height {
+        for x in 0..width {
+            let baseline_pixel = baseline.get_pixel(x, y);
+            let actual_pixel = actual.get_pixel(x, y);
+
+            if pixels_are_similar(baseline_pixel, actual_pixel) {
+                // Matching pixel - show as dimmed version of actual
+                diff_image.put_pixel(
+                    x,
+                    y,
+                    image::Rgba([
+                        actual_pixel[0] / 3,
+                        actual_pixel[1] / 3,
+                        actual_pixel[2] / 3,
+                        255,
+                    ]),
+                );
+            } else {
+                diff_count += 1;
+                // Different pixel - highlight in red
+                diff_image.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
+            }
+        }
+    }
+
+    let match_percentage = if total_pixels > 0 {
+        (total_pixels - diff_count) as f64 / total_pixels as f64
+    } else {
+        1.0
+    };
+
+    ImageComparison {
+        match_percentage,
+        diff_image: Some(diff_image),
+        diff_pixel_count: diff_count,
+        total_pixels,
+    }
+}
+
+fn pixels_are_similar(a: &image::Rgba<u8>, b: &image::Rgba<u8>) -> bool {
+    // Allow small differences due to anti-aliasing, font rendering, etc.
+    const TOLERANCE: i16 = 2;
+
+    (a[0] as i16 - b[0] as i16).abs() <= TOLERANCE
+        && (a[1] as i16 - b[1] as i16).abs() <= TOLERANCE
+        && (a[2] as i16 - b[2] as i16).abs() <= TOLERANCE
+        && (a[3] as i16 - b[3] as i16).abs() <= TOLERANCE
+}
+
+fn capture_screenshot(window: gpui::AnyWindowHandle, cx: &mut gpui::App) -> Result<RgbaImage> {
+    // Use direct texture capture - renders the scene to a texture and reads pixels back.
+    // This does not require the window to be visible on screen.
+    let screenshot = cx.update_window(window, |_view, window: &mut Window, _cx| {
+        window.render_to_image()
+    })??;
+
+    println!(
+        "Screenshot captured: {}x{} pixels",
+        screenshot.width(),
+        screenshot.height()
+    );
+
+    Ok(screenshot)
+}
+
+/// Create test files in a real filesystem directory
+fn create_test_files(project_path: &Path) {
+    let src_dir = project_path.join("src");
+    std::fs::create_dir_all(&src_dir).expect("Failed to create src directory");
+
+    std::fs::write(
+        src_dir.join("main.rs"),
+        r#"fn main() {
+    println!("Hello, world!");
+
+    let message = greet("Zed");
+    println!("{}", message);
+}
+
+fn greet(name: &str) -> String {
+    format!("Welcome to {}, the editor of the future!", name)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_greet() {
+        assert_eq!(greet("World"), "Welcome to World, the editor of the future!");
+    }
+}
+"#,
+    )
+    .expect("Failed to write main.rs");
+
+    std::fs::write(
+        src_dir.join("lib.rs"),
+        r#"//! A sample library for visual testing.
+
+pub mod utils;
+
+/// Adds two numbers together.
+pub fn add(a: i32, b: i32) -> i32 {
+    a + b
+}
+
+/// Subtracts the second number from the first.
+pub fn subtract(a: i32, b: i32) -> i32 {
+    a - b
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_add() {
+        assert_eq!(add(2, 3), 5);
+    }
+
+    #[test]
+    fn test_subtract() {
+        assert_eq!(subtract(5, 3), 2);
+    }
+}
+"#,
+    )
+    .expect("Failed to write lib.rs");
+
+    std::fs::write(
+        src_dir.join("utils.rs"),
+        r#"//! Utility functions for the sample project.
+
+/// Formats a greeting message.
+pub fn format_greeting(name: &str) -> String {
+    format!("Hello, {}!", name)
+}
+
+/// Formats a farewell message.
+pub fn format_farewell(name: &str) -> String {
+    format!("Goodbye, {}!", name)
+}
+"#,
+    )
+    .expect("Failed to write utils.rs");
+
+    std::fs::write(
+        project_path.join("Cargo.toml"),
+        r#"[package]
+name = "test-project"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+
+[dev-dependencies]
+"#,
+    )
+    .expect("Failed to write Cargo.toml");
+
+    std::fs::write(
+        project_path.join("README.md"),
+        r#"# Test Project
+
+This is a test project for visual testing of Zed.
+
+## Description
+
+A simple Rust project used to verify that Zed's visual testing
+infrastructure can capture screenshots of real workspaces.
+
+## Features
+
+- Sample Rust code with main.rs, lib.rs, and utils.rs
+- Standard Cargo.toml configuration
+- Example tests
+
+## Building
+
+```bash
+cargo build
+```
+
+## Testing
+
+```bash
+cargo test
+```
+"#,
+    )
+    .expect("Failed to write README.md");
+}
+
+/// Initialize AppState with real filesystem for visual testing.
+fn init_app_state(cx: &mut gpui::App) -> Arc<AppState> {
+    use client::Client;
+    use clock::FakeSystemClock;
+    use fs::RealFs;
+    use language::LanguageRegistry;
+    use node_runtime::NodeRuntime;
+    use session::Session;
+
+    let fs = Arc::new(RealFs::new(None, cx.background_executor().clone()));
+    let languages = Arc::new(LanguageRegistry::new(cx.background_executor().clone()));
+    let clock = Arc::new(FakeSystemClock::new());
+    let http_client = http_client::FakeHttpClient::with_404_response();
+    let client = Client::new(clock, http_client, cx);
+    let session = cx.new(|cx| session::AppSession::new(Session::test(), cx));
+    let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx));
+    let workspace_store = cx.new(|cx| workspace::WorkspaceStore::new(client.clone(), cx));
+
+    Arc::new(AppState {
+        client,
+        fs,
+        languages,
+        user_store,
+        workspace_store,
+        node_runtime: NodeRuntime::unavailable(),
+        build_window_options: |_, _| Default::default(),
+        session,
+    })
+}

crates/zed/src/zed.rs 🔗

@@ -5,6 +5,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,539 @@
+#![allow(dead_code, unused_imports)]
+
+//! Visual testing infrastructure for Zed.
+//!
+//! This module provides utilities for visual regression testing of Zed's UI.
+//! It allows capturing screenshots of the real Zed application window and comparing
+//! them against baseline images.
+//!
+//! ## Important: Main Thread Requirement
+//!
+//! On macOS, the `VisualTestAppContext` must be created on the main thread.
+//! Standard Rust tests run on worker threads, so visual tests that use
+//! `VisualTestAppContext::new()` must be run with special consideration.
+//!
+//! ## Running Visual Tests
+//!
+//! Visual tests are marked with `#[ignore]` by default because:
+//! 1. They require macOS with Screen Recording permission
+//! 2. They need to run on the main thread
+//! 3. They may produce different results on different displays/resolutions
+//!
+//! To run visual tests:
+//! ```bash
+//! # Run all visual tests (requires macOS, may need Screen Recording permission)
+//! cargo test -p zed visual_tests -- --ignored --test-threads=1
+//!
+//! # Update baselines when UI intentionally changes
+//! UPDATE_BASELINES=1 cargo test -p zed visual_tests -- --ignored --test-threads=1
+//! ```
+//!
+//! ## Screenshot Output
+//!
+//! Screenshots are saved to the directory specified by `VISUAL_TEST_OUTPUT_DIR`
+//! environment variable, or `target/visual_tests` by default.
+
+use anyhow::{Result, anyhow};
+use gpui::{
+    AnyWindowHandle, AppContext as _, Empty, Size, VisualTestAppContext, WindowHandle, px, size,
+};
+use image::{ImageBuffer, Rgba, RgbaImage};
+use std::path::Path;
+use std::sync::Arc;
+use std::time::Duration;
+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))
+}
+
+/// Waits for the UI to stabilize by running pending work and waiting for animations.
+pub async fn wait_for_ui_stabilization(cx: &VisualTestAppContext) {
+    cx.run_until_parked();
+    cx.background_executor
+        .timer(Duration::from_millis(100))
+        .await;
+    cx.run_until_parked();
+}
+
+/// Captures a screenshot of the given window and optionally saves it to a file.
+///
+/// # Arguments
+/// * `cx` - The visual test context
+/// * `window` - The window to capture
+/// * `output_path` - Optional path to save the screenshot
+///
+/// # Returns
+/// The captured screenshot as an RgbaImage
+pub async fn capture_and_save_screenshot(
+    cx: &mut VisualTestAppContext,
+    window: AnyWindowHandle,
+    output_path: Option<&Path>,
+) -> Result<RgbaImage> {
+    wait_for_ui_stabilization(cx).await;
+
+    let screenshot = cx.capture_screenshot(window)?;
+
+    if let Some(path) = output_path {
+        if let Some(parent) = path.parent() {
+            std::fs::create_dir_all(parent)?;
+        }
+        screenshot.save(path)?;
+        println!("Screenshot saved to: {}", path.display());
+    }
+
+    Ok(screenshot)
+}
+
+/// Check if we should update baselines (controlled by UPDATE_BASELINES env var).
+pub fn should_update_baselines() -> bool {
+    std::env::var("UPDATE_BASELINES").is_ok()
+}
+
+/// Assert that a screenshot matches a baseline, or update the baseline if UPDATE_BASELINES is set.
+pub fn assert_or_update_baseline(
+    actual: &RgbaImage,
+    baseline_path: &Path,
+    tolerance: f64,
+    per_pixel_threshold: u8,
+) -> Result<()> {
+    if should_update_baselines() {
+        save_baseline(actual, baseline_path)?;
+        println!("Updated baseline: {}", baseline_path.display());
+        Ok(())
+    } else {
+        assert_screenshot_matches(actual, baseline_path, tolerance, per_pixel_threshold)
+    }
+}
+
+/// 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();
+    }
+
+    /// This test captures a screenshot of an empty Zed workspace.
+    ///
+    /// Note: This test is ignored by default because:
+    /// 1. It requires macOS with Screen Recording permission granted
+    /// 2. It must run on the main thread (standard test threads won't work)
+    /// 3. Screenshot capture may fail in CI environments without display access
+    ///
+    /// The test will gracefully handle screenshot failures and print an error
+    /// message rather than failing hard, to allow running in environments
+    /// where screen capture isn't available.
+    #[test]
+    #[ignore]
+    fn test_workspace_screenshot() {
+        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"
+                        },
+                        "README.md": "# Test Project\n\nThis is a test project for visual testing.\n"
+                    }),
+                )
+                .await;
+        });
+
+        let workspace = smol::block_on(open_test_workspace(app_state, &mut cx))
+            .expect("Failed to open workspace");
+
+        smol::block_on(async {
+            wait_for_ui_stabilization(&cx).await;
+
+            let screenshot_result = cx.capture_screenshot(workspace.into());
+
+            match screenshot_result {
+                Ok(screenshot) => {
+                    println!(
+                        "Screenshot captured successfully: {}x{}",
+                        screenshot.width(),
+                        screenshot.height()
+                    );
+
+                    let output_dir = std::env::var("VISUAL_TEST_OUTPUT_DIR")
+                        .unwrap_or_else(|_| "target/visual_tests".to_string());
+                    let output_path = Path::new(&output_dir).join("workspace_screenshot.png");
+
+                    if let Err(e) = std::fs::create_dir_all(&output_dir) {
+                        eprintln!("Warning: Failed to create output directory: {}", e);
+                    }
+
+                    if let Err(e) = screenshot.save(&output_path) {
+                        eprintln!("Warning: Failed to save screenshot: {}", e);
+                    } else {
+                        println!("Screenshot saved to: {}", output_path.display());
+                    }
+
+                    assert!(
+                        screenshot.width() > 0,
+                        "Screenshot width should be positive"
+                    );
+                    assert!(
+                        screenshot.height() > 0,
+                        "Screenshot height should be positive"
+                    );
+                }
+                Err(e) => {
+                    eprintln!(
+                        "Screenshot capture failed (this may be expected in CI without screen recording permission): {}",
+                        e
+                    );
+                }
+            }
+        });
+
+        cx.run_until_parked();
+    }
+}

docs/src/development/macos.md 🔗

@@ -53,6 +53,35 @@ And to run the tests:
 cargo test --workspace
 ```
 
+## Visual Regression Tests
+
+Zed includes visual regression tests that capture screenshots of real Zed windows and compare them against baseline images. These tests require macOS with Screen Recording permission.
+
+### Prerequisites
+
+You must grant Screen Recording permission to your terminal:
+
+1. Run the visual test runner once - macOS will prompt for permission
+2. Or manually: System Settings > Privacy & Security > Screen Recording
+3. Enable your terminal app (e.g., Terminal.app, iTerm2, Ghostty)
+4. Restart your terminal after granting permission
+
+### Running Visual Tests
+
+```sh
+cargo run -p zed --bin visual_test_runner --features visual-tests
+```
+
+### Updating Baselines
+
+When UI changes are intentional, update the baseline images:
+
+```sh
+UPDATE_BASELINE=1 cargo run -p zed --bin visual_test_runner --features visual-tests
+```
+
+Baseline images are stored in `crates/zed/test_fixtures/visual_tests/` and should be committed to the repository.
+
 ## Troubleshooting
 
 ### Error compiling metal shaders

docs/src/development/windows.md 🔗

@@ -108,6 +108,8 @@ And to run the tests:
 cargo test --workspace
 ```
 
+> **Note:** Visual regression tests are currently macOS-only and require Screen Recording permission. See [Building Zed for macOS](./macos.md#visual-regression-tests) for details.
+
 ## Installing from msys2
 
 Zed does not support unofficial MSYS2 Zed packages built for Mingw-w64. Please report any issues you may have with [mingw-w64-zed](https://packages.msys2.org/base/mingw-w64-zed) to [msys2/MINGW-packages/issues](https://github.com/msys2/MINGW-packages/issues?q=is%3Aissue+is%3Aopen+zed).