Detailed changes
@@ -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",
@@ -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 {
@@ -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() {
@@ -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
@@ -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;
@@ -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();
@@ -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 {
@@ -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);
@@ -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"] }
@@ -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())
+}
@@ -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();
+ }
}