diff --git a/Cargo.lock b/Cargo.lock index 58607e3685594cca45cc08c3db8e577a2ea2a1e2..5deed7ca1e12b97eedbd6e91507dec223a092e0d 100644 --- a/Cargo.lock +++ b/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", diff --git a/crates/gpui/src/app/visual_test_context.rs b/crates/gpui/src/app/visual_test_context.rs index 9b7ef5786ce00d83a0c6787b26e7ce69bbf853fd..f8cad492e6fb315bebe1df60f99d3758bdd21362 100644 --- a/crates/gpui/src/app/visual_test_context.rs +++ b/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 { + 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 { + 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 { diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 6c2ecb341ff2fe446efd7823c107fd32a557feb5..8aa6461c6ed82b8585545dd7baccea4ec09c329d 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -425,6 +425,7 @@ impl BackgroundExecutor { timeout: Option, ) -> Result + use> { 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() { diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index f120e075fea7f9336e2f6e10c51611d8ba03564d..336aa5434195304fe8553c4660935b5838397cad 100644 --- a/crates/gpui/src/platform.rs +++ b/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 { + None + } } /// This type is public so that our test macro can generate and use it, but it should not diff --git a/crates/gpui/src/platform/mac.rs b/crates/gpui/src/platform/mac.rs index aa056846e6bc56e53d95c41a44444dbb89a16237..fda6acb2581934597e872a4af1acd0d33f73dd69 100644 --- a/crates/gpui/src/platform/mac.rs +++ b/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; diff --git a/crates/gpui/src/platform/mac/screen_capture.rs b/crates/gpui/src/platform/mac/screen_capture.rs index 2f2c1eae335c8bcb366879661534c46dacfd47b4..4d5445addaa19b5936743c63e654c78ad5177b57 100644 --- a/crates/gpui/src/platform/mac/screen_capture.rs +++ b/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 oneshot::Receiver> { + 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>>>>, +) { + 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 { + 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 = 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::, Vec>::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 { + 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::, Vec>::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 { + 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(); diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 19ad1777570da9494148e01161e156748cd9bcfc..8f23bf9270768450c9872ed800f5c07d6a204352 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/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 { + Some(self.window_number()) + } } impl rwh::HasWindowHandle for MacWindow { diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index c606409661eb022b8627fe9bc9f6c53565f5569f..3a6585c1b3a3ca1f17c7a4c7ecc9c5f301e21903 100644 --- a/crates/gpui/src/window.rs +++ b/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 { + self.platform_window.native_window_id() + } + /// Set the content size of the window. pub fn resize(&mut self, size: Size) { self.platform_window.resize(size); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 1b54dfcfd91f234db8538b6699f7810401c7eb1d..38fd76bd4bc617bb1c3f9778f632b8eb21797a65 100644 --- a/crates/zed/Cargo.toml +++ b/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"] } diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs new file mode 100644 index 0000000000000000000000000000000000000000..b36342bcf8e7e3658157063cfa3928f625017981 --- /dev/null +++ b/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 = 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 { + 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 { + // 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()) +} diff --git a/crates/zed/src/zed/visual_tests.rs b/crates/zed/src/zed/visual_tests.rs index 94c87f4eb63a95ac63679fcf25292caff033a1d4..a6e73b1d722a894e81c841157ce1bcf539fa70bd 100644 --- a/crates/zed/src/zed/visual_tests.rs +++ b/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 { 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 { + 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(); + } }