Visual test for actual Zed workspace

This commit is contained in:
Richard Feldman
2025-12-17 12:40:35 -05:00
parent 0ce65331f8
commit 9de9b0bde0
11 changed files with 1004 additions and 7 deletions

2
Cargo.lock generated
View File

@@ -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",

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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

View File

@@ -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;

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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"] }

View File

@@ -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())
}

View File

@@ -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();
}
}