Visual test for actual Zed workspace

Richard Feldman created

Change summary

Cargo.lock                                     |   2 
crates/gpui/src/app/visual_test_context.rs     |  43 ++
crates/gpui/src/executor.rs                    |  31 +
crates/gpui/src/platform.rs                    |  16 
crates/gpui/src/platform/mac.rs                |   4 
crates/gpui/src/platform/mac/screen_capture.rs | 291 ++++++++++++++
crates/gpui/src/platform/mac/window.rs         |  13 
crates/gpui/src/window.rs                      |   8 
crates/zed/Cargo.toml                          |  35 +
crates/zed/src/visual_test_runner.rs           | 388 ++++++++++++++++++++
crates/zed/src/zed/visual_tests.rs             | 180 +++++++++
11 files changed, 1,004 insertions(+), 7 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -20609,6 +20609,7 @@ dependencies = [
  "clap",
  "cli",
  "client",
+ "clock",
  "codestral",
  "collab_ui",
  "collections",
@@ -20709,6 +20710,7 @@ dependencies = [
  "task",
  "tasks_ui",
  "telemetry",
+ "tempfile",
  "terminal_view",
  "theme",
  "theme_extension",

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

@@ -1,3 +1,5 @@
+#[cfg(feature = "screen-capture")]
+use crate::capture_window_screenshot;
 use crate::{
     Action, AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, Bounds,
     ClipboardItem, Context, Entity, ForegroundExecutor, Global, InputEvent, Keystroke, Modifiers,
@@ -6,6 +8,8 @@ use crate::{
     app::GpuiMode, current_platform,
 };
 use anyhow::anyhow;
+#[cfg(feature = "screen-capture")]
+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.
@@ -337,6 +341,45 @@ impl VisualTestAppContext {
                 .await;
         }
     }
+
+    /// Returns the native window ID (CGWindowID on macOS) for a window.
+    /// This can be used to capture screenshots of specific windows.
+    #[cfg(feature = "screen-capture")]
+    pub fn native_window_id(&mut self, window: AnyWindowHandle) -> Result<u32> {
+        self.update_window(window, |_, window, _| {
+            window
+                .native_window_id()
+                .ok_or_else(|| anyhow!("Window does not have a native window ID"))
+        })?
+    }
+
+    /// Captures a screenshot of the specified window.
+    ///
+    /// This uses ScreenCaptureKit to capture the window contents, even if the window
+    /// is positioned off-screen (e.g., at -10000, -10000 for invisible rendering).
+    ///
+    /// # Arguments
+    /// * `window` - The window handle to capture
+    ///
+    /// # Returns
+    /// An `RgbaImage` containing the captured window contents, or an error if capture failed.
+    #[cfg(feature = "screen-capture")]
+    pub async fn capture_screenshot(&mut self, window: AnyWindowHandle) -> Result<RgbaImage> {
+        let window_id = self.native_window_id(window)?;
+
+        let rx = capture_window_screenshot(window_id);
+
+        rx.await
+            .map_err(|_| anyhow!("Screenshot capture was cancelled"))?
+    }
+
+    /// 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 {

crates/gpui/src/executor.rs 🔗

@@ -425,6 +425,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;
 
@@ -432,8 +433,36 @@ impl BackgroundExecutor {
         if timeout == Some(Duration::ZERO) {
             return Err(future);
         }
+
+        // If there's no test dispatcher, fall back to production blocking behavior
         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 🔗

@@ -88,6 +88,15 @@ pub use linux::layer_shell;
 #[cfg(any(test, feature = "test-support"))]
 pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream};
 
+#[cfg(all(
+    target_os = "macos",
+    feature = "screen-capture",
+    any(test, feature = "test-support")
+))]
+pub use mac::{
+    capture_window_screenshot, cv_pixel_buffer_to_rgba_image, screen_capture_frame_to_rgba_image,
+};
+
 /// Returns a background executor for the current platform.
 pub fn background_executor() -> BackgroundExecutor {
     current_platform(true).background_executor()
@@ -564,6 +573,13 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
     fn as_test(&mut self) -> Option<&mut TestWindow> {
         None
     }
+
+    /// Returns the native window ID (CGWindowID on macOS) for window capture.
+    /// This is used by visual testing infrastructure to capture window screenshots.
+    #[cfg(any(test, feature = "test-support"))]
+    fn native_window_id(&self) -> Option<u32> {
+        None
+    }
 }
 
 /// This type is public so that our test macro can generate and use it, but it should not

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

