Visual test for actual Zed workspace
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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"] }
|
||||
|
||||
388
crates/zed/src/visual_test_runner.rs
Normal file
388
crates/zed/src/visual_test_runner.rs
Normal 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())
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user