Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T07:46:21.549Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

visual_tests.rs

552 lines · 18.6 KB · rust
1#![allow(dead_code, unused_imports)]
2
3//! Visual testing infrastructure for Zed.
4//!
5//! This module provides utilities for visual regression testing of Zed's UI.
6//! It allows capturing screenshots of the real Zed application window and comparing
7//! them against baseline images.
8//!
9//! ## Important: Main Thread Requirement
10//!
11//! On macOS, the `VisualTestAppContext` must be created on the main thread.
12//! Standard Rust tests run on worker threads, so visual tests that use
13//! `VisualTestAppContext::new()` must be run with special consideration.
14//!
15//! ## Running Visual Tests
16//!
17//! Visual tests are marked with `#[ignore]` by default because:
18//! 1. They require macOS with Screen Recording permission
19//! 2. They need to run on the main thread
20//! 3. They may produce different results on different displays/resolutions
21//!
22//! To run visual tests:
23//! ```bash
24//! # Run all visual tests (requires macOS, may need Screen Recording permission)
25//! cargo test -p zed visual_tests -- --ignored --test-threads=1
26//!
27//! # Update baselines when UI intentionally changes
28//! UPDATE_BASELINES=1 cargo test -p zed visual_tests -- --ignored --test-threads=1
29//! ```
30//!
31//! ## Screenshot Output
32//!
33//! Screenshots are saved to the directory specified by `VISUAL_TEST_OUTPUT_DIR`
34//! environment variable, or `target/visual_tests` by default.
35
36use anyhow::{Result, anyhow};
37use gpui::{
38    AnyWindowHandle, AppContext as _, Empty, Size, VisualTestAppContext, WindowHandle, px, size,
39};
40use image::{ImageBuffer, Rgba, RgbaImage};
41use std::path::Path;
42use std::sync::Arc;
43use std::time::Duration;
44use workspace::AppState;
45
46/// Initialize a visual test context with all necessary Zed subsystems.
47pub fn init_visual_test(cx: &mut VisualTestAppContext) -> Arc<AppState> {
48    cx.update(|cx| {
49        env_logger::builder().is_test(true).try_init().ok();
50
51        let app_state = AppState::test(cx);
52
53        gpui_tokio::init(cx);
54        theme_settings::init(theme::LoadThemes::JustBase, cx);
55        audio::init(cx);
56        workspace::init(app_state.clone(), cx);
57        release_channel::init(semver::Version::new(0, 0, 0), cx);
58        command_palette::init(cx);
59        editor::init(cx);
60        project_panel::init(cx);
61        outline_panel::init(cx);
62        terminal_view::init(cx);
63        image_viewer::init(cx);
64        search::init(cx);
65        lsp_locations::init(cx);
66        cx.set_global(workspace::PaneSearchBarCallbacks {
67            setup_search_bar: |languages, toolbar, window, cx| {
68                let search_bar = cx.new(|cx| search::BufferSearchBar::new(languages, window, cx));
69                toolbar.update(cx, |toolbar, cx| {
70                    toolbar.add_item(search_bar, window, cx);
71                });
72            },
73            wrap_div_with_search_actions: search::buffer_search::register_pane_search_actions,
74        });
75
76        app_state
77    })
78}
79
80/// Open a test workspace with the given app state.
81pub async fn open_test_workspace(
82    app_state: Arc<AppState>,
83    cx: &mut VisualTestAppContext,
84) -> Result<WindowHandle<workspace::Workspace>> {
85    let window_size = size(px(1280.0), px(800.0));
86    let project = cx.update(|cx| {
87        project::Project::local(
88            app_state.client.clone(),
89            app_state.node_runtime.clone(),
90            app_state.user_store.clone(),
91            app_state.languages.clone(),
92            app_state.fs.clone(),
93            None,
94            project::LocalProjectFlags {
95                init_worktree_trust: false,
96                ..Default::default()
97            },
98            cx,
99        )
100    });
101
102    let window = cx.open_offscreen_window(window_size, |window, cx| {
103        cx.new(|cx| workspace::Workspace::new(None, project.clone(), app_state.clone(), window, cx))
104    })?;
105
106    cx.run_until_parked();
107
108    Ok(window)
109}
110
111/// Returns the default window size for visual tests (1280x800).
112pub fn default_window_size() -> Size<gpui::Pixels> {
113    size(px(1280.0), px(800.0))
114}
115
116/// Waits for the UI to stabilize by running pending work and waiting for animations.
117pub async fn wait_for_ui_stabilization(cx: &VisualTestAppContext) {
118    cx.run_until_parked();
119    cx.background_executor
120        .timer(Duration::from_millis(100))
121        .await;
122    cx.run_until_parked();
123}
124
125/// Captures a screenshot of the given window and optionally saves it to a file.
126///
127/// # Arguments
128/// * `cx` - The visual test context
129/// * `window` - The window to capture
130/// * `output_path` - Optional path to save the screenshot
131///
132/// # Returns
133/// The captured screenshot as an RgbaImage
134pub async fn capture_and_save_screenshot(
135    cx: &mut VisualTestAppContext,
136    window: AnyWindowHandle,
137    output_path: Option<&Path>,
138) -> Result<RgbaImage> {
139    wait_for_ui_stabilization(cx).await;
140
141    let screenshot = cx.capture_screenshot(window)?;
142
143    if let Some(path) = output_path {
144        if let Some(parent) = path.parent() {
145            std::fs::create_dir_all(parent)?;
146        }
147        screenshot.save(path)?;
148        println!("Screenshot saved to: {}", path.display());
149    }
150
151    Ok(screenshot)
152}
153
154/// Check if we should update baselines (controlled by UPDATE_BASELINES env var).
155pub fn should_update_baselines() -> bool {
156    std::env::var("UPDATE_BASELINES").is_ok()
157}
158
159/// Assert that a screenshot matches a baseline, or update the baseline if UPDATE_BASELINES is set.
160pub fn assert_or_update_baseline(
161    actual: &RgbaImage,
162    baseline_path: &Path,
163    tolerance: f64,
164    per_pixel_threshold: u8,
165) -> Result<()> {
166    if should_update_baselines() {
167        save_baseline(actual, baseline_path)?;
168        println!("Updated baseline: {}", baseline_path.display());
169        Ok(())
170    } else {
171        assert_screenshot_matches(actual, baseline_path, tolerance, per_pixel_threshold)
172    }
173}
174
175/// Result of comparing two screenshots.
176#[derive(Debug)]
177pub struct ScreenshotComparison {
178    /// Percentage of pixels that match (0.0 to 1.0)
179    pub match_percentage: f64,
180    /// Optional diff image highlighting differences (red = different, green = same)
181    pub diff_image: Option<RgbaImage>,
182    /// Number of pixels that differ
183    pub diff_pixel_count: u64,
184    /// Total number of pixels compared
185    pub total_pixels: u64,
186}
187
188impl ScreenshotComparison {
189    /// Returns true if the images match within the given tolerance.
190    pub fn matches(&self, tolerance: f64) -> bool {
191        self.match_percentage >= (1.0 - tolerance)
192    }
193}
194
195/// Compare two screenshots with tolerance for minor differences (e.g., anti-aliasing).
196///
197/// # Arguments
198/// * `actual` - The screenshot to test
199/// * `expected` - The baseline screenshot to compare against
200/// * `per_pixel_threshold` - Maximum color difference per channel (0-255) to consider pixels equal
201///
202/// # Returns
203/// A `ScreenshotComparison` containing match statistics and an optional diff image.
204pub fn compare_screenshots(
205    actual: &RgbaImage,
206    expected: &RgbaImage,
207    per_pixel_threshold: u8,
208) -> ScreenshotComparison {
209    let (width, height) = actual.dimensions();
210    let (exp_width, exp_height) = expected.dimensions();
211
212    if width != exp_width || height != exp_height {
213        return ScreenshotComparison {
214            match_percentage: 0.0,
215            diff_image: None,
216            diff_pixel_count: (width * height).max(exp_width * exp_height) as u64,
217            total_pixels: (width * height).max(exp_width * exp_height) as u64,
218        };
219    }
220
221    let total_pixels = (width * height) as u64;
222    let mut diff_pixel_count = 0u64;
223    let mut diff_image: RgbaImage = ImageBuffer::new(width, height);
224
225    for y in 0..height {
226        for x in 0..width {
227            let actual_pixel = actual.get_pixel(x, y);
228            let expected_pixel = expected.get_pixel(x, y);
229
230            let pixels_match =
231                pixels_are_similar(actual_pixel, expected_pixel, per_pixel_threshold);
232
233            if pixels_match {
234                diff_image.put_pixel(x, y, Rgba([0, 128, 0, 255]));
235            } else {
236                diff_pixel_count += 1;
237                diff_image.put_pixel(x, y, Rgba([255, 0, 0, 255]));
238            }
239        }
240    }
241
242    let matching_pixels = total_pixels - diff_pixel_count;
243    let match_percentage = if total_pixels > 0 {
244        matching_pixels as f64 / total_pixels as f64
245    } else {
246        1.0
247    };
248
249    ScreenshotComparison {
250        match_percentage,
251        diff_image: Some(diff_image),
252        diff_pixel_count,
253        total_pixels,
254    }
255}
256
257/// Check if two pixels are similar within a threshold.
258fn pixels_are_similar(a: &Rgba<u8>, b: &Rgba<u8>, threshold: u8) -> bool {
259    let threshold = threshold as i16;
260
261    let diff_r = (a[0] as i16 - b[0] as i16).abs();
262    let diff_g = (a[1] as i16 - b[1] as i16).abs();
263    let diff_b = (a[2] as i16 - b[2] as i16).abs();
264    let diff_a = (a[3] as i16 - b[3] as i16).abs();
265
266    diff_r <= threshold && diff_g <= threshold && diff_b <= threshold && diff_a <= threshold
267}
268
269/// Assert that a screenshot matches a baseline image within tolerance.
270///
271/// # Arguments
272/// * `actual` - The screenshot to test
273/// * `baseline_path` - Path to the baseline image file
274/// * `tolerance` - Percentage of pixels that can differ (0.0 to 1.0)
275/// * `per_pixel_threshold` - Maximum color difference per channel (0-255) to consider pixels equal
276///
277/// # Returns
278/// Ok(()) if the images match, Err with details if they don't.
279pub fn assert_screenshot_matches(
280    actual: &RgbaImage,
281    baseline_path: &Path,
282    tolerance: f64,
283    per_pixel_threshold: u8,
284) -> Result<()> {
285    if !baseline_path.exists() {
286        return Err(anyhow!(
287            "Baseline image not found at: {}. Run with UPDATE_BASELINES=1 to create it.",
288            baseline_path.display()
289        ));
290    }
291
292    let expected = image::open(baseline_path)
293        .map_err(|e| anyhow!("Failed to open baseline image: {}", e))?
294        .to_rgba8();
295
296    let comparison = compare_screenshots(actual, &expected, per_pixel_threshold);
297
298    if comparison.matches(tolerance) {
299        Ok(())
300    } else {
301        let diff_path = baseline_path.with_extension("diff.png");
302        if let Some(diff_image) = &comparison.diff_image {
303            diff_image.save(&diff_path).ok();
304        }
305
306        let actual_path = baseline_path.with_extension("actual.png");
307        actual.save(&actual_path).ok();
308
309        Err(anyhow!(
310            "Screenshot does not match baseline.\n\
311             Match: {:.2}% (required: {:.2}%)\n\
312             Differing pixels: {} / {}\n\
313             Baseline: {}\n\
314             Actual saved to: {}\n\
315             Diff saved to: {}",
316            comparison.match_percentage * 100.0,
317            (1.0 - tolerance) * 100.0,
318            comparison.diff_pixel_count,
319            comparison.total_pixels,
320            baseline_path.display(),
321            actual_path.display(),
322            diff_path.display()
323        ))
324    }
325}
326
327/// Save an image as the new baseline, creating parent directories if needed.
328pub fn save_baseline(image: &RgbaImage, baseline_path: &Path) -> Result<()> {
329    if let Some(parent) = baseline_path.parent() {
330        std::fs::create_dir_all(parent)
331            .map_err(|e| anyhow!("Failed to create baseline directory: {}", e))?;
332    }
333
334    image
335        .save(baseline_path)
336        .map_err(|e| anyhow!("Failed to save baseline image: {}", e))?;
337
338    Ok(())
339}
340
341/// Load an image from a file path.
342pub fn load_image(path: &Path) -> Result<RgbaImage> {
343    image::open(path)
344        .map_err(|e| anyhow!("Failed to load image from {}: {}", path.display(), e))
345        .map(|img| img.to_rgba8())
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    fn create_test_image(width: u32, height: u32, color: Rgba<u8>) -> RgbaImage {
353        let mut img = ImageBuffer::new(width, height);
354        for pixel in img.pixels_mut() {
355            *pixel = color;
356        }
357        img
358    }
359
360    #[test]
361    fn test_identical_images_match() {
362        let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
363        let img2 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
364
365        let comparison = compare_screenshots(&img1, &img2, 0);
366
367        assert_eq!(comparison.match_percentage, 1.0);
368        assert_eq!(comparison.diff_pixel_count, 0);
369        assert!(comparison.matches(0.0));
370    }
371
372    #[test]
373    fn test_different_images_dont_match() {
374        let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
375        let img2 = create_test_image(100, 100, Rgba([0, 255, 0, 255]));
376
377        let comparison = compare_screenshots(&img1, &img2, 0);
378
379        assert_eq!(comparison.match_percentage, 0.0);
380        assert_eq!(comparison.diff_pixel_count, 10000);
381        assert!(!comparison.matches(0.5));
382    }
383
384    #[test]
385    fn test_similar_images_match_with_threshold() {
386        let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
387        let img2 = create_test_image(100, 100, Rgba([250, 5, 0, 255]));
388
389        let comparison_strict = compare_screenshots(&img1, &img2, 0);
390        assert_eq!(comparison_strict.match_percentage, 0.0);
391
392        let comparison_lenient = compare_screenshots(&img1, &img2, 10);
393        assert_eq!(comparison_lenient.match_percentage, 1.0);
394    }
395
396    #[test]
397    fn test_different_size_images() {
398        let img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
399        let img2 = create_test_image(200, 200, Rgba([255, 0, 0, 255]));
400
401        let comparison = compare_screenshots(&img1, &img2, 0);
402
403        assert_eq!(comparison.match_percentage, 0.0);
404        assert!(comparison.diff_image.is_none());
405    }
406
407    #[test]
408    fn test_partial_difference() {
409        let mut img1 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
410        let img2 = create_test_image(100, 100, Rgba([255, 0, 0, 255]));
411
412        for x in 0..50 {
413            for y in 0..100 {
414                img1.put_pixel(x, y, Rgba([0, 255, 0, 255]));
415            }
416        }
417
418        let comparison = compare_screenshots(&img1, &img2, 0);
419
420        assert_eq!(comparison.match_percentage, 0.5);
421        assert_eq!(comparison.diff_pixel_count, 5000);
422        assert!(comparison.matches(0.5));
423        assert!(!comparison.matches(0.49));
424    }
425
426    #[test]
427    #[ignore]
428    fn test_visual_test_smoke() {
429        let mut cx = VisualTestAppContext::new(gpui_platform::current_platform(false));
430
431        let _window = cx
432            .open_offscreen_window_default(|_, cx| cx.new(|_| Empty))
433            .expect("Failed to open offscreen window");
434
435        cx.run_until_parked();
436    }
437
438    #[test]
439    #[ignore]
440    fn test_workspace_opens() {
441        let mut cx = VisualTestAppContext::new(gpui_platform::current_platform(false));
442        let app_state = init_visual_test(&mut cx);
443
444        gpui::block_on(async {
445            app_state
446                .fs
447                .as_fake()
448                .insert_tree(
449                    "/project",
450                    serde_json::json!({
451                        "src": {
452                            "main.rs": "fn main() {\n    println!(\"Hello, world!\");\n}\n"
453                        }
454                    }),
455                )
456                .await;
457        });
458
459        let workspace_result = gpui::block_on(open_test_workspace(app_state, &mut cx));
460        assert!(
461            workspace_result.is_ok(),
462            "Failed to open workspace: {:?}",
463            workspace_result.err()
464        );
465
466        cx.run_until_parked();
467    }
468
469    /// This test captures a screenshot of an empty Zed workspace.
470    ///
471    /// Note: This test is ignored by default because:
472    /// 1. It requires macOS with Screen Recording permission granted
473    /// 2. It must run on the main thread (standard test threads won't work)
474    /// 3. Screenshot capture may fail in CI environments without display access
475    ///
476    /// The test will gracefully handle screenshot failures and print an error
477    /// message rather than failing hard, to allow running in environments
478    /// where screen capture isn't available.
479    #[test]
480    #[ignore]
481    fn test_workspace_screenshot() {
482        let mut cx = VisualTestAppContext::new(gpui_platform::current_platform(false));
483        let app_state = init_visual_test(&mut cx);
484
485        gpui::block_on(async {
486            app_state
487                .fs
488                .as_fake()
489                .insert_tree(
490                    "/project",
491                    serde_json::json!({
492                        "src": {
493                            "main.rs": "fn main() {\n    println!(\"Hello, world!\");\n}\n"
494                        },
495                        "README.md": "# Test Project\n\nThis is a test project for visual testing.\n"
496                    }),
497                )
498                .await;
499        });
500
501        let workspace = gpui::block_on(open_test_workspace(app_state, &mut cx))
502            .expect("Failed to open workspace");
503
504        gpui::block_on(async {
505            wait_for_ui_stabilization(&cx).await;
506
507            let screenshot_result = cx.capture_screenshot(workspace.into());
508
509            match screenshot_result {
510                Ok(screenshot) => {
511                    println!(
512                        "Screenshot captured successfully: {}x{}",
513                        screenshot.width(),
514                        screenshot.height()
515                    );
516
517                    let output_dir = std::env::var("VISUAL_TEST_OUTPUT_DIR")
518                        .unwrap_or_else(|_| "target/visual_tests".to_string());
519                    let output_path = Path::new(&output_dir).join("workspace_screenshot.png");
520
521                    if let Err(e) = std::fs::create_dir_all(&output_dir) {
522                        eprintln!("Warning: Failed to create output directory: {}", e);
523                    }
524
525                    if let Err(e) = screenshot.save(&output_path) {
526                        eprintln!("Warning: Failed to save screenshot: {}", e);
527                    } else {
528                        println!("Screenshot saved to: {}", output_path.display());
529                    }
530
531                    assert!(
532                        screenshot.width() > 0,
533                        "Screenshot width should be positive"
534                    );
535                    assert!(
536                        screenshot.height() > 0,
537                        "Screenshot height should be positive"
538                    );
539                }
540                Err(e) => {
541                    eprintln!(
542                        "Screenshot capture failed (this may be expected in CI without screen recording permission): {}",
543                        e
544                    );
545                }
546            }
547        });
548
549        cx.run_until_parked();
550    }
551}
552
Served at tenant.openagents/omega Member data and write actions are omitted.