Skip to repository content552 lines · 18.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T06:43:26.670Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
visual_tests.rs
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