@@ -8,6 +8,10 @@ mod keyboard;
 
 #[cfg(feature = "screen-capture")]
 mod screen_capture;
+#[cfg(all(feature = "screen-capture", any(test, feature = "test-support")))]
+pub use screen_capture::{
+    capture_window_screenshot, cv_pixel_buffer_to_rgba_image, screen_capture_frame_to_rgba_image,
+};
 
 #[cfg(not(feature = "macos-blade"))]
 mod metal_atlas;

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

@@ -7,17 +7,25 @@ use crate::{
 use anyhow::{Result, anyhow};
 use block::ConcreteBlock;
 use cocoa::{
-    base::{YES, id, nil},
+    base::{NO, YES, id, nil},
     foundation::NSArray,
 };
 use collections::HashMap;
 use core_foundation::base::TCFType;
-use core_graphics::display::{
-    CGDirectDisplayID, CGDisplayCopyDisplayMode, CGDisplayModeGetPixelHeight,
-    CGDisplayModeGetPixelWidth, CGDisplayModeRelease,
+use core_graphics::{
+    base::CGFloat,
+    color_space::CGColorSpace,
+    display::{
+        CGDirectDisplayID, CGDisplayCopyDisplayMode, CGDisplayModeGetPixelHeight,
+        CGDisplayModeGetPixelWidth, CGDisplayModeRelease,
+    },
+    image::CGImage,
 };
+use core_video::pixel_buffer::CVPixelBuffer;
 use ctor::ctor;
+use foreign_types::ForeignType;
 use futures::channel::oneshot;
+use image::{ImageBuffer, Rgba, RgbaImage};
 use media::core_media::{CMSampleBuffer, CMSampleBufferRef};
 use metal::NSInteger;
 use objc::{
@@ -275,6 +283,281 @@ pub(crate) fn get_sources() -> oneshot::Receiver<Result<Vec<Rc<dyn ScreenCapture
     }
 }
 
+/// Captures a single screenshot of a specific window by its CGWindowID.
+///
+/// This uses ScreenCaptureKit's `initWithDesktopIndependentWindow:` API which can
+/// capture windows even when they are positioned off-screen (e.g., at -10000, -10000).
+///
+/// # Arguments
+/// * `window_id` - The CGWindowID (NSWindow's windowNumber) of the window to capture
+///
+/// # Returns
+/// An `RgbaImage` containing the captured window contents, or an error if capture failed.
+pub fn capture_window_screenshot(window_id: u32) -> oneshot::Receiver<Result<RgbaImage>> {
+    let (tx, rx) = oneshot::channel();
+    let tx = Rc::new(RefCell::new(Some(tx)));
+
+    unsafe {
+        log::info!(
+            "capture_window_screenshot: looking for window_id={}",
+            window_id
+        );
+        let content_handler = ConcreteBlock::new(move |shareable_content: id, error: id| {
+            log::info!("capture_window_screenshot: content handler called");
+            if error != nil {
+                if let Some(sender) = tx.borrow_mut().take() {
+                    let msg: id = msg_send![error, localizedDescription];
+                    sender
+                        .send(Err(anyhow!(
+                            "Failed to get shareable content: {:?}",
+                            NSStringExt::to_str(&msg)
+                        )))
+                        .ok();
+                }
+                return;
+            }
+
+            let windows: id = msg_send![shareable_content, windows];
+            let count: usize = msg_send![windows, count];
+
+            let mut target_window: id = nil;
+            log::info!(
+                "capture_window_screenshot: searching {} windows for window_id={}",
+                count,
+                window_id
+            );
+            for i in 0..count {
+                let window: id = msg_send![windows, objectAtIndex: i];
+                let wid: u32 = msg_send![window, windowID];
+                if wid == window_id {
+                    log::info!(
+                        "capture_window_screenshot: found matching window at index {}",
+                        i
+                    );
+                    target_window = window;
+                    break;
+                }
+            }
+
+            if target_window == nil {
+                if let Some(sender) = tx.borrow_mut().take() {
+                    sender
+                        .send(Err(anyhow!(
+                            "Window with ID {} not found in shareable content",
+                            window_id
+                        )))
+                        .ok();
+                }
+                return;
+            }
+
+            log::info!("capture_window_screenshot: calling capture_window_frame");
+            capture_window_frame(target_window, &tx);
+        });
+        let content_handler = content_handler.copy();
+
+        let _: () = msg_send![
+            class!(SCShareableContent),
+            getShareableContentExcludingDesktopWindows:NO
+                                   onScreenWindowsOnly:NO
+                                     completionHandler:content_handler
+        ];
+    }
+
+    rx
+}
+
+unsafe fn capture_window_frame(
+    sc_window: id,
+    tx: &Rc<RefCell<Option<oneshot::Sender<Result<RgbaImage>>>>>,
+) {
+    log::info!("capture_window_frame: creating filter for window");
+    let filter: id = msg_send![class!(SCContentFilter), alloc];
+    let filter: id = msg_send![filter, initWithDesktopIndependentWindow: sc_window];
+    log::info!("capture_window_frame: filter created: {:?}", filter);
+
+    let configuration: id = msg_send![class!(SCStreamConfiguration), alloc];
+    let configuration: id = msg_send![configuration, init];
+
+    let frame: cocoa::foundation::NSRect = msg_send![sc_window, frame];
+    let width = frame.size.width as i64;
+    let height = frame.size.height as i64;
+    log::info!("capture_window_frame: window frame {}x{}", width, height);
+
+    if width <= 0 || height <= 0 {
+        if let Some(tx) = tx.borrow_mut().take() {
+            tx.send(Err(anyhow!(
+                "Window has invalid dimensions: {}x{}",
+                width,
+                height
+            )))
+            .ok();
+        }
+        return;
+    }
+
+    let _: () = msg_send![configuration, setWidth: width];
+    let _: () = msg_send![configuration, setHeight: height];
+    let _: () = msg_send![configuration, setScalesToFit: true];
+    let _: () = msg_send![configuration, setPixelFormat: 0x42475241u32]; // 'BGRA'
+    let _: () = msg_send![configuration, setShowsCursor: false];
+    let _: () = msg_send![configuration, setCapturesAudio: false];
+
+    let tx_for_capture = tx.clone();
+    // The completion handler receives (CGImageRef, NSError*), not CMSampleBuffer
+    let capture_handler =
+        ConcreteBlock::new(move |cg_image: core_graphics::sys::CGImageRef, error: id| {
+            log::info!("Screenshot capture handler called");
+
+            let Some(tx) = tx_for_capture.borrow_mut().take() else {
+                log::warn!("Screenshot capture: tx already taken");
+                return;
+            };
+
+            unsafe {
+                if error != nil {
+                    let msg: id = msg_send![error, localizedDescription];
+                    let error_str = NSStringExt::to_str(&msg);
+                    log::error!("Screenshot capture error from API: {:?}", error_str);
+                    tx.send(Err(anyhow!("Screenshot capture failed: {:?}", error_str)))
+                        .ok();
+                    return;
+                }
+
+                if cg_image.is_null() {
+                    log::error!("Screenshot capture: cg_image is null");
+                    tx.send(Err(anyhow!(
+                        "Screenshot capture returned null CGImage. \
+                         This may mean Screen Recording permission is not granted."
+                    )))
+                    .ok();
+                    return;
+                }
+
+                log::info!("Screenshot capture: got CGImage, converting...");
+                let cg_image = CGImage::from_ptr(cg_image);
+                match cg_image_to_rgba_image(&cg_image) {
+                    Ok(image) => {
+                        log::info!(
+                            "Screenshot capture: success! {}x{}",
+                            image.width(),
+                            image.height()
+                        );
+                        tx.send(Ok(image)).ok();
+                    }
+                    Err(e) => {
+                        log::error!("Screenshot capture: CGImage conversion failed: {}", e);
+                        tx.send(Err(e)).ok();
+                    }
+                }
+            }
+        });
+    let capture_handler = capture_handler.copy();
+
+    log::info!("Calling SCScreenshotManager captureImageWithFilter...");
+    let _: () = msg_send![
+        class!(SCScreenshotManager),
+        captureImageWithFilter: filter
+                 configuration: configuration
+             completionHandler: capture_handler
+    ];
+    log::info!("SCScreenshotManager captureImageWithFilter called");
+}
+
+/// Converts a CGImage to an RgbaImage.
+fn cg_image_to_rgba_image(cg_image: &CGImage) -> Result<RgbaImage> {
+    let width = cg_image.width();
+    let height = cg_image.height();
+
+    if width == 0 || height == 0 {
+        return Err(anyhow!("CGImage has zero dimensions: {}x{}", width, height));
+    }
+
+    // Create a bitmap context to draw the CGImage into
+    let color_space = CGColorSpace::create_device_rgb();
+    let bytes_per_row = width * 4;
+    let mut pixel_data: Vec<u8> = vec![0; height * bytes_per_row];
+
+    let context = core_graphics::context::CGContext::create_bitmap_context(
+        Some(pixel_data.as_mut_ptr() as *mut c_void),
+        width,
+        height,
+        8,             // bits per component
+        bytes_per_row, // bytes per row
+        &color_space,
+        core_graphics::base::kCGImageAlphaPremultipliedLast // RGBA
+            | core_graphics::base::kCGBitmapByteOrder32Big,
+    );
+
+    // Draw the image into the context
+    let rect = core_graphics::geometry::CGRect::new(
+        &core_graphics::geometry::CGPoint::new(0.0, 0.0),
+        &core_graphics::geometry::CGSize::new(width as CGFloat, height as CGFloat),
+    );
+    context.draw_image(rect, cg_image);
+
+    // The pixel data is now in RGBA format
+    ImageBuffer::<Rgba<u8>, Vec<u8>>::from_raw(width as u32, height as u32, pixel_data)
+        .ok_or_else(|| anyhow!("Failed to create RgbaImage from CGImage pixel data"))
+}
+
+/// Converts a CVPixelBuffer (in BGRA format) to an RgbaImage.
+///
+/// This function locks the pixel buffer, reads the raw pixel data,
+/// converts from BGRA to RGBA format, and returns an image::RgbaImage.
+pub fn cv_pixel_buffer_to_rgba_image(pixel_buffer: &CVPixelBuffer) -> Result<RgbaImage> {
+    use core_video::r#return::kCVReturnSuccess;
+
+    unsafe {
+        if pixel_buffer.lock_base_address(0) != kCVReturnSuccess {
+            return Err(anyhow!("Failed to lock pixel buffer base address"));
+        }
+
+        let width = pixel_buffer.get_width();
+        let height = pixel_buffer.get_height();
+        let bytes_per_row = pixel_buffer.get_bytes_per_row();
+        let base_address = pixel_buffer.get_base_address();
+
+        if base_address.is_null() {
+            pixel_buffer.unlock_base_address(0);
+            return Err(anyhow!("Pixel buffer base address is null"));
+        }
+
+        let mut rgba_data = Vec::with_capacity(width * height * 4);
+
+        for y in 0..height {
+            let row_start = base_address.add(y * bytes_per_row) as *const u8;
+            for x in 0..width {
+                let pixel = row_start.add(x * 4);
+                let b = *pixel;
+                let g = *pixel.add(1);
+                let r = *pixel.add(2);
+                let a = *pixel.add(3);
+                rgba_data.push(r);
+                rgba_data.push(g);
+                rgba_data.push(b);
+                rgba_data.push(a);
+            }
+        }
+
+        pixel_buffer.unlock_base_address(0);
+
+        ImageBuffer::<Rgba<u8>, Vec<u8>>::from_raw(width as u32, height as u32, rgba_data)
+            .ok_or_else(|| anyhow!("Failed to create RgbaImage from pixel data"))
+    }
+}
+
+/// Converts a ScreenCaptureFrame to an RgbaImage.
+///
+/// This is useful for converting frames received from continuous screen capture streams.
+pub fn screen_capture_frame_to_rgba_image(frame: &ScreenCaptureFrame) -> Result<RgbaImage> {
+    unsafe {
+        let pixel_buffer =
+            CVPixelBuffer::wrap_under_get_rule(frame.0.as_concrete_TypeRef() as *mut _);
+        cv_pixel_buffer_to_rgba_image(&pixel_buffer)
+    }
+}
+
 #[ctor]
 unsafe fn build_classes() {
     let mut decl = ClassDecl::new("GPUIStreamDelegate", class!(NSObject)).unwrap();

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

@@ -931,6 +931,14 @@ impl MacWindow {
             }
         }
     }
