1// Allow blocking process commands in this binary - it's a synchronous test runner
2#![allow(clippy::disallowed_methods)]
3
4//! Visual Test Runner
5//!
6//! This binary runs visual regression tests for Zed's UI. It captures screenshots
7//! of real Zed windows and compares them against baseline images.
8//!
9//! **Note: This tool is macOS-only** because it uses `VisualTestAppContext` which
10//! depends on the macOS Metal renderer for accurate screenshot capture.
11//!
12//! ## How It Works
13//!
14//! This tool uses `VisualTestAppContext` which combines:
15//! - Real Metal/compositor rendering for accurate screenshots
16//! - Deterministic task scheduling via TestDispatcher
17//! - Controllable time via `advance_clock` for testing time-based behaviors
18//!
19//! This approach:
20//! - Does NOT require Screen Recording permission
21//! - Does NOT require the window to be visible on screen
22//! - Captures raw GPUI output without system window chrome
23//! - Is fully deterministic - tooltips, animations, etc. work reliably
24//!
25//! ## Usage
26//!
27//! Run the visual tests:
28//! cargo run -p zed --bin zed_visual_test_runner --features visual-tests
29//!
30//! Update baseline images (when UI intentionally changes):
31//! UPDATE_BASELINE=1 cargo run -p zed --bin zed_visual_test_runner --features visual-tests
32//!
33//! ## Environment Variables
34//!
35//! UPDATE_BASELINE - Set to update baseline images instead of comparing
36//! VISUAL_TEST_OUTPUT_DIR - Directory to save test output (default: target/visual_tests)
37
38// Stub main for non-macOS platforms
39#[cfg(not(target_os = "macos"))]
40fn main() {
41 eprintln!("Visual test runner is only supported on macOS");
42 std::process::exit(1);
43}
44
45// All macOS-specific imports grouped together
46#[cfg(target_os = "macos")]
47use {
48 acp_thread::{AgentConnection, StubAgentConnection},
49 agent_client_protocol as acp,
50 agent_servers::{AgentServer, AgentServerDelegate},
51 anyhow::{Context as _, Result},
52 assets::Assets,
53 editor::display_map::DisplayRow,
54 feature_flags::FeatureFlagAppExt as _,
55 git_ui::project_diff::ProjectDiff,
56 gpui::{
57 App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString, VisualTestAppContext,
58 WindowBounds, WindowHandle, WindowOptions, point, px, size,
59 },
60 image::RgbaImage,
61 project_panel::ProjectPanel,
62 settings::{NotifyWhenAgentWaiting, Settings as _},
63 std::{
64 any::Any,
65 path::{Path, PathBuf},
66 rc::Rc,
67 sync::Arc,
68 time::Duration,
69 },
70 watch,
71 workspace::{AppState, Workspace},
72};
73
74// All macOS-specific constants grouped together
75#[cfg(target_os = "macos")]
76mod constants {
77 use std::time::Duration;
78
79 /// Baseline images are stored relative to this file
80 pub const BASELINE_DIR: &str = "crates/zed/test_fixtures/visual_tests";
81
82 /// Embedded test image (Zed app icon) for visual tests.
83 pub const EMBEDDED_TEST_IMAGE: &[u8] = include_bytes!("../resources/app-icon.png");
84
85 /// Threshold for image comparison (0.0 to 1.0)
86 /// Images must match at least this percentage to pass
87 pub const MATCH_THRESHOLD: f64 = 0.99;
88
89 /// Tooltip show delay - must match TOOLTIP_SHOW_DELAY in gpui/src/elements/div.rs
90 pub const TOOLTIP_SHOW_DELAY: Duration = Duration::from_millis(500);
91}
92
93#[cfg(target_os = "macos")]
94use constants::*;
95
96#[cfg(target_os = "macos")]
97fn main() {
98 // Set ZED_STATELESS early to prevent file system access to real config directories
99 // This must be done before any code accesses zed_env_vars::ZED_STATELESS
100 // SAFETY: We're at the start of main(), before any threads are spawned
101 unsafe {
102 std::env::set_var("ZED_STATELESS", "1");
103 }
104
105 env_logger::builder()
106 .filter_level(log::LevelFilter::Info)
107 .init();
108
109 let update_baseline = std::env::var("UPDATE_BASELINE").is_ok();
110
111 // Create a temporary directory for test files
112 // Canonicalize the path to resolve symlinks (on macOS, /var -> /private/var)
113 // which prevents "path does not exist" errors during worktree scanning
114 // Use keep() to prevent auto-cleanup - background worktree tasks may still be running
115 // when tests complete, so we let the OS clean up temp directories on process exit
116 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
117 let temp_path = temp_dir.keep();
118 let canonical_temp = temp_path
119 .canonicalize()
120 .expect("Failed to canonicalize temp directory");
121 let project_path = canonical_temp.join("project");
122 std::fs::create_dir_all(&project_path).expect("Failed to create project directory");
123
124 // Create test files in the real filesystem
125 create_test_files(&project_path);
126
127 let test_result = std::panic::catch_unwind(|| run_visual_tests(project_path, update_baseline));
128
129 // Note: We don't delete temp_path here because background worktree tasks may still
130 // be running. The directory will be cleaned up when the process exits or by the OS.
131
132 match test_result {
133 Ok(Ok(())) => {}
134 Ok(Err(e)) => {
135 eprintln!("Visual tests failed: {}", e);
136 std::process::exit(1);
137 }
138 Err(_) => {
139 eprintln!("Visual tests panicked");
140 std::process::exit(1);
141 }
142 }
143}
144
145#[cfg(target_os = "macos")]
146fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> {
147 // Create the visual test context with deterministic task scheduling
148 // Use real Assets so that SVG icons render properly
149 let mut cx = VisualTestAppContext::with_asset_source(Arc::new(Assets));
150
151 // Load embedded fonts (IBM Plex Sans, Lilex, etc.) so UI renders with correct fonts
152 cx.update(|cx| {
153 Assets.load_fonts(cx).unwrap();
154 });
155
156 // Initialize settings store with real default settings (not test settings)
157 // Test settings use Courier font, but we want the real Zed fonts for visual tests
158 cx.update(|cx| {
159 settings::init(cx);
160 });
161
162 // Create AppState using the test initialization
163 let app_state = cx.update(|cx| init_app_state(cx));
164
165 // Initialize all Zed subsystems
166 cx.update(|cx| {
167 gpui_tokio::init(cx);
168 theme::init(theme::LoadThemes::JustBase, cx);
169 client::init(&app_state.client, cx);
170 audio::init(cx);
171 workspace::init(app_state.clone(), cx);
172 release_channel::init(semver::Version::new(0, 0, 0), cx);
173 command_palette::init(cx);
174 editor::init(cx);
175 call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
176 title_bar::init(cx);
177 project_panel::init(cx);
178 outline_panel::init(cx);
179 terminal_view::init(cx);
180 image_viewer::init(cx);
181 search::init(cx);
182 prompt_store::init(cx);
183 language_model::init(app_state.client.clone(), cx);
184 language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
185 git_ui::init(cx);
186
187 // Load default keymaps so tooltips can show keybindings like "f9" for ToggleBreakpoint
188 // We load a minimal set of editor keybindings needed for visual tests
189 cx.bind_keys([KeyBinding::new(
190 "f9",
191 editor::actions::ToggleBreakpoint,
192 Some("Editor"),
193 )]);
194
195 // Disable agent notifications during visual tests to avoid popup windows
196 agent_settings::AgentSettings::override_global(
197 agent_settings::AgentSettings {
198 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
199 play_sound_when_agent_done: false,
200 ..agent_settings::AgentSettings::get_global(cx).clone()
201 },
202 cx,
203 );
204 });
205
206 // Run until all initialization tasks complete
207 cx.run_until_parked();
208
209 // Open workspace window
210 let window_size = size(px(1280.0), px(800.0));
211 let bounds = Bounds {
212 origin: point(px(0.0), px(0.0)),
213 size: window_size,
214 };
215
216 // Create a project for the workspace
217 let project = cx.update(|cx| {
218 project::Project::local(
219 app_state.client.clone(),
220 app_state.node_runtime.clone(),
221 app_state.user_store.clone(),
222 app_state.languages.clone(),
223 app_state.fs.clone(),
224 None,
225 false,
226 cx,
227 )
228 });
229
230 let workspace_window: WindowHandle<Workspace> = cx
231 .update(|cx| {
232 cx.open_window(
233 WindowOptions {
234 window_bounds: Some(WindowBounds::Windowed(bounds)),
235 focus: false,
236 show: false,
237 ..Default::default()
238 },
239 |window, cx| {
240 cx.new(|cx| {
241 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
242 })
243 },
244 )
245 })
246 .context("Failed to open workspace window")?;
247
248 cx.run_until_parked();
249
250 // Add the test project as a worktree
251 let add_worktree_task = workspace_window
252 .update(&mut cx, |workspace, _window, cx| {
253 let project = workspace.project().clone();
254 project.update(cx, |project, cx| {
255 project.find_or_create_worktree(&project_path, true, cx)
256 })
257 })
258 .context("Failed to start adding worktree")?;
259
260 // Use block_test to wait for the worktree task
261 // block_test runs both foreground and background tasks, which is needed because
262 // worktree creation spawns foreground tasks via cx.spawn
263 // Allow parking since filesystem operations happen outside the test dispatcher
264 cx.background_executor.allow_parking();
265 let worktree_result = cx.foreground_executor.block_test(add_worktree_task);
266 cx.background_executor.forbid_parking();
267 worktree_result.context("Failed to add worktree")?;
268
269 cx.run_until_parked();
270
271 // Create and add the project panel
272 let (weak_workspace, async_window_cx) = workspace_window
273 .update(&mut cx, |workspace, window, cx| {
274 (workspace.weak_handle(), window.to_async(cx))
275 })
276 .context("Failed to get workspace handle")?;
277
278 cx.background_executor.allow_parking();
279 let panel = cx
280 .foreground_executor
281 .block_test(ProjectPanel::load(weak_workspace, async_window_cx))
282 .context("Failed to load project panel")?;
283 cx.background_executor.forbid_parking();
284
285 workspace_window
286 .update(&mut cx, |workspace, window, cx| {
287 workspace.add_panel(panel, window, cx);
288 })
289 .ok();
290
291 cx.run_until_parked();
292
293 // Open the project panel
294 workspace_window
295 .update(&mut cx, |workspace, window, cx| {
296 workspace.open_panel::<ProjectPanel>(window, cx);
297 })
298 .ok();
299
300 cx.run_until_parked();
301
302 // Open main.rs in the editor
303 let open_file_task = workspace_window
304 .update(&mut cx, |workspace, window, cx| {
305 let worktree = workspace.project().read(cx).worktrees(cx).next();
306 if let Some(worktree) = worktree {
307 let worktree_id = worktree.read(cx).id();
308 let rel_path: std::sync::Arc<util::rel_path::RelPath> =
309 util::rel_path::rel_path("src/main.rs").into();
310 let project_path: project::ProjectPath = (worktree_id, rel_path).into();
311 Some(workspace.open_path(project_path, None, true, window, cx))
312 } else {
313 None
314 }
315 })
316 .ok()
317 .flatten();
318
319 if let Some(task) = open_file_task {
320 cx.background_executor.allow_parking();
321 let block_result = cx.foreground_executor.block_test(task);
322 cx.background_executor.forbid_parking();
323 if let Ok(item) = block_result {
324 workspace_window
325 .update(&mut cx, |workspace, window, cx| {
326 let pane = workspace.active_pane().clone();
327 pane.update(cx, |pane, cx| {
328 if let Some(index) = pane.index_for_item(item.as_ref()) {
329 pane.activate_item(index, true, true, window, cx);
330 }
331 });
332 })
333 .ok();
334 }
335 }
336
337 cx.run_until_parked();
338
339 // Request a window refresh
340 cx.update_window(workspace_window.into(), |_, window, _cx| {
341 window.refresh();
342 })
343 .ok();
344
345 cx.run_until_parked();
346
347 // Track test results
348 let mut passed = 0;
349 let mut failed = 0;
350 let mut updated = 0;
351
352 // Run Test 1: Project Panel (with project panel visible)
353 println!("\n--- Test 1: project_panel ---");
354 match run_visual_test(
355 "project_panel",
356 workspace_window.into(),
357 &mut cx,
358 update_baseline,
359 ) {
360 Ok(TestResult::Passed) => {
361 println!("✓ project_panel: PASSED");
362 passed += 1;
363 }
364 Ok(TestResult::BaselineUpdated(_)) => {
365 println!("✓ project_panel: Baseline updated");
366 updated += 1;
367 }
368 Err(e) => {
369 eprintln!("✗ project_panel: FAILED - {}", e);
370 failed += 1;
371 }
372 }
373
374 // Run Test 2: Workspace with Editor
375 println!("\n--- Test 2: workspace_with_editor ---");
376
377 // Close project panel for this test
378 workspace_window
379 .update(&mut cx, |workspace, window, cx| {
380 workspace.close_panel::<ProjectPanel>(window, cx);
381 })
382 .ok();
383
384 cx.run_until_parked();
385
386 match run_visual_test(
387 "workspace_with_editor",
388 workspace_window.into(),
389 &mut cx,
390 update_baseline,
391 ) {
392 Ok(TestResult::Passed) => {
393 println!("✓ workspace_with_editor: PASSED");
394 passed += 1;
395 }
396 Ok(TestResult::BaselineUpdated(_)) => {
397 println!("✓ workspace_with_editor: Baseline updated");
398 updated += 1;
399 }
400 Err(e) => {
401 eprintln!("✗ workspace_with_editor: FAILED - {}", e);
402 failed += 1;
403 }
404 }
405
406 // Run Test 3: Agent Thread View tests
407 #[cfg(feature = "visual-tests")]
408 {
409 println!("\n--- Test 3: agent_thread_with_image (collapsed + expanded) ---");
410 match run_agent_thread_view_test(app_state.clone(), &mut cx, update_baseline) {
411 Ok(TestResult::Passed) => {
412 println!("✓ agent_thread_with_image (collapsed + expanded): PASSED");
413 passed += 1;
414 }
415 Ok(TestResult::BaselineUpdated(_)) => {
416 println!("✓ agent_thread_with_image: Baselines updated (collapsed + expanded)");
417 updated += 1;
418 }
419 Err(e) => {
420 eprintln!("✗ agent_thread_with_image: FAILED - {}", e);
421 failed += 1;
422 }
423 }
424 }
425
426 // Run Test 4: Subagent Cards visual tests
427 #[cfg(feature = "visual-tests")]
428 {
429 println!("\n--- Test 4: subagent_cards (running, completed, expanded) ---");
430 match run_subagent_visual_tests(app_state.clone(), &mut cx, update_baseline) {
431 Ok(TestResult::Passed) => {
432 println!("✓ subagent_cards: PASSED");
433 passed += 1;
434 }
435 Ok(TestResult::BaselineUpdated(_)) => {
436 println!("✓ subagent_cards: Baselines updated");
437 updated += 1;
438 }
439 Err(e) => {
440 eprintln!("✗ subagent_cards: FAILED - {}", e);
441 failed += 1;
442 }
443 }
444 }
445
446 // Run Test 5: Breakpoint Hover visual tests
447 println!("\n--- Test 5: breakpoint_hover (3 variants) ---");
448 match run_breakpoint_hover_visual_tests(app_state.clone(), &mut cx, update_baseline) {
449 Ok(TestResult::Passed) => {
450 println!("✓ breakpoint_hover: PASSED");
451 passed += 1;
452 }
453 Ok(TestResult::BaselineUpdated(_)) => {
454 println!("✓ breakpoint_hover: Baselines updated");
455 updated += 1;
456 }
457 Err(e) => {
458 eprintln!("✗ breakpoint_hover: FAILED - {}", e);
459 failed += 1;
460 }
461 }
462
463 // Run Test 6: Diff Review Button visual tests
464 println!("\n--- Test 6: diff_review_button (3 variants) ---");
465 match run_diff_review_visual_tests(app_state.clone(), &mut cx, update_baseline) {
466 Ok(TestResult::Passed) => {
467 println!("✓ diff_review_button: PASSED");
468 passed += 1;
469 }
470 Ok(TestResult::BaselineUpdated(_)) => {
471 println!("✓ diff_review_button: Baselines updated");
472 updated += 1;
473 }
474 Err(e) => {
475 eprintln!("✗ diff_review_button: FAILED - {}", e);
476 failed += 1;
477 }
478 }
479
480 // Clean up the main workspace's worktree to stop background scanning tasks
481 // This prevents "root path could not be canonicalized" errors when main() drops temp_dir
482 workspace_window
483 .update(&mut cx, |workspace, _window, cx| {
484 let project = workspace.project().clone();
485 project.update(cx, |project, cx| {
486 let worktree_ids: Vec<_> =
487 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
488 for id in worktree_ids {
489 project.remove_worktree(id, cx);
490 }
491 });
492 })
493 .ok();
494
495 cx.run_until_parked();
496
497 // Close the main window
498 let _ = cx.update_window(workspace_window.into(), |_, window, _cx| {
499 window.remove_window();
500 });
501
502 // Run until all cleanup tasks complete
503 cx.run_until_parked();
504
505 // Give background tasks time to finish, including scrollbar hide timers (1 second)
506 for _ in 0..15 {
507 cx.advance_clock(Duration::from_millis(100));
508 cx.run_until_parked();
509 }
510
511 // Print summary
512 println!("\n=== Test Summary ===");
513 println!("Passed: {}", passed);
514 println!("Failed: {}", failed);
515 if updated > 0 {
516 println!("Baselines Updated: {}", updated);
517 }
518
519 if failed > 0 {
520 eprintln!("\n=== Visual Tests FAILED ===");
521 Err(anyhow::anyhow!("{} tests failed", failed))
522 } else {
523 println!("\n=== All Visual Tests PASSED ===");
524 Ok(())
525 }
526}
527
528#[cfg(target_os = "macos")]
529enum TestResult {
530 Passed,
531 BaselineUpdated(PathBuf),
532}
533
534#[cfg(target_os = "macos")]
535fn run_visual_test(
536 test_name: &str,
537 window: gpui::AnyWindowHandle,
538 cx: &mut VisualTestAppContext,
539 update_baseline: bool,
540) -> Result<TestResult> {
541 // Ensure all pending work is done
542 cx.run_until_parked();
543
544 // Refresh the window to ensure it's fully rendered
545 cx.update_window(window, |_, window, _cx| {
546 window.refresh();
547 })?;
548
549 cx.run_until_parked();
550
551 // Capture the screenshot using direct texture capture
552 let screenshot = cx.capture_screenshot(window)?;
553
554 // Get paths
555 let baseline_path = get_baseline_path(test_name);
556 let output_dir = std::env::var("VISUAL_TEST_OUTPUT_DIR")
557 .unwrap_or_else(|_| "target/visual_tests".to_string());
558 let output_path = PathBuf::from(&output_dir).join(format!("{}.png", test_name));
559
560 // Ensure output directory exists
561 std::fs::create_dir_all(&output_dir)?;
562
563 // Always save the current screenshot
564 screenshot.save(&output_path)?;
565 println!(" Screenshot saved to: {}", output_path.display());
566
567 if update_baseline {
568 // Update the baseline
569 if let Some(parent) = baseline_path.parent() {
570 std::fs::create_dir_all(parent)?;
571 }
572 screenshot.save(&baseline_path)?;
573 println!(" Baseline updated: {}", baseline_path.display());
574 return Ok(TestResult::BaselineUpdated(baseline_path));
575 }
576
577 // Compare with baseline
578 if !baseline_path.exists() {
579 return Err(anyhow::anyhow!(
580 "Baseline not found: {}. Run with UPDATE_BASELINE=1 to create it.",
581 baseline_path.display()
582 ));
583 }
584
585 let baseline = image::open(&baseline_path)?.to_rgba8();
586 let comparison = compare_images(&screenshot, &baseline);
587
588 println!(
589 " Match: {:.2}% ({} different pixels)",
590 comparison.match_percentage * 100.0,
591 comparison.diff_pixel_count
592 );
593
594 if comparison.match_percentage >= MATCH_THRESHOLD {
595 Ok(TestResult::Passed)
596 } else {
597 // Save diff image
598 let diff_path = PathBuf::from(&output_dir).join(format!("{}_diff.png", test_name));
599 comparison.diff_image.save(&diff_path)?;
600 println!(" Diff image saved to: {}", diff_path.display());
601
602 Err(anyhow::anyhow!(
603 "Image mismatch: {:.2}% match (threshold: {:.2}%)",
604 comparison.match_percentage * 100.0,
605 MATCH_THRESHOLD * 100.0
606 ))
607 }
608}
609
610#[cfg(target_os = "macos")]
611fn get_baseline_path(test_name: &str) -> PathBuf {
612 // Get the workspace root (where Cargo.toml is)
613 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
614 let workspace_root = PathBuf::from(manifest_dir)
615 .parent()
616 .and_then(|p| p.parent())
617 .map(|p| p.to_path_buf())
618 .unwrap_or_else(|| PathBuf::from("."));
619
620 workspace_root
621 .join(BASELINE_DIR)
622 .join(format!("{}.png", test_name))
623}
624
625#[cfg(target_os = "macos")]
626struct ImageComparison {
627 match_percentage: f64,
628 diff_image: RgbaImage,
629 diff_pixel_count: u32,
630 #[allow(dead_code)]
631 total_pixels: u32,
632}
633
634#[cfg(target_os = "macos")]
635fn compare_images(actual: &RgbaImage, expected: &RgbaImage) -> ImageComparison {
636 let width = actual.width().max(expected.width());
637 let height = actual.height().max(expected.height());
638 let total_pixels = width * height;
639
640 let mut diff_image = RgbaImage::new(width, height);
641 let mut matching_pixels = 0u32;
642
643 for y in 0..height {
644 for x in 0..width {
645 let actual_pixel = if x < actual.width() && y < actual.height() {
646 *actual.get_pixel(x, y)
647 } else {
648 image::Rgba([0, 0, 0, 0])
649 };
650
651 let expected_pixel = if x < expected.width() && y < expected.height() {
652 *expected.get_pixel(x, y)
653 } else {
654 image::Rgba([0, 0, 0, 0])
655 };
656
657 if pixels_are_similar(&actual_pixel, &expected_pixel) {
658 matching_pixels += 1;
659 // Semi-transparent green for matching pixels
660 diff_image.put_pixel(x, y, image::Rgba([0, 255, 0, 64]));
661 } else {
662 // Bright red for differing pixels
663 diff_image.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
664 }
665 }
666 }
667
668 let match_percentage = matching_pixels as f64 / total_pixels as f64;
669 let diff_pixel_count = total_pixels - matching_pixels;
670
671 ImageComparison {
672 match_percentage,
673 diff_image,
674 diff_pixel_count,
675 total_pixels,
676 }
677}
678
679#[cfg(target_os = "macos")]
680fn pixels_are_similar(a: &image::Rgba<u8>, b: &image::Rgba<u8>) -> bool {
681 const TOLERANCE: i16 = 2;
682 (a.0[0] as i16 - b.0[0] as i16).abs() <= TOLERANCE
683 && (a.0[1] as i16 - b.0[1] as i16).abs() <= TOLERANCE
684 && (a.0[2] as i16 - b.0[2] as i16).abs() <= TOLERANCE
685 && (a.0[3] as i16 - b.0[3] as i16).abs() <= TOLERANCE
686}
687
688#[cfg(target_os = "macos")]
689fn create_test_files(project_path: &Path) {
690 // Create src directory
691 let src_dir = project_path.join("src");
692 std::fs::create_dir_all(&src_dir).expect("Failed to create src directory");
693
694 // Create main.rs
695 let main_rs = r#"fn main() {
696 println!("Hello, world!");
697
698 let x = 42;
699 let y = x * 2;
700
701 if y > 50 {
702 println!("y is greater than 50");
703 } else {
704 println!("y is not greater than 50");
705 }
706
707 for i in 0..10 {
708 println!("i = {}", i);
709 }
710}
711
712fn helper_function(a: i32, b: i32) -> i32 {
713 a + b
714}
715
716struct MyStruct {
717 field1: String,
718 field2: i32,
719}
720
721impl MyStruct {
722 fn new(name: &str, value: i32) -> Self {
723 Self {
724 field1: name.to_string(),
725 field2: value,
726 }
727 }
728
729 fn get_value(&self) -> i32 {
730 self.field2
731 }
732}
733"#;
734 std::fs::write(src_dir.join("main.rs"), main_rs).expect("Failed to write main.rs");
735
736 // Create lib.rs
737 let lib_rs = r#"//! A sample library for visual testing
738
739pub mod utils;
740
741/// A public function in the library
742pub fn library_function() -> String {
743 "Hello from lib".to_string()
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749
750 #[test]
751 fn it_works() {
752 assert_eq!(library_function(), "Hello from lib");
753 }
754}
755"#;
756 std::fs::write(src_dir.join("lib.rs"), lib_rs).expect("Failed to write lib.rs");
757
758 // Create utils.rs
759 let utils_rs = r#"//! Utility functions
760
761/// Format a number with commas
762pub fn format_number(n: u64) -> String {
763 let s = n.to_string();
764 let mut result = String::new();
765 for (i, c) in s.chars().rev().enumerate() {
766 if i > 0 && i % 3 == 0 {
767 result.push(',');
768 }
769 result.push(c);
770 }
771 result.chars().rev().collect()
772}
773
774/// Calculate fibonacci number
775pub fn fibonacci(n: u32) -> u64 {
776 match n {
777 0 => 0,
778 1 => 1,
779 _ => fibonacci(n - 1) + fibonacci(n - 2),
780 }
781}
782"#;
783 std::fs::write(src_dir.join("utils.rs"), utils_rs).expect("Failed to write utils.rs");
784
785 // Create Cargo.toml
786 let cargo_toml = r#"[package]
787name = "test_project"
788version = "0.1.0"
789edition = "2021"
790
791[dependencies]
792"#;
793 std::fs::write(project_path.join("Cargo.toml"), cargo_toml)
794 .expect("Failed to write Cargo.toml");
795
796 // Create README.md
797 let readme = r#"# Test Project
798
799This is a test project for visual testing of Zed.
800
801## Features
802
803- Feature 1
804- Feature 2
805- Feature 3
806
807## Usage
808
809```bash
810cargo run
811```
812"#;
813 std::fs::write(project_path.join("README.md"), readme).expect("Failed to write README.md");
814}
815
816#[cfg(target_os = "macos")]
817fn init_app_state(cx: &mut App) -> Arc<AppState> {
818 use fs::Fs;
819 use node_runtime::NodeRuntime;
820 use session::Session;
821 use settings::SettingsStore;
822
823 if !cx.has_global::<SettingsStore>() {
824 let settings_store = SettingsStore::test(cx);
825 cx.set_global(settings_store);
826 }
827
828 // Use the real filesystem instead of FakeFs so we can access actual files on disk
829 let fs: Arc<dyn Fs> = Arc::new(fs::RealFs::new(None, cx.background_executor().clone()));
830 <dyn Fs>::set_global(fs.clone(), cx);
831
832 let languages = Arc::new(language::LanguageRegistry::test(
833 cx.background_executor().clone(),
834 ));
835 let clock = Arc::new(clock::FakeSystemClock::new());
836 let http_client = http_client::FakeHttpClient::with_404_response();
837 let client = client::Client::new(clock, http_client, cx);
838 let session = cx.new(|cx| session::AppSession::new(Session::test(), cx));
839 let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx));
840 let workspace_store = cx.new(|cx| workspace::WorkspaceStore::new(client.clone(), cx));
841
842 theme::init(theme::LoadThemes::JustBase, cx);
843 client::init(&client, cx);
844
845 Arc::new(AppState {
846 client,
847 fs,
848 languages,
849 user_store,
850 workspace_store,
851 node_runtime: NodeRuntime::unavailable(),
852 build_window_options: |_, _| Default::default(),
853 session,
854 })
855}
856
857/// Runs visual tests for breakpoint hover states in the editor gutter.
858///
859/// This test captures three states:
860/// 1. Gutter with line numbers, no breakpoint hover (baseline)
861/// 2. Gutter with breakpoint hover indicator (gray circle)
862/// 3. Gutter with breakpoint hover AND tooltip
863#[cfg(target_os = "macos")]
864fn run_breakpoint_hover_visual_tests(
865 app_state: Arc<AppState>,
866 cx: &mut VisualTestAppContext,
867 update_baseline: bool,
868) -> Result<TestResult> {
869 // Create a temporary directory with a simple test file
870 let temp_dir = tempfile::tempdir()?;
871 let temp_path = temp_dir.keep();
872 let canonical_temp = temp_path.canonicalize()?;
873 let project_path = canonical_temp.join("project");
874 std::fs::create_dir_all(&project_path)?;
875
876 // Create a simple file with a few lines
877 let src_dir = project_path.join("src");
878 std::fs::create_dir_all(&src_dir)?;
879
880 let test_content = r#"fn main() {
881 println!("Hello");
882 let x = 42;
883}
884"#;
885 std::fs::write(src_dir.join("test.rs"), test_content)?;
886
887 // Create a small window - just big enough to show gutter and a few lines
888 let window_size = size(px(300.0), px(200.0));
889 let bounds = Bounds {
890 origin: point(px(0.0), px(0.0)),
891 size: window_size,
892 };
893
894 // Create project
895 let project = cx.update(|cx| {
896 project::Project::local(
897 app_state.client.clone(),
898 app_state.node_runtime.clone(),
899 app_state.user_store.clone(),
900 app_state.languages.clone(),
901 app_state.fs.clone(),
902 None,
903 false,
904 cx,
905 )
906 });
907
908 // Open workspace window
909 let workspace_window: WindowHandle<Workspace> = cx
910 .update(|cx| {
911 cx.open_window(
912 WindowOptions {
913 window_bounds: Some(WindowBounds::Windowed(bounds)),
914 focus: false,
915 show: false,
916 ..Default::default()
917 },
918 |window, cx| {
919 cx.new(|cx| {
920 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
921 })
922 },
923 )
924 })
925 .context("Failed to open breakpoint test window")?;
926
927 cx.run_until_parked();
928
929 // Add the project as a worktree
930 let add_worktree_task = workspace_window
931 .update(cx, |workspace, _window, cx| {
932 let project = workspace.project().clone();
933 project.update(cx, |project, cx| {
934 project.find_or_create_worktree(&project_path, true, cx)
935 })
936 })
937 .context("Failed to start adding worktree")?;
938
939 cx.background_executor.allow_parking();
940 let worktree_result = cx.foreground_executor.block_test(add_worktree_task);
941 cx.background_executor.forbid_parking();
942 worktree_result.context("Failed to add worktree")?;
943
944 cx.run_until_parked();
945
946 // Open the test file
947 let open_file_task = workspace_window
948 .update(cx, |workspace, window, cx| {
949 let worktree = workspace.project().read(cx).worktrees(cx).next();
950 if let Some(worktree) = worktree {
951 let worktree_id = worktree.read(cx).id();
952 let rel_path: std::sync::Arc<util::rel_path::RelPath> =
953 util::rel_path::rel_path("src/test.rs").into();
954 let project_path: project::ProjectPath = (worktree_id, rel_path).into();
955 Some(workspace.open_path(project_path, None, true, window, cx))
956 } else {
957 None
958 }
959 })
960 .ok()
961 .flatten();
962
963 if let Some(task) = open_file_task {
964 cx.background_executor.allow_parking();
965 let _ = cx.foreground_executor.block_test(task);
966 cx.background_executor.forbid_parking();
967 }
968
969 cx.run_until_parked();
970
971 // Wait for the editor to fully load
972 for _ in 0..10 {
973 cx.advance_clock(Duration::from_millis(100));
974 cx.run_until_parked();
975 }
976
977 // Refresh window
978 cx.update_window(workspace_window.into(), |_, window, _cx| {
979 window.refresh();
980 })?;
981
982 cx.run_until_parked();
983
984 // Test 1: Gutter visible with line numbers, no breakpoint hover
985 let test1_result = run_visual_test(
986 "breakpoint_hover_none",
987 workspace_window.into(),
988 cx,
989 update_baseline,
990 )?;
991
992 // Test 2: Breakpoint hover indicator (circle) visible
993 // The gutter is on the left side. We need to position the mouse over the gutter area
994 // for line 1. The breakpoint indicator appears in the leftmost part of the gutter.
995 //
996 // The breakpoint hover requires multiple steps:
997 // 1. Draw to register mouse listeners
998 // 2. Mouse move to trigger gutter_hovered and create PhantomBreakpointIndicator
999 // 3. Wait 200ms for is_active to become true
1000 // 4. Draw again to render the indicator
1001 //
1002 // The gutter_position should be in the gutter area to trigger the phantom breakpoint.
1003 // The button_position should be directly over the breakpoint icon button for tooltip hover.
1004 // Based on debug output: button is at origin=(3.12, 66.5) with size=(14, 16)
1005 let gutter_position = point(px(30.0), px(85.0));
1006 let button_position = point(px(10.0), px(75.0)); // Center of the breakpoint button
1007
1008 // Step 1: Initial draw to register mouse listeners
1009 cx.update_window(workspace_window.into(), |_, window, cx| {
1010 window.draw(cx).clear();
1011 })?;
1012 cx.run_until_parked();
1013
1014 // Step 2: Simulate mouse move into gutter area
1015 cx.simulate_mouse_move(
1016 workspace_window.into(),
1017 gutter_position,
1018 None,
1019 Modifiers::default(),
1020 );
1021
1022 // Step 3: Advance clock past 200ms debounce
1023 cx.advance_clock(Duration::from_millis(300));
1024 cx.run_until_parked();
1025
1026 // Step 4: Draw again to pick up the indicator state change
1027 cx.update_window(workspace_window.into(), |_, window, cx| {
1028 window.draw(cx).clear();
1029 })?;
1030 cx.run_until_parked();
1031
1032 // Step 5: Another mouse move to keep hover state active
1033 cx.simulate_mouse_move(
1034 workspace_window.into(),
1035 gutter_position,
1036 None,
1037 Modifiers::default(),
1038 );
1039
1040 // Step 6: Final draw
1041 cx.update_window(workspace_window.into(), |_, window, cx| {
1042 window.draw(cx).clear();
1043 })?;
1044 cx.run_until_parked();
1045
1046 let test2_result = run_visual_test(
1047 "breakpoint_hover_circle",
1048 workspace_window.into(),
1049 cx,
1050 update_baseline,
1051 )?;
1052
1053 // Test 3: Breakpoint hover with tooltip visible
1054 // The tooltip delay is 500ms (TOOLTIP_SHOW_DELAY constant)
1055 // We need to position the mouse directly over the breakpoint button for the tooltip to show.
1056 // The button hitbox is approximately at (3.12, 66.5) with size (14, 16).
1057
1058 // Move mouse directly over the button to trigger tooltip hover
1059 cx.simulate_mouse_move(
1060 workspace_window.into(),
1061 button_position,
1062 None,
1063 Modifiers::default(),
1064 );
1065
1066 // Draw to register the button's tooltip hover listener
1067 cx.update_window(workspace_window.into(), |_, window, cx| {
1068 window.draw(cx).clear();
1069 })?;
1070 cx.run_until_parked();
1071
1072 // Move mouse over button again to trigger tooltip scheduling
1073 cx.simulate_mouse_move(
1074 workspace_window.into(),
1075 button_position,
1076 None,
1077 Modifiers::default(),
1078 );
1079
1080 // Advance clock past TOOLTIP_SHOW_DELAY (500ms)
1081 cx.advance_clock(TOOLTIP_SHOW_DELAY + Duration::from_millis(100));
1082 cx.run_until_parked();
1083
1084 // Draw to render the tooltip
1085 cx.update_window(workspace_window.into(), |_, window, cx| {
1086 window.draw(cx).clear();
1087 })?;
1088 cx.run_until_parked();
1089
1090 // Refresh window
1091 cx.update_window(workspace_window.into(), |_, window, _cx| {
1092 window.refresh();
1093 })?;
1094
1095 cx.run_until_parked();
1096
1097 let test3_result = run_visual_test(
1098 "breakpoint_hover_tooltip",
1099 workspace_window.into(),
1100 cx,
1101 update_baseline,
1102 )?;
1103
1104 // Clean up: remove worktrees to stop background scanning
1105 workspace_window
1106 .update(cx, |workspace, _window, cx| {
1107 let project = workspace.project().clone();
1108 project.update(cx, |project, cx| {
1109 let worktree_ids: Vec<_> =
1110 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
1111 for id in worktree_ids {
1112 project.remove_worktree(id, cx);
1113 }
1114 });
1115 })
1116 .ok();
1117
1118 cx.run_until_parked();
1119
1120 // Close the window
1121 let _ = cx.update_window(workspace_window.into(), |_, window, _cx| {
1122 window.remove_window();
1123 });
1124
1125 cx.run_until_parked();
1126
1127 // Give background tasks time to finish
1128 for _ in 0..15 {
1129 cx.advance_clock(Duration::from_millis(100));
1130 cx.run_until_parked();
1131 }
1132
1133 // Return combined result
1134 match (&test1_result, &test2_result, &test3_result) {
1135 (TestResult::Passed, TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
1136 (TestResult::BaselineUpdated(p), _, _)
1137 | (_, TestResult::BaselineUpdated(p), _)
1138 | (_, _, TestResult::BaselineUpdated(p)) => Ok(TestResult::BaselineUpdated(p.clone())),
1139 }
1140}
1141
1142/// Runs visual tests for the diff review button in git diff views.
1143///
1144/// This test captures three states:
1145/// 1. Diff view with feature flag enabled (button visible)
1146/// 2. Diff view with feature flag disabled (no button)
1147/// 3. Regular editor with feature flag enabled (no button - only shows in diff views)
1148#[cfg(target_os = "macos")]
1149fn run_diff_review_visual_tests(
1150 app_state: Arc<AppState>,
1151 cx: &mut VisualTestAppContext,
1152 update_baseline: bool,
1153) -> Result<TestResult> {
1154 // Create a temporary directory with test files and a real git repo
1155 let temp_dir = tempfile::tempdir()?;
1156 let temp_path = temp_dir.keep();
1157 let canonical_temp = temp_path.canonicalize()?;
1158 let project_path = canonical_temp.join("project");
1159 std::fs::create_dir_all(&project_path)?;
1160
1161 // Initialize a real git repository
1162 std::process::Command::new("git")
1163 .args(["init"])
1164 .current_dir(&project_path)
1165 .output()?;
1166
1167 // Configure git user for commits
1168 std::process::Command::new("git")
1169 .args(["config", "user.email", "test@test.com"])
1170 .current_dir(&project_path)
1171 .output()?;
1172 std::process::Command::new("git")
1173 .args(["config", "user.name", "Test User"])
1174 .current_dir(&project_path)
1175 .output()?;
1176
1177 // Create a test file with original content
1178 let original_content = "// Original content\n";
1179 std::fs::write(project_path.join("thread-view.tsx"), original_content)?;
1180
1181 // Commit the original file
1182 std::process::Command::new("git")
1183 .args(["add", "thread-view.tsx"])
1184 .current_dir(&project_path)
1185 .output()?;
1186 std::process::Command::new("git")
1187 .args(["commit", "-m", "Initial commit"])
1188 .current_dir(&project_path)
1189 .output()?;
1190
1191 // Modify the file to create a diff
1192 let modified_content = r#"import { ScrollArea } from 'components';
1193import { ButtonAlt, Tooltip } from 'ui';
1194import { Message, FileEdit } from 'types';
1195import { AiPaneTabContext } from 'context';
1196"#;
1197 std::fs::write(project_path.join("thread-view.tsx"), modified_content)?;
1198
1199 // Create window for the diff view - sized to show just the editor
1200 let window_size = size(px(600.0), px(400.0));
1201 let bounds = Bounds {
1202 origin: point(px(0.0), px(0.0)),
1203 size: window_size,
1204 };
1205
1206 // Create project
1207 let project = cx.update(|cx| {
1208 project::Project::local(
1209 app_state.client.clone(),
1210 app_state.node_runtime.clone(),
1211 app_state.user_store.clone(),
1212 app_state.languages.clone(),
1213 app_state.fs.clone(),
1214 None,
1215 false,
1216 cx,
1217 )
1218 });
1219
1220 // Add the test directory as a worktree
1221 let add_worktree_task = project.update(cx, |project, cx| {
1222 project.find_or_create_worktree(&project_path, true, cx)
1223 });
1224
1225 cx.background_executor.allow_parking();
1226 let _ = cx.foreground_executor.block_test(add_worktree_task);
1227 cx.background_executor.forbid_parking();
1228
1229 cx.run_until_parked();
1230
1231 // Wait for worktree to be fully scanned and git status to be detected
1232 for _ in 0..5 {
1233 cx.advance_clock(Duration::from_millis(100));
1234 cx.run_until_parked();
1235 }
1236
1237 // Test 1: Diff view with feature flag enabled
1238 // Enable the feature flag
1239 cx.update(|cx| {
1240 cx.update_flags(true, vec!["diff-review".to_string()]);
1241 });
1242
1243 let workspace_window: WindowHandle<Workspace> = cx
1244 .update(|cx| {
1245 cx.open_window(
1246 WindowOptions {
1247 window_bounds: Some(WindowBounds::Windowed(bounds)),
1248 focus: false,
1249 show: false,
1250 ..Default::default()
1251 },
1252 |window, cx| {
1253 cx.new(|cx| {
1254 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
1255 })
1256 },
1257 )
1258 })
1259 .context("Failed to open diff review test window")?;
1260
1261 cx.run_until_parked();
1262
1263 // Create and add the ProjectDiff using the public deploy_at method
1264 workspace_window
1265 .update(cx, |workspace, window, cx| {
1266 ProjectDiff::deploy_at(workspace, None, window, cx);
1267 })
1268 .ok();
1269
1270 // Wait for diff to render
1271 for _ in 0..5 {
1272 cx.advance_clock(Duration::from_millis(100));
1273 cx.run_until_parked();
1274 }
1275
1276 // Refresh window
1277 cx.update_window(workspace_window.into(), |_, window, _cx| {
1278 window.refresh();
1279 })?;
1280
1281 cx.run_until_parked();
1282
1283 // Capture Test 1: Diff with flag enabled
1284 let test1_result = run_visual_test(
1285 "diff_review_button_enabled",
1286 workspace_window.into(),
1287 cx,
1288 update_baseline,
1289 )?;
1290
1291 // Test 2: Diff view with feature flag disabled
1292 // Disable the feature flag
1293 cx.update(|cx| {
1294 cx.update_flags(false, vec![]);
1295 });
1296
1297 // Refresh window
1298 cx.update_window(workspace_window.into(), |_, window, _cx| {
1299 window.refresh();
1300 })?;
1301
1302 for _ in 0..3 {
1303 cx.advance_clock(Duration::from_millis(100));
1304 cx.run_until_parked();
1305 }
1306
1307 // Capture Test 2: Diff with flag disabled
1308 let test2_result = run_visual_test(
1309 "diff_review_button_disabled",
1310 workspace_window.into(),
1311 cx,
1312 update_baseline,
1313 )?;
1314
1315 // Test 3: Regular editor with flag enabled (should NOT show button)
1316 // Re-enable the feature flag
1317 cx.update(|cx| {
1318 cx.update_flags(true, vec!["diff-review".to_string()]);
1319 });
1320
1321 // Create a new window with just a regular editor
1322 let regular_window: WindowHandle<Workspace> = cx
1323 .update(|cx| {
1324 cx.open_window(
1325 WindowOptions {
1326 window_bounds: Some(WindowBounds::Windowed(bounds)),
1327 focus: false,
1328 show: false,
1329 ..Default::default()
1330 },
1331 |window, cx| {
1332 cx.new(|cx| {
1333 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
1334 })
1335 },
1336 )
1337 })
1338 .context("Failed to open regular editor window")?;
1339
1340 cx.run_until_parked();
1341
1342 // Open a regular file (not a diff view)
1343 let open_file_task = regular_window
1344 .update(cx, |workspace, window, cx| {
1345 let worktree = workspace.project().read(cx).worktrees(cx).next();
1346 if let Some(worktree) = worktree {
1347 let worktree_id = worktree.read(cx).id();
1348 let rel_path: std::sync::Arc<util::rel_path::RelPath> =
1349 util::rel_path::rel_path("thread-view.tsx").into();
1350 let project_path: project::ProjectPath = (worktree_id, rel_path).into();
1351 Some(workspace.open_path(project_path, None, true, window, cx))
1352 } else {
1353 None
1354 }
1355 })
1356 .ok()
1357 .flatten();
1358
1359 if let Some(task) = open_file_task {
1360 cx.background_executor.allow_parking();
1361 let _ = cx.foreground_executor.block_test(task);
1362 cx.background_executor.forbid_parking();
1363 }
1364
1365 // Wait for file to open
1366 for _ in 0..3 {
1367 cx.advance_clock(Duration::from_millis(100));
1368 cx.run_until_parked();
1369 }
1370
1371 // Refresh window
1372 cx.update_window(regular_window.into(), |_, window, _cx| {
1373 window.refresh();
1374 })?;
1375
1376 cx.run_until_parked();
1377
1378 // Capture Test 3: Regular editor with flag enabled (no button)
1379 let test3_result = run_visual_test(
1380 "diff_review_button_regular_editor",
1381 regular_window.into(),
1382 cx,
1383 update_baseline,
1384 )?;
1385
1386 // Test 4: Show the diff review overlay on the regular editor
1387 regular_window
1388 .update(cx, |workspace, window, cx| {
1389 // Get the first editor from the workspace
1390 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1391 if let Some(editor) = editors.into_iter().next() {
1392 editor.update(cx, |editor, cx| {
1393 editor.show_diff_review_overlay(DisplayRow(1), window, cx);
1394 });
1395 }
1396 })
1397 .ok();
1398
1399 // Wait for overlay to render
1400 for _ in 0..3 {
1401 cx.advance_clock(Duration::from_millis(100));
1402 cx.run_until_parked();
1403 }
1404
1405 // Refresh window
1406 cx.update_window(regular_window.into(), |_, window, _cx| {
1407 window.refresh();
1408 })?;
1409
1410 cx.run_until_parked();
1411
1412 // Capture Test 4: Regular editor with overlay shown
1413 let test4_result = run_visual_test(
1414 "diff_review_overlay_shown",
1415 regular_window.into(),
1416 cx,
1417 update_baseline,
1418 )?;
1419
1420 // Test 5: Type text into the diff review prompt and submit it
1421 // First, get the prompt editor from the overlay and type some text
1422 regular_window
1423 .update(cx, |workspace, window, cx| {
1424 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1425 if let Some(editor) = editors.into_iter().next() {
1426 editor.update(cx, |editor, cx| {
1427 // Get the prompt editor from the overlay and insert text
1428 if let Some(prompt_editor) = editor.diff_review_prompt_editor().cloned() {
1429 prompt_editor.update(cx, |prompt_editor: &mut editor::Editor, cx| {
1430 prompt_editor.insert(
1431 "This change needs better error handling",
1432 window,
1433 cx,
1434 );
1435 });
1436 }
1437 });
1438 }
1439 })
1440 .ok();
1441
1442 // Wait for text to be inserted
1443 for _ in 0..3 {
1444 cx.advance_clock(Duration::from_millis(100));
1445 cx.run_until_parked();
1446 }
1447
1448 // Refresh window
1449 cx.update_window(regular_window.into(), |_, window, _cx| {
1450 window.refresh();
1451 })?;
1452
1453 cx.run_until_parked();
1454
1455 // Capture Test 5: Diff review overlay with typed text
1456 let test5_result = run_visual_test(
1457 "diff_review_overlay_with_text",
1458 regular_window.into(),
1459 cx,
1460 update_baseline,
1461 )?;
1462
1463 // Test 6: Submit a comment to store it locally
1464 regular_window
1465 .update(cx, |workspace, window, cx| {
1466 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1467 if let Some(editor) = editors.into_iter().next() {
1468 editor.update(cx, |editor, cx| {
1469 // Submit the comment that was typed in test 5
1470 editor.submit_diff_review_comment(window, cx);
1471 });
1472 }
1473 })
1474 .ok();
1475
1476 // Wait for comment to be stored
1477 for _ in 0..3 {
1478 cx.advance_clock(Duration::from_millis(100));
1479 cx.run_until_parked();
1480 }
1481
1482 // Refresh window
1483 cx.update_window(regular_window.into(), |_, window, _cx| {
1484 window.refresh();
1485 })?;
1486
1487 cx.run_until_parked();
1488
1489 // Capture Test 6: Overlay with one stored comment
1490 let test6_result = run_visual_test(
1491 "diff_review_one_comment",
1492 regular_window.into(),
1493 cx,
1494 update_baseline,
1495 )?;
1496
1497 // Test 7: Add more comments to show multiple comments expanded
1498 regular_window
1499 .update(cx, |workspace, window, cx| {
1500 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1501 if let Some(editor) = editors.into_iter().next() {
1502 editor.update(cx, |editor, cx| {
1503 // Add second comment
1504 if let Some(prompt_editor) = editor.diff_review_prompt_editor().cloned() {
1505 prompt_editor.update(cx, |pe, cx| {
1506 pe.insert("Second comment about imports", window, cx);
1507 });
1508 }
1509 editor.submit_diff_review_comment(window, cx);
1510
1511 // Add third comment
1512 if let Some(prompt_editor) = editor.diff_review_prompt_editor().cloned() {
1513 prompt_editor.update(cx, |pe, cx| {
1514 pe.insert("Third comment about naming conventions", window, cx);
1515 });
1516 }
1517 editor.submit_diff_review_comment(window, cx);
1518 });
1519 }
1520 })
1521 .ok();
1522
1523 // Wait for comments to be stored
1524 for _ in 0..3 {
1525 cx.advance_clock(Duration::from_millis(100));
1526 cx.run_until_parked();
1527 }
1528
1529 // Refresh window
1530 cx.update_window(regular_window.into(), |_, window, _cx| {
1531 window.refresh();
1532 })?;
1533
1534 cx.run_until_parked();
1535
1536 // Capture Test 7: Overlay with multiple comments expanded
1537 let test7_result = run_visual_test(
1538 "diff_review_multiple_comments_expanded",
1539 regular_window.into(),
1540 cx,
1541 update_baseline,
1542 )?;
1543
1544 // Test 8: Collapse the comments section
1545 regular_window
1546 .update(cx, |workspace, _window, cx| {
1547 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1548 if let Some(editor) = editors.into_iter().next() {
1549 editor.update(cx, |editor, cx| {
1550 // Toggle collapse using the public method
1551 editor.set_diff_review_comments_expanded(false, cx);
1552 });
1553 }
1554 })
1555 .ok();
1556
1557 // Wait for UI to update
1558 for _ in 0..3 {
1559 cx.advance_clock(Duration::from_millis(100));
1560 cx.run_until_parked();
1561 }
1562
1563 // Refresh window
1564 cx.update_window(regular_window.into(), |_, window, _cx| {
1565 window.refresh();
1566 })?;
1567
1568 cx.run_until_parked();
1569
1570 // Capture Test 8: Comments collapsed
1571 let test8_result = run_visual_test(
1572 "diff_review_comments_collapsed",
1573 regular_window.into(),
1574 cx,
1575 update_baseline,
1576 )?;
1577
1578 // Clean up: remove worktrees to stop background scanning
1579 workspace_window
1580 .update(cx, |workspace, _window, cx| {
1581 let project = workspace.project().clone();
1582 project.update(cx, |project, cx| {
1583 let worktree_ids: Vec<_> =
1584 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
1585 for id in worktree_ids {
1586 project.remove_worktree(id, cx);
1587 }
1588 });
1589 })
1590 .ok();
1591
1592 cx.run_until_parked();
1593
1594 // Close windows
1595 let _ = cx.update_window(workspace_window.into(), |_, window, _cx| {
1596 window.remove_window();
1597 });
1598 let _ = cx.update_window(regular_window.into(), |_, window, _cx| {
1599 window.remove_window();
1600 });
1601
1602 cx.run_until_parked();
1603
1604 // Give background tasks time to finish
1605 for _ in 0..15 {
1606 cx.advance_clock(Duration::from_millis(100));
1607 cx.run_until_parked();
1608 }
1609
1610 // Return combined result
1611 let all_results = [
1612 &test1_result,
1613 &test2_result,
1614 &test3_result,
1615 &test4_result,
1616 &test5_result,
1617 &test6_result,
1618 &test7_result,
1619 &test8_result,
1620 ];
1621
1622 // Combine results: if any test updated a baseline, return BaselineUpdated;
1623 // otherwise return Passed. The exhaustive match ensures the compiler
1624 // verifies we handle all TestResult variants.
1625 let result = all_results
1626 .iter()
1627 .fold(TestResult::Passed, |acc, r| match r {
1628 TestResult::Passed => acc,
1629 TestResult::BaselineUpdated(p) => TestResult::BaselineUpdated(p.clone()),
1630 });
1631 Ok(result)
1632}
1633
1634/// A stub AgentServer for visual testing that returns a pre-programmed connection.
1635#[derive(Clone)]
1636#[cfg(target_os = "macos")]
1637struct StubAgentServer {
1638 connection: StubAgentConnection,
1639}
1640
1641#[cfg(target_os = "macos")]
1642impl StubAgentServer {
1643 fn new(connection: StubAgentConnection) -> Self {
1644 Self { connection }
1645 }
1646}
1647
1648#[cfg(target_os = "macos")]
1649impl AgentServer for StubAgentServer {
1650 fn logo(&self) -> ui::IconName {
1651 ui::IconName::ZedAssistant
1652 }
1653
1654 fn name(&self) -> SharedString {
1655 "Visual Test Agent".into()
1656 }
1657
1658 fn connect(
1659 &self,
1660 _root_dir: Option<&Path>,
1661 _delegate: AgentServerDelegate,
1662 _cx: &mut App,
1663 ) -> gpui::Task<gpui::Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
1664 gpui::Task::ready(Ok((Rc::new(self.connection.clone()), None)))
1665 }
1666
1667 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
1668 self
1669 }
1670}
1671
1672#[cfg(all(target_os = "macos", feature = "visual-tests"))]
1673fn run_subagent_visual_tests(
1674 app_state: Arc<AppState>,
1675 cx: &mut VisualTestAppContext,
1676 update_baseline: bool,
1677) -> Result<TestResult> {
1678 use acp_thread::{
1679 AcpThread, SUBAGENT_TOOL_NAME, ToolCallUpdateSubagentThread, meta_with_tool_name,
1680 };
1681 use agent_ui::AgentPanel;
1682
1683 // Create a temporary project directory
1684 let temp_dir = tempfile::tempdir()?;
1685 let temp_path = temp_dir.keep();
1686 let canonical_temp = temp_path.canonicalize()?;
1687 let project_path = canonical_temp.join("project");
1688 std::fs::create_dir_all(&project_path)?;
1689
1690 // Create a project
1691 let project = cx.update(|cx| {
1692 project::Project::local(
1693 app_state.client.clone(),
1694 app_state.node_runtime.clone(),
1695 app_state.user_store.clone(),
1696 app_state.languages.clone(),
1697 app_state.fs.clone(),
1698 None,
1699 false,
1700 cx,
1701 )
1702 });
1703
1704 // Add the test directory as a worktree
1705 let add_worktree_task = project.update(cx, |project, cx| {
1706 project.find_or_create_worktree(&project_path, true, cx)
1707 });
1708
1709 let _ = cx.foreground_executor.block_test(add_worktree_task);
1710
1711 cx.run_until_parked();
1712
1713 // Create stub connection - we'll manually inject the subagent content
1714 let connection = StubAgentConnection::new();
1715
1716 // Create a subagent tool call (in progress state)
1717 let tool_call = acp::ToolCall::new("subagent-tool-1", "2 subagents")
1718 .kind(acp::ToolKind::Other)
1719 .meta(meta_with_tool_name(SUBAGENT_TOOL_NAME))
1720 .status(acp::ToolCallStatus::InProgress);
1721
1722 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
1723
1724 let stub_agent: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(connection.clone()));
1725
1726 // Create a window sized for the agent panel
1727 let window_size = size(px(600.0), px(700.0));
1728 let bounds = Bounds {
1729 origin: point(px(0.0), px(0.0)),
1730 size: window_size,
1731 };
1732
1733 let workspace_window: WindowHandle<Workspace> = cx
1734 .update(|cx| {
1735 cx.open_window(
1736 WindowOptions {
1737 window_bounds: Some(WindowBounds::Windowed(bounds)),
1738 focus: false,
1739 show: false,
1740 ..Default::default()
1741 },
1742 |window, cx| {
1743 cx.new(|cx| {
1744 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
1745 })
1746 },
1747 )
1748 })
1749 .context("Failed to open agent window")?;
1750
1751 cx.run_until_parked();
1752
1753 // Load the AgentPanel
1754 let (weak_workspace, async_window_cx) = workspace_window
1755 .update(cx, |workspace, window, cx| {
1756 (workspace.weak_handle(), window.to_async(cx))
1757 })
1758 .context("Failed to get workspace handle")?;
1759
1760 let prompt_builder =
1761 cx.update(|cx| prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx));
1762 let panel = cx
1763 .foreground_executor
1764 .block_test(AgentPanel::load(
1765 weak_workspace,
1766 prompt_builder,
1767 async_window_cx,
1768 ))
1769 .context("Failed to load AgentPanel")?;
1770
1771 cx.update_window(workspace_window.into(), |_, _window, cx| {
1772 workspace_window
1773 .update(cx, |workspace, window, cx| {
1774 workspace.add_panel(panel.clone(), window, cx);
1775 workspace.open_panel::<AgentPanel>(window, cx);
1776 })
1777 .ok();
1778 })?;
1779
1780 cx.run_until_parked();
1781
1782 // Open the stub thread
1783 cx.update_window(workspace_window.into(), |_, window, cx| {
1784 panel.update(cx, |panel: &mut agent_ui::AgentPanel, cx| {
1785 panel.open_external_thread_with_server(stub_agent.clone(), window, cx);
1786 });
1787 })?;
1788
1789 cx.run_until_parked();
1790
1791 // Get the thread view and send a message to trigger the subagent tool call
1792 let thread_view = cx
1793 .read(|cx| panel.read(cx).active_thread_view_for_tests().cloned())
1794 .ok_or_else(|| anyhow::anyhow!("No active thread view"))?;
1795
1796 let thread = cx
1797 .read(|cx| thread_view.read(cx).thread().cloned())
1798 .ok_or_else(|| anyhow::anyhow!("Thread not available"))?;
1799
1800 // Send the message to trigger the subagent response
1801 let send_future = thread.update(cx, |thread: &mut acp_thread::AcpThread, cx| {
1802 thread.send(vec!["Run two subagents".into()], cx)
1803 });
1804
1805 let _ = cx.foreground_executor.block_test(send_future);
1806
1807 cx.run_until_parked();
1808
1809 // Get the tool call ID
1810 let tool_call_id = cx
1811 .read(|cx| {
1812 thread.read(cx).entries().iter().find_map(|entry| {
1813 if let acp_thread::AgentThreadEntry::ToolCall(tool_call) = entry {
1814 Some(tool_call.id.clone())
1815 } else {
1816 None
1817 }
1818 })
1819 })
1820 .ok_or_else(|| anyhow::anyhow!("Expected a ToolCall entry in thread"))?;
1821
1822 // Create two subagent AcpThreads and inject them
1823 let subagent1 = cx.update(|cx| {
1824 let action_log = cx.new(|_| action_log::ActionLog::new(project.clone()));
1825 let session_id = acp::SessionId::new("subagent-1");
1826 cx.new(|cx| {
1827 let mut thread = AcpThread::new(
1828 "Exploring test-repo",
1829 Rc::new(connection.clone()),
1830 project.clone(),
1831 action_log,
1832 session_id,
1833 watch::Receiver::constant(acp::PromptCapabilities::new()),
1834 cx,
1835 );
1836 // Add some content to this subagent
1837 thread.push_assistant_content_block(
1838 "## Summary of test-repo\n\nThis is a test repository with:\n\n- **Files:** test.txt\n- **Purpose:** Testing".into(),
1839 false,
1840 cx,
1841 );
1842 thread
1843 })
1844 });
1845
1846 let subagent2 = cx.update(|cx| {
1847 let action_log = cx.new(|_| action_log::ActionLog::new(project.clone()));
1848 let session_id = acp::SessionId::new("subagent-2");
1849 cx.new(|cx| {
1850 let mut thread = AcpThread::new(
1851 "Exploring test-worktree",
1852 Rc::new(connection.clone()),
1853 project.clone(),
1854 action_log,
1855 session_id,
1856 watch::Receiver::constant(acp::PromptCapabilities::new()),
1857 cx,
1858 );
1859 // Add some content to this subagent
1860 thread.push_assistant_content_block(
1861 "## Summary of test-worktree\n\nThis directory contains:\n\n- A single `config.json` file\n- Basic project setup".into(),
1862 false,
1863 cx,
1864 );
1865 thread
1866 })
1867 });
1868
1869 // Inject subagent threads into the tool call
1870 thread.update(cx, |thread: &mut acp_thread::AcpThread, cx| {
1871 thread
1872 .update_tool_call(
1873 ToolCallUpdateSubagentThread {
1874 id: tool_call_id.clone(),
1875 thread: subagent1,
1876 },
1877 cx,
1878 )
1879 .ok();
1880 thread
1881 .update_tool_call(
1882 ToolCallUpdateSubagentThread {
1883 id: tool_call_id.clone(),
1884 thread: subagent2,
1885 },
1886 cx,
1887 )
1888 .ok();
1889 });
1890
1891 cx.run_until_parked();
1892
1893 cx.update_window(workspace_window.into(), |_, window, _cx| {
1894 window.refresh();
1895 })?;
1896
1897 cx.run_until_parked();
1898
1899 // Capture subagents in RUNNING state (tool call still in progress)
1900 let running_result = run_visual_test(
1901 "subagent_cards_running",
1902 workspace_window.into(),
1903 cx,
1904 update_baseline,
1905 )?;
1906
1907 // Now mark the tool call as completed by updating it through the thread
1908 thread.update(cx, |thread: &mut acp_thread::AcpThread, cx| {
1909 thread
1910 .handle_session_update(
1911 acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
1912 tool_call_id.clone(),
1913 acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed),
1914 )),
1915 cx,
1916 )
1917 .ok();
1918 });
1919
1920 cx.run_until_parked();
1921
1922 cx.update_window(workspace_window.into(), |_, window, _cx| {
1923 window.refresh();
1924 })?;
1925
1926 cx.run_until_parked();
1927
1928 // Capture subagents in COMPLETED state
1929 let completed_result = run_visual_test(
1930 "subagent_cards_completed",
1931 workspace_window.into(),
1932 cx,
1933 update_baseline,
1934 )?;
1935
1936 // Expand the first subagent
1937 thread_view.update(cx, |view: &mut agent_ui::acp::AcpThreadView, cx| {
1938 view.expand_subagent(acp::SessionId::new("subagent-1"), cx);
1939 });
1940
1941 cx.run_until_parked();
1942
1943 cx.update_window(workspace_window.into(), |_, window, _cx| {
1944 window.refresh();
1945 })?;
1946
1947 cx.run_until_parked();
1948
1949 // Capture subagent in EXPANDED state
1950 let expanded_result = run_visual_test(
1951 "subagent_cards_expanded",
1952 workspace_window.into(),
1953 cx,
1954 update_baseline,
1955 )?;
1956
1957 // Cleanup
1958 workspace_window
1959 .update(cx, |workspace, _window, cx| {
1960 let project = workspace.project().clone();
1961 project.update(cx, |project, cx| {
1962 let worktree_ids: Vec<_> =
1963 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
1964 for id in worktree_ids {
1965 project.remove_worktree(id, cx);
1966 }
1967 });
1968 })
1969 .ok();
1970
1971 cx.run_until_parked();
1972
1973 let _ = cx.update_window(workspace_window.into(), |_, window, _cx| {
1974 window.remove_window();
1975 });
1976
1977 cx.run_until_parked();
1978
1979 for _ in 0..15 {
1980 cx.advance_clock(Duration::from_millis(100));
1981 cx.run_until_parked();
1982 }
1983
1984 match (&running_result, &completed_result, &expanded_result) {
1985 (TestResult::Passed, TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
1986 (TestResult::BaselineUpdated(p), _, _)
1987 | (_, TestResult::BaselineUpdated(p), _)
1988 | (_, _, TestResult::BaselineUpdated(p)) => Ok(TestResult::BaselineUpdated(p.clone())),
1989 }
1990}
1991
1992#[cfg(all(target_os = "macos", feature = "visual-tests"))]
1993fn run_agent_thread_view_test(
1994 app_state: Arc<AppState>,
1995 cx: &mut VisualTestAppContext,
1996 update_baseline: bool,
1997) -> Result<TestResult> {
1998 use agent::AgentTool;
1999 use agent_ui::AgentPanel;
2000
2001 // Create a temporary directory with the test image
2002 // Canonicalize to resolve symlinks (on macOS, /var -> /private/var)
2003 // Use keep() to prevent auto-cleanup - we'll clean up manually after stopping background tasks
2004 let temp_dir = tempfile::tempdir()?;
2005 let temp_path = temp_dir.keep();
2006 let canonical_temp = temp_path.canonicalize()?;
2007 let project_path = canonical_temp.join("project");
2008 std::fs::create_dir_all(&project_path)?;
2009 let image_path = project_path.join("test-image.png");
2010 std::fs::write(&image_path, EMBEDDED_TEST_IMAGE)?;
2011
2012 // Create a project with the test image
2013 let project = cx.update(|cx| {
2014 project::Project::local(
2015 app_state.client.clone(),
2016 app_state.node_runtime.clone(),
2017 app_state.user_store.clone(),
2018 app_state.languages.clone(),
2019 app_state.fs.clone(),
2020 None,
2021 false,
2022 cx,
2023 )
2024 });
2025
2026 // Add the test directory as a worktree
2027 let add_worktree_task = project.update(cx, |project, cx| {
2028 project.find_or_create_worktree(&project_path, true, cx)
2029 });
2030
2031 cx.background_executor.allow_parking();
2032 let (worktree, _) = cx
2033 .foreground_executor
2034 .block_test(add_worktree_task)
2035 .context("Failed to add worktree")?;
2036 cx.background_executor.forbid_parking();
2037
2038 cx.run_until_parked();
2039
2040 let worktree_name = cx.read(|cx| worktree.read(cx).root_name_str().to_string());
2041
2042 // Create the necessary entities for the ReadFileTool
2043 let action_log = cx.update(|cx| cx.new(|_| action_log::ActionLog::new(project.clone())));
2044 let context_server_registry = cx.update(|cx| {
2045 cx.new(|cx| agent::ContextServerRegistry::new(project.read(cx).context_server_store(), cx))
2046 });
2047 let fake_model = Arc::new(language_model::fake_provider::FakeLanguageModel::default());
2048 let project_context = cx.update(|cx| cx.new(|_| prompt_store::ProjectContext::default()));
2049
2050 // Create the agent Thread
2051 let thread = cx.update(|cx| {
2052 cx.new(|cx| {
2053 agent::Thread::new(
2054 project.clone(),
2055 project_context,
2056 context_server_registry,
2057 agent::Templates::new(),
2058 Some(fake_model),
2059 cx,
2060 )
2061 })
2062 });
2063
2064 // Create the ReadFileTool
2065 let tool = Arc::new(agent::ReadFileTool::new(
2066 thread.downgrade(),
2067 project.clone(),
2068 action_log,
2069 ));
2070
2071 // Create a test event stream to capture tool output
2072 let (event_stream, mut event_receiver) = agent::ToolCallEventStream::test();
2073
2074 // Run the real ReadFileTool to get the actual image content
2075 let input = agent::ReadFileToolInput {
2076 path: format!("{}/test-image.png", worktree_name),
2077 start_line: None,
2078 end_line: None,
2079 };
2080 let run_task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
2081
2082 cx.background_executor.allow_parking();
2083 let run_result = cx.foreground_executor.block_test(run_task);
2084 cx.background_executor.forbid_parking();
2085 run_result.context("ReadFileTool failed")?;
2086
2087 cx.run_until_parked();
2088
2089 // Collect the events from the tool execution
2090 let mut tool_content: Vec<acp::ToolCallContent> = Vec::new();
2091 let mut tool_locations: Vec<acp::ToolCallLocation> = Vec::new();
2092
2093 while let Ok(Some(event)) = event_receiver.try_next() {
2094 if let Ok(agent::ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(
2095 update,
2096 ))) = event
2097 {
2098 if let Some(content) = update.fields.content {
2099 tool_content.extend(content);
2100 }
2101 if let Some(locations) = update.fields.locations {
2102 tool_locations.extend(locations);
2103 }
2104 }
2105 }
2106
2107 if tool_content.is_empty() {
2108 return Err(anyhow::anyhow!("ReadFileTool did not produce any content"));
2109 }
2110
2111 // Create stub connection with the real tool output
2112 let connection = StubAgentConnection::new();
2113 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
2114 acp::ToolCall::new(
2115 "read_file",
2116 format!("Read file `{}/test-image.png`", worktree_name),
2117 )
2118 .kind(acp::ToolKind::Read)
2119 .status(acp::ToolCallStatus::Completed)
2120 .locations(tool_locations)
2121 .content(tool_content),
2122 )]);
2123
2124 let stub_agent: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(connection));
2125
2126 // Create a window sized for the agent panel
2127 let window_size = size(px(500.0), px(900.0));
2128 let bounds = Bounds {
2129 origin: point(px(0.0), px(0.0)),
2130 size: window_size,
2131 };
2132
2133 let workspace_window: WindowHandle<Workspace> = cx
2134 .update(|cx| {
2135 cx.open_window(
2136 WindowOptions {
2137 window_bounds: Some(WindowBounds::Windowed(bounds)),
2138 focus: false,
2139 show: false,
2140 ..Default::default()
2141 },
2142 |window, cx| {
2143 cx.new(|cx| {
2144 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
2145 })
2146 },
2147 )
2148 })
2149 .context("Failed to open agent window")?;
2150
2151 cx.run_until_parked();
2152
2153 // Load the AgentPanel
2154 let (weak_workspace, async_window_cx) = workspace_window
2155 .update(cx, |workspace, window, cx| {
2156 (workspace.weak_handle(), window.to_async(cx))
2157 })
2158 .context("Failed to get workspace handle")?;
2159
2160 let prompt_builder =
2161 cx.update(|cx| prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx));
2162 cx.background_executor.allow_parking();
2163 let panel = cx
2164 .foreground_executor
2165 .block_test(AgentPanel::load(
2166 weak_workspace,
2167 prompt_builder,
2168 async_window_cx,
2169 ))
2170 .context("Failed to load AgentPanel")?;
2171 cx.background_executor.forbid_parking();
2172
2173 cx.update_window(workspace_window.into(), |_, _window, cx| {
2174 workspace_window
2175 .update(cx, |workspace, window, cx| {
2176 workspace.add_panel(panel.clone(), window, cx);
2177 workspace.open_panel::<AgentPanel>(window, cx);
2178 })
2179 .ok();
2180 })?;
2181
2182 cx.run_until_parked();
2183
2184 // Inject the stub server and open the stub thread
2185 cx.update_window(workspace_window.into(), |_, window, cx| {
2186 panel.update(cx, |panel, cx| {
2187 panel.open_external_thread_with_server(stub_agent.clone(), window, cx);
2188 });
2189 })?;
2190
2191 cx.run_until_parked();
2192
2193 // Get the thread view and send a message
2194 let thread_view = cx
2195 .read(|cx| panel.read(cx).active_thread_view_for_tests().cloned())
2196 .ok_or_else(|| anyhow::anyhow!("No active thread view"))?;
2197
2198 let thread = cx
2199 .read(|cx| thread_view.read(cx).thread().cloned())
2200 .ok_or_else(|| anyhow::anyhow!("Thread not available"))?;
2201
2202 // Send the message to trigger the image response
2203 let send_future = thread.update(cx, |thread, cx| {
2204 thread.send(vec!["Show me the Zed logo".into()], cx)
2205 });
2206
2207 cx.background_executor.allow_parking();
2208 let send_result = cx.foreground_executor.block_test(send_future);
2209 cx.background_executor.forbid_parking();
2210 send_result.context("Failed to send message")?;
2211
2212 cx.run_until_parked();
2213
2214 // Get the tool call ID for expanding later
2215 let tool_call_id = cx
2216 .read(|cx| {
2217 thread.read(cx).entries().iter().find_map(|entry| {
2218 if let acp_thread::AgentThreadEntry::ToolCall(tool_call) = entry {
2219 Some(tool_call.id.clone())
2220 } else {
2221 None
2222 }
2223 })
2224 })
2225 .ok_or_else(|| anyhow::anyhow!("Expected a ToolCall entry in thread"))?;
2226
2227 cx.update_window(workspace_window.into(), |_, window, _cx| {
2228 window.refresh();
2229 })?;
2230
2231 cx.run_until_parked();
2232
2233 // Capture the COLLAPSED state
2234 let collapsed_result = run_visual_test(
2235 "agent_thread_with_image_collapsed",
2236 workspace_window.into(),
2237 cx,
2238 update_baseline,
2239 )?;
2240
2241 // Now expand the tool call so the image is visible
2242 thread_view.update(cx, |view, cx| {
2243 view.expand_tool_call(tool_call_id, cx);
2244 });
2245
2246 cx.run_until_parked();
2247
2248 cx.update_window(workspace_window.into(), |_, window, _cx| {
2249 window.refresh();
2250 })?;
2251
2252 cx.run_until_parked();
2253
2254 // Capture the EXPANDED state
2255 let expanded_result = run_visual_test(
2256 "agent_thread_with_image_expanded",
2257 workspace_window.into(),
2258 cx,
2259 update_baseline,
2260 )?;
2261
2262 // Remove the worktree from the project to stop background scanning tasks
2263 // This prevents "root path could not be canonicalized" errors when we clean up
2264 workspace_window
2265 .update(cx, |workspace, _window, cx| {
2266 let project = workspace.project().clone();
2267 project.update(cx, |project, cx| {
2268 let worktree_ids: Vec<_> =
2269 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
2270 for id in worktree_ids {
2271 project.remove_worktree(id, cx);
2272 }
2273 });
2274 })
2275 .ok();
2276
2277 cx.run_until_parked();
2278
2279 // Close the window
2280 // Note: This may cause benign "editor::scroll window not found" errors from scrollbar
2281 // auto-hide timers that were scheduled before the window was closed. These errors
2282 // don't affect test results.
2283 let _ = cx.update_window(workspace_window.into(), |_, window, _cx| {
2284 window.remove_window();
2285 });
2286
2287 // Run until all cleanup tasks complete
2288 cx.run_until_parked();
2289
2290 // Give background tasks time to finish, including scrollbar hide timers (1 second)
2291 for _ in 0..15 {
2292 cx.advance_clock(Duration::from_millis(100));
2293 cx.run_until_parked();
2294 }
2295
2296 // Note: We don't delete temp_path here because background worktree tasks may still
2297 // be running. The directory will be cleaned up when the process exits.
2298
2299 match (&collapsed_result, &expanded_result) {
2300 (TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
2301 (TestResult::BaselineUpdated(p), _) | (_, TestResult::BaselineUpdated(p)) => {
2302 Ok(TestResult::BaselineUpdated(p.clone()))
2303 }
2304 }
2305}