diff --git a/Cargo.lock b/Cargo.lock index de9cb227c6cfb799099abf446c1bdee61ec85bff..58607e3685594cca45cc08c3db8e577a2ea2a1e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20642,6 +20642,7 @@ dependencies = [ "gpui", "gpui_tokio", "http_client", + "image", "image_viewer", "inspector_ui", "install_cli", diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index aa1acae33b8fb55fc5e2f8fa8c0f5b8bb91758f3..b4c59e2671d4989a462d7cbab95fe49f9e62bab1 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -30,6 +30,8 @@ use smallvec::SmallVec; #[cfg(any(test, feature = "test-support"))] pub use test_context::*; use util::{ResultExt, debug_panic}; +#[cfg(all(target_os = "macos", any(test, feature = "test-support")))] +pub use visual_test_context::*; #[cfg(any(feature = "inspector", debug_assertions))] use crate::InspectorElementRegistry; @@ -52,6 +54,8 @@ mod context; mod entity_map; #[cfg(any(test, feature = "test-support"))] mod test_context; +#[cfg(all(target_os = "macos", any(test, feature = "test-support")))] +mod visual_test_context; /// The duration for which futures returned from [Context::on_app_quit] can run before the application fully quits. pub const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100); diff --git a/crates/gpui/src/app/visual_test_context.rs b/crates/gpui/src/app/visual_test_context.rs new file mode 100644 index 0000000000000000000000000000000000000000..9b7ef5786ce00d83a0c6787b26e7ce69bbf853fd --- /dev/null +++ b/crates/gpui/src/app/visual_test_context.rs @@ -0,0 +1,435 @@ +use crate::{ + Action, AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, Bounds, + ClipboardItem, Context, Entity, ForegroundExecutor, Global, InputEvent, Keystroke, Modifiers, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, Point, Render, + Result, Size, Task, TextSystem, Window, WindowBounds, WindowHandle, WindowOptions, + app::GpuiMode, current_platform, +}; +use anyhow::anyhow; +use std::{future::Future, rc::Rc, sync::Arc, time::Duration}; + +/// A test context that uses real macOS rendering instead of mocked rendering. +/// This is used for visual tests that need to capture actual screenshots. +/// +/// Unlike `TestAppContext` which uses `TestPlatform` with mocked rendering, +/// `VisualTestAppContext` uses the real `MacPlatform` to produce actual rendered output. +/// +/// Windows created through this context are positioned off-screen (at coordinates like -10000, -10000) +/// so they are invisible to the user but still fully rendered by the compositor. +#[derive(Clone)] +pub struct VisualTestAppContext { + /// The underlying app cell + pub app: Rc, + /// The background executor for running async tasks + pub background_executor: BackgroundExecutor, + /// The foreground executor for running tasks on the main thread + pub foreground_executor: ForegroundExecutor, + platform: Rc, + text_system: Arc, +} + +impl VisualTestAppContext { + /// Creates a new `VisualTestAppContext` with real macOS platform rendering. + /// + /// This initializes the real macOS platform (not the test platform), which means: + /// - Windows are actually rendered by Metal/the compositor + /// - Screenshots can be captured via ScreenCaptureKit + /// - All platform APIs work as they do in production + pub fn new() -> Self { + let platform = current_platform(false); + let background_executor = platform.background_executor(); + let foreground_executor = platform.foreground_executor(); + let text_system = Arc::new(TextSystem::new(platform.text_system())); + + let asset_source = Arc::new(()); + let http_client = http_client::FakeHttpClient::with_404_response(); + + let mut app = App::new_app(platform.clone(), asset_source, http_client); + app.borrow_mut().mode = GpuiMode::test(); + + Self { + app, + background_executor, + foreground_executor, + platform, + text_system, + } + } + + /// Opens a window positioned off-screen for invisible rendering. + /// + /// The window is positioned at (-10000, -10000) so it's not visible on any display, + /// but it's still fully rendered by the compositor and can be captured via ScreenCaptureKit. + /// + /// # Arguments + /// * `size` - The size of the window to create + /// * `build_root` - A closure that builds the root view for the window + pub fn open_offscreen_window( + &mut self, + size: Size, + build_root: impl FnOnce(&mut Window, &mut App) -> Entity, + ) -> Result> { + use crate::{point, px}; + + let bounds = Bounds { + origin: point(px(-10000.0), px(-10000.0)), + size, + }; + + let mut cx = self.app.borrow_mut(); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + focus: false, + show: true, + ..Default::default() + }, + build_root, + ) + } + + /// Opens an off-screen window with default size (1280x800). + pub fn open_offscreen_window_default( + &mut self, + build_root: impl FnOnce(&mut Window, &mut App) -> Entity, + ) -> Result> { + use crate::{px, size}; + self.open_offscreen_window(size(px(1280.0), px(800.0)), build_root) + } + + /// Returns whether screen capture is supported on this platform. + pub fn is_screen_capture_supported(&self) -> bool { + self.platform.is_screen_capture_supported() + } + + /// Returns the text system used by this context. + pub fn text_system(&self) -> &Arc { + &self.text_system + } + + /// Returns the background executor. + pub fn executor(&self) -> BackgroundExecutor { + self.background_executor.clone() + } + + /// Returns the foreground executor. + pub fn foreground_executor(&self) -> ForegroundExecutor { + self.foreground_executor.clone() + } + + /// Runs pending background tasks until there's nothing left to do. + pub fn run_until_parked(&self) { + self.background_executor.run_until_parked(); + } + + /// Updates the app state. + pub fn update(&mut self, f: impl FnOnce(&mut App) -> R) -> R { + let mut app = self.app.borrow_mut(); + f(&mut app) + } + + /// Reads from the app state. + pub fn read(&self, f: impl FnOnce(&App) -> R) -> R { + let app = self.app.borrow(); + f(&app) + } + + /// Updates a window. + pub fn update_window(&mut self, window: AnyWindowHandle, f: F) -> Result + where + F: FnOnce(AnyView, &mut Window, &mut App) -> T, + { + let mut lock = self.app.borrow_mut(); + lock.update_window(window, f) + } + + /// Spawns a task on the foreground executor. + pub fn spawn(&self, f: F) -> Task + where + F: Future + 'static, + R: 'static, + { + self.foreground_executor.spawn(f) + } + + /// Checks if a global of type G exists. + pub fn has_global(&self) -> bool { + let app = self.app.borrow(); + app.has_global::() + } + + /// Reads a global value. + pub fn read_global(&self, f: impl FnOnce(&G, &App) -> R) -> R { + let app = self.app.borrow(); + f(app.global::(), &app) + } + + /// Sets a global value. + pub fn set_global(&mut self, global: G) { + let mut app = self.app.borrow_mut(); + app.set_global(global); + } + + /// Updates a global value. + pub fn update_global(&mut self, f: impl FnOnce(&mut G, &mut App) -> R) -> R { + let mut lock = self.app.borrow_mut(); + lock.update(|cx| { + let mut global = cx.lease_global::(); + let result = f(&mut global, cx); + cx.end_global_lease(global); + result + }) + } + + /// Simulates a sequence of keystrokes on the given window. + /// + /// Keystrokes are specified as a space-separated string, e.g., "cmd-p escape". + pub fn simulate_keystrokes(&mut self, window: AnyWindowHandle, keystrokes: &str) { + for keystroke_text in keystrokes.split_whitespace() { + let keystroke = Keystroke::parse(keystroke_text) + .unwrap_or_else(|_| panic!("Invalid keystroke: {}", keystroke_text)); + self.dispatch_keystroke(window, keystroke); + } + self.run_until_parked(); + } + + /// Dispatches a single keystroke to a window. + pub fn dispatch_keystroke(&mut self, window: AnyWindowHandle, keystroke: Keystroke) { + self.update_window(window, |_, window, cx| { + window.dispatch_keystroke(keystroke, cx); + }) + .ok(); + } + + /// Simulates typing text input on the given window. + pub fn simulate_input(&mut self, window: AnyWindowHandle, input: &str) { + for char in input.chars() { + let key = char.to_string(); + let keystroke = Keystroke { + modifiers: Modifiers::default(), + key: key.clone(), + key_char: Some(key), + }; + self.dispatch_keystroke(window, keystroke); + } + self.run_until_parked(); + } + + /// Simulates a mouse move event. + pub fn simulate_mouse_move( + &mut self, + window: AnyWindowHandle, + position: Point, + button: impl Into>, + modifiers: Modifiers, + ) { + self.simulate_event( + window, + MouseMoveEvent { + position, + modifiers, + pressed_button: button.into(), + }, + ); + } + + /// Simulates a mouse down event. + pub fn simulate_mouse_down( + &mut self, + window: AnyWindowHandle, + position: Point, + button: MouseButton, + modifiers: Modifiers, + ) { + self.simulate_event( + window, + MouseDownEvent { + position, + modifiers, + button, + click_count: 1, + first_mouse: false, + }, + ); + } + + /// Simulates a mouse up event. + pub fn simulate_mouse_up( + &mut self, + window: AnyWindowHandle, + position: Point, + button: MouseButton, + modifiers: Modifiers, + ) { + self.simulate_event( + window, + MouseUpEvent { + position, + modifiers, + button, + click_count: 1, + }, + ); + } + + /// Simulates a click (mouse down followed by mouse up). + pub fn simulate_click( + &mut self, + window: AnyWindowHandle, + position: Point, + modifiers: Modifiers, + ) { + self.simulate_mouse_down(window, position, MouseButton::Left, modifiers); + self.simulate_mouse_up(window, position, MouseButton::Left, modifiers); + } + + /// Simulates an input event on the given window. + pub fn simulate_event(&mut self, window: AnyWindowHandle, event: E) { + self.update_window(window, |_, window, cx| { + window.dispatch_event(event.to_platform_input(), cx); + }) + .ok(); + self.run_until_parked(); + } + + /// Dispatches an action to the given window. + pub fn dispatch_action(&mut self, window: AnyWindowHandle, action: impl Action) { + self.update_window(window, |_, window, cx| { + window.dispatch_action(action.boxed_clone(), cx); + }) + .ok(); + self.run_until_parked(); + } + + /// Writes to the clipboard. + pub fn write_to_clipboard(&self, item: ClipboardItem) { + self.platform.write_to_clipboard(item); + } + + /// Reads from the clipboard. + pub fn read_from_clipboard(&self) -> Option { + self.platform.read_from_clipboard() + } + + /// Waits for a condition to become true, with a timeout. + pub async fn wait_for( + &mut self, + entity: &Entity, + predicate: impl Fn(&T) -> bool, + timeout: Duration, + ) -> Result<()> { + let start = std::time::Instant::now(); + loop { + { + let app = self.app.borrow(); + if predicate(entity.read(&app)) { + return Ok(()); + } + } + + if start.elapsed() > timeout { + return Err(anyhow!("Timed out waiting for condition")); + } + + self.run_until_parked(); + self.background_executor + .timer(Duration::from_millis(10)) + .await; + } + } +} + +impl Default for VisualTestAppContext { + fn default() -> Self { + Self::new() + } +} + +impl AppContext for VisualTestAppContext { + type Result = T; + + fn new( + &mut self, + build_entity: impl FnOnce(&mut Context) -> T, + ) -> Self::Result> { + let mut app = self.app.borrow_mut(); + app.new(build_entity) + } + + fn reserve_entity(&mut self) -> Self::Result> { + let mut app = self.app.borrow_mut(); + app.reserve_entity() + } + + fn insert_entity( + &mut self, + reservation: crate::Reservation, + build_entity: impl FnOnce(&mut Context) -> T, + ) -> Self::Result> { + let mut app = self.app.borrow_mut(); + app.insert_entity(reservation, build_entity) + } + + fn update_entity( + &mut self, + handle: &Entity, + update: impl FnOnce(&mut T, &mut Context) -> R, + ) -> Self::Result { + let mut app = self.app.borrow_mut(); + app.update_entity(handle, update) + } + + fn as_mut<'a, T>(&'a mut self, _: &Entity) -> Self::Result> + where + T: 'static, + { + panic!("Cannot use as_mut with a visual test app context. Try calling update() first") + } + + fn read_entity( + &self, + handle: &Entity, + read: impl FnOnce(&T, &App) -> R, + ) -> Self::Result + where + T: 'static, + { + let app = self.app.borrow(); + app.read_entity(handle, read) + } + + fn update_window(&mut self, window: AnyWindowHandle, f: F) -> Result + where + F: FnOnce(AnyView, &mut Window, &mut App) -> T, + { + let mut lock = self.app.borrow_mut(); + lock.update_window(window, f) + } + + fn read_window( + &self, + window: &WindowHandle, + read: impl FnOnce(Entity, &App) -> R, + ) -> Result + where + T: 'static, + { + let app = self.app.borrow(); + app.read_window(window, read) + } + + fn background_spawn(&self, future: impl Future + Send + 'static) -> Task + where + R: Send + 'static, + { + self.background_executor.spawn(future) + } + + fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result + where + G: Global, + { + let app = self.app.borrow(); + callback(app.global::(), &app) + } +} diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 955540843489ac21d79042854eb6fcebf5f64318..1b54dfcfd91f234db8538b6699f7810401c7eb1d 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -195,6 +195,7 @@ terminal_view = { workspace = true, features = ["test-support"] } tree-sitter-md.workspace = true tree-sitter-rust.workspace = true workspace = { workspace = true, features = ["test-support"] } +image.workspace = true [package.metadata.bundle-dev] icon = ["resources/app-icon-dev@2x.png", "resources/app-icon-dev.png"] diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index d2764c5c334ba32730982fc55e80d6197de3a2aa..17b422febbe20dc48ee39083236c52b3b3e47ccc 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -6,6 +6,8 @@ pub(crate) mod mac_only_instance; mod migrate; mod open_listener; mod quick_action_bar; +#[cfg(all(target_os = "macos", any(test, feature = "test-support")))] +pub mod visual_tests; #[cfg(target_os = "windows")] pub(crate) mod windows_only_instance; diff --git a/crates/zed/src/zed/visual_tests.rs b/crates/zed/src/zed/visual_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..94c87f4eb63a95ac63679fcf25292caff033a1d4 --- /dev/null +++ b/crates/zed/src/zed/visual_tests.rs @@ -0,0 +1,361 @@ +#![allow(dead_code)] + +use anyhow::{Result, anyhow}; +use gpui::{AppContext as _, Empty, Size, VisualTestAppContext, WindowHandle, px, size}; +use image::{ImageBuffer, Rgba, RgbaImage}; +use std::path::Path; +use std::sync::Arc; +use workspace::AppState; + +/// Initialize a visual test context with all necessary Zed subsystems. +pub fn init_visual_test(cx: &mut VisualTestAppContext) -> Arc { + cx.update(|cx| { + env_logger::builder().is_test(true).try_init().ok(); + + let app_state = AppState::test(cx); + + gpui_tokio::init(cx); + theme::init(theme::LoadThemes::JustBase, 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); + + app_state + }) +} + +/// Open a test workspace with the given app state. +pub async fn open_test_workspace( + app_state: Arc, + cx: &mut VisualTestAppContext, +) -> Result> { + let window_size = size(px(1280.0), px(800.0)); + + let project = cx.update(|cx| { + 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 window = cx.open_offscreen_window(window_size, |window, cx| { + cx.new(|cx| workspace::Workspace::new(None, project.clone(), app_state.clone(), window, cx)) + })?; + + cx.run_until_parked(); + + Ok(window) +} + +/// Returns the default window size for visual tests (1280x800). +pub fn default_window_size() -> Size { + size(px(1280.0), px(800.0)) +} + +/// Result of comparing two screenshots. +#[derive(Debug)] +pub struct ScreenshotComparison { + /// Percentage of pixels that match (0.0 to 1.0) + pub match_percentage: f64, + /// Optional diff image highlighting differences (red = different, green = same) + pub diff_image: Option, + /// Number of pixels that differ + pub diff_pixel_count: u64, + /// Total number of pixels compared + pub total_pixels: u64, +} + +impl ScreenshotComparison { + /// Returns true if the images match within the given tolerance. + pub fn matches(&self, tolerance: f64) -> bool { + self.match_percentage >= (1.0 - tolerance) + } +} + +/// Compare two screenshots with tolerance for minor differences (e.g., anti-aliasing). +/// +/// # Arguments +/// * `actual` - The screenshot to test +/// * `expected` - The baseline screenshot to compare against +/// * `per_pixel_threshold` - Maximum color difference per channel (0-255) to consider pixels equal +/// +/// # Returns +/// A `ScreenshotComparison` containing match statistics and an optional diff image. +pub fn compare_screenshots( + actual: &RgbaImage, + expected: &RgbaImage, + per_pixel_threshold: u8, +) -> ScreenshotComparison { + let (width, height) = actual.dimensions(); + let (exp_width, exp_height) = expected.dimensions(); + + if width != exp_width || height != exp_height { + return ScreenshotComparison { + match_percentage: 0.0, + diff_image: None, + diff_pixel_count: (width * height).max(exp_width * exp_height) as u64, + total_pixels: (width * height).max(exp_width * exp_height) as u64, + }; + } + + let total_pixels = (width * height) as u64; + let mut diff_pixel_count = 0u64; + let mut diff_image: RgbaImage = ImageBuffer::new(width, height); + + for y in 0..height { + for x in 0..width { + let actual_pixel = actual.get_pixel(x, y); + let expected_pixel = expected.get_pixel(x, y); + + let pixels_match = + pixels_are_similar(actual_pixel, expected_pixel, per_pixel_threshold); + + if pixels_match { + diff_image.put_pixel(x, y, Rgba([0, 128, 0, 255])); + } else { + diff_pixel_count += 1; + diff_image.put_pixel(x, y, Rgba([255, 0, 0, 255])); + } + } + } + + let matching_pixels = total_pixels - diff_pixel_count; + let match_percentage = if total_pixels > 0 { + matching_pixels as f64 / total_pixels as f64 + } else { + 1.0 + }; + + ScreenshotComparison { + match_percentage, + diff_image: Some(diff_image), + diff_pixel_count, + total_pixels, + } +} + +/// Check if two pixels are similar within a threshold. +fn pixels_are_similar(a: &Rgba, b: &Rgba, threshold: u8) -> bool { + let threshold = threshold as i16; + + let diff_r = (a[0] as i16 - b[0] as i16).abs(); + let diff_g = (a[1] as i16 - b[1] as i16).abs(); + let diff_b = (a[2] as i16 - b[2] as i16).abs(); + let diff_a = (a[3] as i16 - b[3] as i16).abs(); + + diff_r <= threshold && diff_g <= threshold && diff_b <= threshold && diff_a <= threshold +} + +/// Assert that a screenshot matches a baseline image within tolerance. +/// +/// # Arguments +/// * `actual` - The screenshot to test +/// * `baseline_path` - Path to the baseline image file +/// * `tolerance` - Percentage of pixels that can differ (0.0 to 1.0) +/// * `per_pixel_threshold` - Maximum color difference per channel (0-255) to consider pixels equal +/// +/// # Returns +/// Ok(()) if the images match, Err with details if they don't. +pub fn assert_screenshot_matches( + actual: &RgbaImage, + baseline_path: &Path, + tolerance: f64, + per_pixel_threshold: u8, +) -> Result<()> { + if !baseline_path.exists() { + return Err(anyhow!( + "Baseline image not found at: {}. Run with UPDATE_BASELINES=1 to create it.", + baseline_path.display() + )); + } + + let expected = image::open(baseline_path) + .map_err(|e| anyhow!("Failed to open baseline image: {}", e))? + .to_rgba8(); + + let comparison = compare_screenshots(actual, &expected, per_pixel_threshold); + + if comparison.matches(tolerance) { + Ok(()) + } else { + let diff_path = baseline_path.with_extension("diff.png"); + if let Some(diff_image) = &comparison.diff_image { + diff_image.save(&diff_path).ok(); + } + + let actual_path = baseline_path.with_extension("actual.png"); + actual.save(&actual_path).ok(); + + Err(anyhow!( + "Screenshot does not match baseline.\n\ + Match: {:.2}% (required: {:.2}%)\n\ + Differing pixels: {} / {}\n\ + Baseline: {}\n\ + Actual saved to: {}\n\ + Diff saved to: {}", + comparison.match_percentage * 100.0, + (1.0 - tolerance) * 100.0, + comparison.diff_pixel_count, + comparison.total_pixels, + baseline_path.display(), + actual_path.display(), + diff_path.display() + )) + } +} + +/// Save an image as the new baseline, creating parent directories if needed. +pub fn save_baseline(image: &RgbaImage, baseline_path: &Path) -> Result<()> { + if let Some(parent) = baseline_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| anyhow!("Failed to create baseline directory: {}", e))?; + } + + image + .save(baseline_path) + .map_err(|e| anyhow!("Failed to save baseline image: {}", e))?; + + Ok(()) +} + +/// Load an image from a file path. +pub fn load_image(path: &Path) -> Result { + image::open(path) + .map_err(|e| anyhow!("Failed to load image from {}: {}", path.display(), e)) + .map(|img| img.to_rgba8()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_image(width: u32, height: u32, color: Rgba) -> RgbaImage { + let mut img = ImageBuffer::new(width, height); + for pixel in img.pixels_mut() { + *pixel = color; + } + img + } + + #[test] + fn test_identical_images_match() { + let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255])); + let img2 = create_test_image(100, 100, Rgba([255, 0, 0, 255])); + + let comparison = compare_screenshots(&img1, &img2, 0); + + assert_eq!(comparison.match_percentage, 1.0); + assert_eq!(comparison.diff_pixel_count, 0); + assert!(comparison.matches(0.0)); + } + + #[test] + fn test_different_images_dont_match() { + let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255])); + let img2 = create_test_image(100, 100, Rgba([0, 255, 0, 255])); + + let comparison = compare_screenshots(&img1, &img2, 0); + + assert_eq!(comparison.match_percentage, 0.0); + assert_eq!(comparison.diff_pixel_count, 10000); + assert!(!comparison.matches(0.5)); + } + + #[test] + fn test_similar_images_match_with_threshold() { + let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255])); + let img2 = create_test_image(100, 100, Rgba([250, 5, 0, 255])); + + let comparison_strict = compare_screenshots(&img1, &img2, 0); + assert_eq!(comparison_strict.match_percentage, 0.0); + + let comparison_lenient = compare_screenshots(&img1, &img2, 10); + assert_eq!(comparison_lenient.match_percentage, 1.0); + } + + #[test] + fn test_different_size_images() { + let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255])); + let img2 = create_test_image(200, 200, Rgba([255, 0, 0, 255])); + + let comparison = compare_screenshots(&img1, &img2, 0); + + assert_eq!(comparison.match_percentage, 0.0); + assert!(comparison.diff_image.is_none()); + } + + #[test] + fn test_partial_difference() { + let mut img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255])); + let img2 = create_test_image(100, 100, Rgba([255, 0, 0, 255])); + + for x in 0..50 { + for y in 0..100 { + img1.put_pixel(x, y, Rgba([0, 255, 0, 255])); + } + } + + let comparison = compare_screenshots(&img1, &img2, 0); + + assert_eq!(comparison.match_percentage, 0.5); + assert_eq!(comparison.diff_pixel_count, 5000); + assert!(comparison.matches(0.5)); + assert!(!comparison.matches(0.49)); + } + + #[test] + #[ignore] + fn test_visual_test_smoke() { + let mut cx = VisualTestAppContext::new(); + + let _window = cx + .open_offscreen_window_default(|_, cx| cx.new(|_| Empty)) + .expect("Failed to open offscreen window"); + + cx.run_until_parked(); + } + + #[test] + #[ignore] + fn test_workspace_opens() { + 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" + } + }), + ) + .await; + }); + + let workspace_result = smol::block_on(open_test_workspace(app_state, &mut cx)); + assert!( + workspace_result.is_ok(), + "Failed to open workspace: {:?}", + workspace_result.err() + ); + + cx.run_until_parked(); + } +}