+
+    /// Returns the CGWindowID (NSWindow's windowNumber) for this window.
+    /// This can be used for ScreenCaptureKit window capture.
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn window_number(&self) -> u32 {
+        let this = self.0.lock();
+        unsafe { this.native_window.windowNumber() as u32 }
+    }
 }
 
 impl Drop for MacWindow {
@@ -1556,6 +1564,11 @@ impl PlatformWindow for MacWindow {
             let _: () = msg_send![window, performWindowDragWithEvent: event];
         }
     }
+
+    #[cfg(any(test, feature = "test-support"))]
+    fn native_window_id(&self) -> Option<u32> {
+        Some(self.window_number())
+    }
 }
 
 impl rwh::HasWindowHandle for MacWindow {

crates/gpui/src/window.rs 🔗

@@ -1764,6 +1764,14 @@ impl Window {
         self.platform_window.bounds()
     }
 
+    /// Returns the native window ID (CGWindowID on macOS) for window capture.
+    /// This is used by visual testing infrastructure to capture window screenshots.
+    /// Returns None on platforms that don't support this or in non-test builds.
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn native_window_id(&self) -> Option<u32> {
+        self.platform_window.native_window_id()
+    }
+
     /// Set the content size of the window.
     pub fn resize(&mut self, size: Size<Pixels>) {
         self.platform_window.resize(size);

crates/zed/Cargo.toml 🔗

@@ -12,11 +12,40 @@ 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/zed-main.rs"
 
+[[bin]]
+name = "visual_test_runner"
+path = "src/visual_test_runner.rs"
+required-features = ["visual-tests"]
+
 [lib]
 name = "zed"
 path = "src/main.rs"
@@ -74,6 +103,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
 
@@ -184,7 +217,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"] }

crates/zed/src/visual_test_runner.rs 🔗

@@ -0,0 +1,388 @@
+//! Visual Test Runner
+//!
+//! This binary runs visual tests on the main thread, which is required on macOS
+//! because App construction must happen on the main thread.
+//!
+//! ## Prerequisites
+//!
+//! **Screen Recording Permission Required**: This tool uses macOS ScreenCaptureKit
+//! to capture window screenshots. You must grant Screen Recording permission:
+//!
+//! 1. Run this tool once - macOS will prompt for permission
+//! 2. Or manually: System Settings > Privacy & Security > Screen Recording
+//! 3. Enable the terminal app you're running from (e.g., Terminal.app, iTerm2)
+//! 4. You may need to restart your terminal after granting permission
+//!
+//! ## Usage
+//!
+//!   cargo run -p zed --bin visual_test_runner --features visual-tests
+//!
+//! ## Environment variables
+//!
+//!   VISUAL_TEST_OUTPUT_DIR - Directory to save screenshots (default: target/visual_tests)
+
+use anyhow::Result;
+use gpui::{
+    AppContext as _, Application, Bounds, Window, WindowBounds, WindowHandle, WindowOptions, point,
+    px, size,
+};
+use settings::SettingsStore;
+use std::path::Path;
+use std::sync::Arc;
+use workspace::{AppState, Workspace};
+
+fn main() {
+    env_logger::builder()
+        .filter_level(log::LevelFilter::Info)
+        .init();
+
+    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
+    println!("Setting up test project at: {:?}", project_path);
+    create_test_files(&project_path);
+
+    let project_path_clone = project_path.clone();
+
+    Application::new().run(move |cx| {
+        println!("Initializing Zed...");
+
+        // 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);
+        project_panel::init(cx);
+        outline_panel::init(cx);
+        terminal_view::init(cx);
+        image_viewer::init(cx);
+        search::init(cx);
+
+        println!("Opening Zed workspace...");
+
+        // Open a real Zed workspace window
+        let window_size = size(px(1280.0), px(800.0));
+        let bounds = Bounds {
+            origin: point(px(100.0), px(100.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: true,
+                    show: true,
+                    ..Default::default()
+                },
+                |window, cx| {
+                    cx.new(|cx| {
+                        Workspace::new(None, project.clone(), app_state.clone(), window, cx)
+                    })
+                },
+            )
+            .expect("Failed to open workspace window");
+
+        println!("Workspace window opened, adding project folder...");
+
+        // Add the test project as a worktree
+        let add_folder_task = workspace_window
+            .update(cx, |workspace, window, cx| {
+                workspace.open_paths(
+                    vec![project_path_clone.clone()],
+                    workspace::OpenOptions::default(),
+                    None,
+                    window,
+                    cx,
+                )
+            })
+            .expect("Failed to update workspace");
+
+        // Spawn async task to wait for project to load, then capture screenshot
+        cx.spawn(async move |mut cx| {
+            // Wait for the folder to be added
+            println!("Waiting for project to load...");
+            add_folder_task.await;
+
+            // Wait for the UI to fully render
+            println!("Waiting for UI to stabilize...");
+            cx.background_executor()
+                .timer(std::time::Duration::from_secs(2))
+                .await;
+
+            println!("Capturing screenshot...");
+
+            // Try multiple times in case the first attempt fails
+            let mut result = Err(anyhow::anyhow!("No capture attempts"));
+            for attempt in 1..=3 {
+                println!("Capture attempt {}...", attempt);
+                result = capture_screenshot(workspace_window.into(), &mut cx).await;
+                if result.is_ok() {
+                    break;
+                }
+                if attempt < 3 {
+                    println!("Attempt {} failed, retrying...", attempt);
+                    cx.background_executor()
+                        .timer(std::time::Duration::from_millis(500))
+                        .await;
+                }
+            }
+
+            match result {
+                Ok(path) => {
+                    println!("\n=== Visual Test PASSED ===");
+                    println!("Screenshot saved to: {}", path);
+                }
+                Err(e) => {
+                    eprintln!("\n=== Visual Test FAILED ===");
+                    eprintln!("Error: {}", e);
+                    eprintln!();
+                    eprintln!("If you see 'Screen Recording permission' errors:");
+                    eprintln!("  1. Open System Settings > Privacy & Security > Screen Recording");
+                    eprintln!("  2. Enable your terminal app (Terminal.app, iTerm2, etc.)");
+                    eprintln!("  3. Restart your terminal and try again");
+                }
+            }
+
+            cx.update(|cx| cx.quit()).ok();
+        })
+        .detach();
+    });
+
+    // Keep temp_dir alive until we're done - it will be dropped here
+    drop(temp_dir);
+}
+
+/// 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.
+/// This creates a minimal AppState without FakeFs to avoid test dispatcher issues.
+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,
+    })
+}
+
+async fn capture_screenshot(
+    window: gpui::AnyWindowHandle,
+    cx: &mut gpui::AsyncApp,
+) -> Result<String> {
+    // Get the native window ID
+    let window_id = cx
+        .update(|cx| {
+            cx.update_window(window, |_view, window: &mut Window, _cx| {
+                window.native_window_id()
+            })
+        })??
+        .ok_or_else(|| anyhow::anyhow!("Failed to get native window ID"))?;
+
+    println!("Window ID: {}", window_id);
+
+    // Capture the screenshot
+    let screenshot = gpui::capture_window_screenshot(window_id)
+        .await
+        .map_err(|_| anyhow::anyhow!("Screenshot capture was cancelled"))??;
+
+    println!(
+        "Screenshot captured: {}x{} pixels",
+        screenshot.width(),
+        screenshot.height()
+    );
+
+    // Determine output path
+    let output_dir =
+        std::env::var("VISUAL_TEST_OUTPUT_DIR").unwrap_or_else(|_| "target/visual_tests".into());
+    let output_path = Path::new(&output_dir).join("zed_workspace.png");
+
+    // Create output directory
+    if let Some(parent) = output_path.parent() {
+        std::fs::create_dir_all(parent)?;
+    }
+
+    // Save the screenshot
+    screenshot.save(&output_path)?;
+
+    // Return absolute path
+    let abs_path = output_path
+        .canonicalize()
+        .unwrap_or_else(|_| output_path.clone());
+    Ok(abs_path.display().to_string())
+}

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

@@ -1,10 +1,46 @@
 #![allow(dead_code)]
 
+//! 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::{AppContext as _, Empty, Size, VisualTestAppContext, WindowHandle, px, size};
+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.
@@ -65,6 +101,65 @@ 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).await?;
+
+    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 {
@@ -358,4 +453,87 @@ mod tests {
 
         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()).await;
+
+            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();
+    }
 }