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#[cfg(target_os = "macos")]
46fn main() {
47 // Set ZED_STATELESS early to prevent file system access to real config directories
48 // This must be done before any code accesses zed_env_vars::ZED_STATELESS
49 // SAFETY: We're at the start of main(), before any threads are spawned
50 unsafe {
51 std::env::set_var("ZED_STATELESS", "1");
52 }
53
54 env_logger::builder()
55 .filter_level(log::LevelFilter::Info)
56 .init();
57
58 let update_baseline = std::env::var("UPDATE_BASELINE").is_ok();
59
60 // Create a temporary directory for test files
61 // Canonicalize the path to resolve symlinks (on macOS, /var -> /private/var)
62 // which prevents "path does not exist" errors during worktree scanning
63 // Use keep() to prevent auto-cleanup - background worktree tasks may still be running
64 // when tests complete, so we let the OS clean up temp directories on process exit
65 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
66 let temp_path = temp_dir.keep();
67 let canonical_temp = temp_path
68 .canonicalize()
69 .expect("Failed to canonicalize temp directory");
70 let project_path = canonical_temp.join("project");
71 std::fs::create_dir_all(&project_path).expect("Failed to create project directory");
72
73 // Create test files in the real filesystem
74 create_test_files(&project_path);
75
76 let test_result = std::panic::catch_unwind(|| run_visual_tests(project_path, update_baseline));
77
78 // Note: We don't delete temp_path here because background worktree tasks may still
79 // be running. The directory will be cleaned up when the process exits or by the OS.
80
81 match test_result {
82 Ok(Ok(())) => {}
83 Ok(Err(e)) => {
84 eprintln!("Visual tests failed: {}", e);
85 std::process::exit(1);
86 }
87 Err(_) => {
88 eprintln!("Visual tests panicked");
89 std::process::exit(1);
90 }
91 }
92}
93
94// All macOS-specific imports grouped together
95#[cfg(target_os = "macos")]
96use {
97 acp_thread::{AgentConnection, StubAgentConnection},
98 agent_client_protocol::schema as acp,
99 agent_servers::{AgentServer, AgentServerDelegate},
100 anyhow::{Context as _, Result},
101 assets::Assets,
102 editor::display_map::DisplayRow,
103 feature_flags::FeatureFlagAppExt as _,
104 git_ui::project_diff::ProjectDiff,
105 gpui::{
106 App, AppContext as _, Bounds, Entity, KeyBinding, Modifiers, VisualTestAppContext,
107 WindowBounds, WindowHandle, WindowOptions, point, px, size,
108 },
109 image::RgbaImage,
110 project::{AgentId, Project},
111 project_panel::ProjectPanel,
112 settings::{NotifyWhenAgentWaiting, PlaySoundWhenAgentDone, Settings as _},
113 settings_ui::SettingsWindow,
114 std::{
115 any::Any,
116 path::{Path, PathBuf},
117 rc::Rc,
118 sync::Arc,
119 time::Duration,
120 },
121 util::ResultExt as _,
122 workspace::{AppState, MultiWorkspace, Workspace},
123 zed_actions::OpenSettingsAt,
124};
125
126// All macOS-specific constants grouped together
127#[cfg(target_os = "macos")]
128mod constants {
129 use std::time::Duration;
130
131 /// Baseline images are stored relative to this file
132 pub const BASELINE_DIR: &str = "crates/zed/test_fixtures/visual_tests";
133
134 /// Embedded test image (Zed app icon) for visual tests.
135 pub const EMBEDDED_TEST_IMAGE: &[u8] = include_bytes!("../resources/app-icon.png");
136
137 /// Threshold for image comparison (0.0 to 1.0)
138 /// Images must match at least this percentage to pass
139 pub const MATCH_THRESHOLD: f64 = 0.99;
140
141 /// Tooltip show delay - must match TOOLTIP_SHOW_DELAY in gpui/src/elements/div.rs
142 pub const TOOLTIP_SHOW_DELAY: Duration = Duration::from_millis(500);
143}
144
145#[cfg(target_os = "macos")]
146use constants::*;
147
148#[cfg(target_os = "macos")]
149fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> {
150 // Create the visual test context with deterministic task scheduling
151 // Use real Assets so that SVG icons render properly
152 let mut cx = VisualTestAppContext::with_asset_source(
153 gpui_platform::current_platform(false),
154 Arc::new(Assets),
155 );
156
157 // Load embedded fonts (IBM Plex Sans, Lilex, etc.) so UI renders with correct fonts
158 cx.update(|cx| {
159 Assets.load_fonts(cx).unwrap();
160 });
161
162 // Initialize settings store with real default settings (not test settings)
163 // Test settings use Courier font, but we want the real Zed fonts for visual tests
164 cx.update(|cx| {
165 settings::init(cx);
166 });
167
168 // Create AppState using the test initialization
169 let app_state = cx.update(|cx| init_app_state(cx));
170
171 // Set the global app state so settings_ui and other subsystems can find it
172 cx.update(|cx| {
173 AppState::set_global(app_state.clone(), cx);
174 });
175
176 // Initialize all Zed subsystems
177 cx.update(|cx| {
178 gpui_tokio::init(cx);
179 theme_settings::init(theme::LoadThemes::JustBase, cx);
180 client::init(&app_state.client, cx);
181 audio::init(cx);
182 workspace::init(app_state.clone(), cx);
183 release_channel::init(semver::Version::new(0, 0, 0), cx);
184 command_palette::init(cx);
185 editor::init(cx);
186 call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
187 title_bar::init(cx);
188 project_panel::init(cx);
189 outline_panel::init(cx);
190 terminal_view::init(cx);
191 image_viewer::init(cx);
192 search::init(cx);
193 cx.set_global(workspace::PaneSearchBarCallbacks {
194 setup_search_bar: |languages, toolbar, window, cx| {
195 let search_bar = cx.new(|cx| search::BufferSearchBar::new(languages, window, cx));
196 toolbar.update(cx, |toolbar, cx| {
197 toolbar.add_item(search_bar, window, cx);
198 });
199 },
200 wrap_div_with_search_actions: search::buffer_search::register_pane_search_actions,
201 });
202 prompt_store::init(cx);
203 let prompt_builder = prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx);
204 language_model::init(cx);
205 client::RefreshLlmTokenListener::register(
206 app_state.client.clone(),
207 app_state.user_store.clone(),
208 cx,
209 );
210 language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
211 git_ui::init(cx);
212 project::AgentRegistryStore::init_global(
213 cx,
214 app_state.fs.clone(),
215 app_state.client.http_client(),
216 );
217 agent_ui::init(
218 app_state.fs.clone(),
219 prompt_builder,
220 app_state.languages.clone(),
221 true,
222 false,
223 cx,
224 );
225 settings_ui::init(cx);
226
227 // Load default keymaps so tooltips can show keybindings like "f9" for ToggleBreakpoint
228 // We load a minimal set of editor keybindings needed for visual tests
229 cx.bind_keys([KeyBinding::new(
230 "f9",
231 editor::actions::ToggleBreakpoint,
232 Some("Editor"),
233 )]);
234
235 // Disable agent notifications during visual tests to avoid popup windows
236 agent_settings::AgentSettings::override_global(
237 agent_settings::AgentSettings {
238 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
239 play_sound_when_agent_done: PlaySoundWhenAgentDone::Never,
240 ..agent_settings::AgentSettings::get_global(cx).clone()
241 },
242 cx,
243 );
244 });
245
246 // Run until all initialization tasks complete
247 cx.run_until_parked();
248
249 // Open workspace window
250 let window_size = size(px(1280.0), px(800.0));
251 let bounds = Bounds {
252 origin: point(px(0.0), px(0.0)),
253 size: window_size,
254 };
255
256 // Create a project for the workspace
257 let project = cx.update(|cx| {
258 project::Project::local(
259 app_state.client.clone(),
260 app_state.node_runtime.clone(),
261 app_state.user_store.clone(),
262 app_state.languages.clone(),
263 app_state.fs.clone(),
264 None,
265 project::LocalProjectFlags {
266 init_worktree_trust: false,
267 ..Default::default()
268 },
269 cx,
270 )
271 });
272
273 let workspace_window: WindowHandle<Workspace> = cx
274 .update(|cx| {
275 cx.open_window(
276 WindowOptions {
277 window_bounds: Some(WindowBounds::Windowed(bounds)),
278 focus: false,
279 show: false,
280 ..Default::default()
281 },
282 |window, cx| {
283 cx.new(|cx| {
284 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
285 })
286 },
287 )
288 })
289 .context("Failed to open workspace window")?;
290
291 cx.run_until_parked();
292
293 // Add the test project as a worktree
294 let add_worktree_task = workspace_window
295 .update(&mut cx, |workspace, _window, cx| {
296 let project = workspace.project().clone();
297 project.update(cx, |project, cx| {
298 project.find_or_create_worktree(&project_path, true, cx)
299 })
300 })
301 .context("Failed to start adding worktree")?;
302
303 // Use block_test to wait for the worktree task
304 // block_test runs both foreground and background tasks, which is needed because
305 // worktree creation spawns foreground tasks via cx.spawn
306 // Allow parking since filesystem operations happen outside the test dispatcher
307 cx.background_executor.allow_parking();
308 let worktree_result = cx.foreground_executor.block_test(add_worktree_task);
309 cx.background_executor.forbid_parking();
310 worktree_result.context("Failed to add worktree")?;
311
312 cx.run_until_parked();
313
314 // Create and add the project panel
315 let (weak_workspace, async_window_cx) = workspace_window
316 .update(&mut cx, |workspace, window, cx| {
317 (workspace.weak_handle(), window.to_async(cx))
318 })
319 .context("Failed to get workspace handle")?;
320
321 cx.background_executor.allow_parking();
322 let panel = cx
323 .foreground_executor
324 .block_test(ProjectPanel::load(weak_workspace, async_window_cx))
325 .context("Failed to load project panel")?;
326 cx.background_executor.forbid_parking();
327
328 workspace_window
329 .update(&mut cx, |workspace, window, cx| {
330 workspace.add_panel(panel, window, cx);
331 })
332 .log_err();
333
334 cx.run_until_parked();
335
336 // Open the project panel
337 workspace_window
338 .update(&mut cx, |workspace, window, cx| {
339 workspace.open_panel::<ProjectPanel>(window, cx);
340 })
341 .log_err();
342
343 cx.run_until_parked();
344
345 // Open main.rs in the editor
346 let open_file_task = workspace_window
347 .update(&mut cx, |workspace, window, cx| {
348 let worktree = workspace.project().read(cx).worktrees(cx).next();
349 if let Some(worktree) = worktree {
350 let worktree_id = worktree.read(cx).id();
351 let rel_path: std::sync::Arc<util::rel_path::RelPath> =
352 util::rel_path::rel_path("src/main.rs").into();
353 let project_path: project::ProjectPath = (worktree_id, rel_path).into();
354 Some(workspace.open_path(project_path, None, true, window, cx))
355 } else {
356 None
357 }
358 })
359 .log_err()
360 .flatten();
361
362 if let Some(task) = open_file_task {
363 cx.background_executor.allow_parking();
364 let block_result = cx.foreground_executor.block_test(task);
365 cx.background_executor.forbid_parking();
366 if let Ok(item) = block_result {
367 workspace_window
368 .update(&mut cx, |workspace, window, cx| {
369 let pane = workspace.active_pane().clone();
370 pane.update(cx, |pane, cx| {
371 if let Some(index) = pane.index_for_item(item.as_ref()) {
372 pane.activate_item(index, true, true, window, cx);
373 }
374 });
375 })
376 .log_err();
377 }
378 }
379
380 cx.run_until_parked();
381
382 // Request a window refresh
383 cx.update_window(workspace_window.into(), |_, window, _cx| {
384 window.refresh();
385 })
386 .log_err();
387
388 cx.run_until_parked();
389
390 // Track test results
391 let mut passed = 0;
392 let mut failed = 0;
393 let mut updated = 0;
394
395 // Run Test 1: Project Panel (with project panel visible)
396 println!("\n--- Test 1: project_panel ---");
397 match run_visual_test(
398 "project_panel",
399 workspace_window.into(),
400 &mut cx,
401 update_baseline,
402 ) {
403 Ok(TestResult::Passed) => {
404 println!("✓ project_panel: PASSED");
405 passed += 1;
406 }
407 Ok(TestResult::BaselineUpdated(_)) => {
408 println!("✓ project_panel: Baseline updated");
409 updated += 1;
410 }
411 Err(e) => {
412 eprintln!("✗ project_panel: FAILED - {}", e);
413 failed += 1;
414 }
415 }
416
417 // Run Test 2: Workspace with Editor
418 println!("\n--- Test 2: workspace_with_editor ---");
419
420 // Close project panel for this test
421 workspace_window
422 .update(&mut cx, |workspace, window, cx| {
423 workspace.close_panel::<ProjectPanel>(window, cx);
424 })
425 .log_err();
426
427 cx.run_until_parked();
428
429 match run_visual_test(
430 "workspace_with_editor",
431 workspace_window.into(),
432 &mut cx,
433 update_baseline,
434 ) {
435 Ok(TestResult::Passed) => {
436 println!("✓ workspace_with_editor: PASSED");
437 passed += 1;
438 }
439 Ok(TestResult::BaselineUpdated(_)) => {
440 println!("✓ workspace_with_editor: Baseline updated");
441 updated += 1;
442 }
443 Err(e) => {
444 eprintln!("✗ workspace_with_editor: FAILED - {}", e);
445 failed += 1;
446 }
447 }
448
449 // Run Test: ThreadItem branch names visual test
450 println!("\n--- Test: thread_item_branch_names ---");
451 match run_thread_item_branch_name_visual_tests(app_state.clone(), &mut cx, update_baseline) {
452 Ok(TestResult::Passed) => {
453 println!("✓ thread_item_branch_names: PASSED");
454 passed += 1;
455 }
456 Ok(TestResult::BaselineUpdated(_)) => {
457 println!("✓ thread_item_branch_names: Baseline updated");
458 updated += 1;
459 }
460 Err(e) => {
461 eprintln!("✗ thread_item_branch_names: FAILED - {}", e);
462 failed += 1;
463 }
464 }
465
466 // Run Test 3: Multi-workspace sidebar visual tests
467 println!("\n--- Test 3: multi_workspace_sidebar ---");
468 match run_multi_workspace_sidebar_visual_tests(app_state.clone(), &mut cx, update_baseline) {
469 Ok(TestResult::Passed) => {
470 println!("✓ multi_workspace_sidebar: PASSED");
471 passed += 1;
472 }
473 Ok(TestResult::BaselineUpdated(_)) => {
474 println!("✓ multi_workspace_sidebar: Baselines updated");
475 updated += 1;
476 }
477 Err(e) => {
478 eprintln!("✗ multi_workspace_sidebar: FAILED - {}", e);
479 failed += 1;
480 }
481 }
482
483 // Run Test 4: Error wrapping visual tests
484 println!("\n--- Test 4: error_message_wrapping ---");
485 match run_error_wrapping_visual_tests(app_state.clone(), &mut cx, update_baseline) {
486 Ok(TestResult::Passed) => {
487 println!("✓ error_message_wrapping: PASSED");
488 passed += 1;
489 }
490 Ok(TestResult::BaselineUpdated(_)) => {
491 println!("✓ error_message_wrapping: Baselines updated");
492 updated += 1;
493 }
494 Err(e) => {
495 eprintln!("✗ error_message_wrapping: FAILED - {}", e);
496 failed += 1;
497 }
498 }
499
500 // Run Test 5: Agent Thread View tests
501 #[cfg(feature = "visual-tests")]
502 {
503 println!("\n--- Test 5: agent_thread_with_image (collapsed + expanded) ---");
504 match run_agent_thread_view_test(app_state.clone(), &mut cx, update_baseline) {
505 Ok(TestResult::Passed) => {
506 println!("✓ agent_thread_with_image (collapsed + expanded): PASSED");
507 passed += 1;
508 }
509 Ok(TestResult::BaselineUpdated(_)) => {
510 println!("✓ agent_thread_with_image: Baselines updated (collapsed + expanded)");
511 updated += 1;
512 }
513 Err(e) => {
514 eprintln!("✗ agent_thread_with_image: FAILED - {}", e);
515 failed += 1;
516 }
517 }
518 }
519
520 // Run Test 6: Breakpoint Hover visual tests
521 println!("\n--- Test 6: breakpoint_hover (3 variants) ---");
522 match run_breakpoint_hover_visual_tests(app_state.clone(), &mut cx, update_baseline) {
523 Ok(TestResult::Passed) => {
524 println!("✓ breakpoint_hover: PASSED");
525 passed += 1;
526 }
527 Ok(TestResult::BaselineUpdated(_)) => {
528 println!("✓ breakpoint_hover: Baselines updated");
529 updated += 1;
530 }
531 Err(e) => {
532 eprintln!("✗ breakpoint_hover: FAILED - {}", e);
533 failed += 1;
534 }
535 }
536
537 // Run Test 7: Diff Review Button visual tests
538 println!("\n--- Test 7: diff_review_button (3 variants) ---");
539 match run_diff_review_visual_tests(app_state.clone(), &mut cx, update_baseline) {
540 Ok(TestResult::Passed) => {
541 println!("✓ diff_review_button: PASSED");
542 passed += 1;
543 }
544 Ok(TestResult::BaselineUpdated(_)) => {
545 println!("✓ diff_review_button: Baselines updated");
546 updated += 1;
547 }
548 Err(e) => {
549 eprintln!("✗ diff_review_button: FAILED - {}", e);
550 failed += 1;
551 }
552 }
553
554 // Run Test 8: ThreadItem icon decorations visual tests
555 println!("\n--- Test 8: thread_item_icon_decorations ---");
556 match run_thread_item_icon_decorations_visual_tests(app_state.clone(), &mut cx, update_baseline)
557 {
558 Ok(TestResult::Passed) => {
559 println!("✓ thread_item_icon_decorations: PASSED");
560 passed += 1;
561 }
562 Ok(TestResult::BaselineUpdated(_)) => {
563 println!("✓ thread_item_icon_decorations: Baseline updated");
564 updated += 1;
565 }
566 Err(e) => {
567 eprintln!("✗ thread_item_icon_decorations: FAILED - {}", e);
568 failed += 1;
569 }
570 }
571
572 // Run Test: Sidebar with duplicate project names
573 println!("\n--- Test: sidebar_duplicate_names ---");
574 match run_sidebar_duplicate_project_names_visual_tests(
575 app_state.clone(),
576 &mut cx,
577 update_baseline,
578 ) {
579 Ok(TestResult::Passed) => {
580 println!("✓ sidebar_duplicate_names: PASSED");
581 passed += 1;
582 }
583 Ok(TestResult::BaselineUpdated(_)) => {
584 println!("✓ sidebar_duplicate_names: Baselines updated");
585 updated += 1;
586 }
587 Err(e) => {
588 eprintln!("✗ sidebar_duplicate_names: FAILED - {}", e);
589 failed += 1;
590 }
591 }
592
593 // Run Test 9: Tool Permissions Settings UI visual test
594 println!("\n--- Test 9: tool_permissions_settings ---");
595 match run_tool_permissions_visual_tests(app_state.clone(), &mut cx, update_baseline) {
596 Ok(TestResult::Passed) => {
597 println!("✓ tool_permissions_settings: PASSED");
598 passed += 1;
599 }
600 Ok(TestResult::BaselineUpdated(_)) => {
601 println!("✓ tool_permissions_settings: Baselines updated");
602 updated += 1;
603 }
604 Err(e) => {
605 eprintln!("✗ tool_permissions_settings: FAILED - {}", e);
606 failed += 1;
607 }
608 }
609
610 // Run Test 10: Settings UI sub-page auto-open visual tests
611 println!("\n--- Test 10: settings_ui_subpage_auto_open (2 variants) ---");
612 match run_settings_ui_subpage_visual_tests(app_state.clone(), &mut cx, update_baseline) {
613 Ok(TestResult::Passed) => {
614 println!("✓ settings_ui_subpage_auto_open: PASSED");
615 passed += 1;
616 }
617 Ok(TestResult::BaselineUpdated(_)) => {
618 println!("✓ settings_ui_subpage_auto_open: Baselines updated");
619 updated += 1;
620 }
621 Err(e) => {
622 eprintln!("✗ settings_ui_subpage_auto_open: FAILED - {}", e);
623 failed += 1;
624 }
625 }
626
627 // Clean up the main workspace's worktree to stop background scanning tasks
628 // This prevents "root path could not be canonicalized" errors when main() drops temp_dir
629 workspace_window
630 .update(&mut cx, |workspace, _window, cx| {
631 let project = workspace.project().clone();
632 project.update(cx, |project, cx| {
633 let worktree_ids: Vec<_> =
634 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
635 for id in worktree_ids {
636 project.remove_worktree(id, cx);
637 }
638 });
639 })
640 .log_err();
641
642 cx.run_until_parked();
643
644 // Close the main window
645 cx.update_window(workspace_window.into(), |_, window, _cx| {
646 window.remove_window();
647 })
648 .log_err();
649
650 // Run until all cleanup tasks complete
651 cx.run_until_parked();
652
653 // Give background tasks time to finish, including scrollbar hide timers (1 second)
654 for _ in 0..15 {
655 cx.advance_clock(Duration::from_millis(100));
656 cx.run_until_parked();
657 }
658
659 // Print summary
660 println!("\n=== Test Summary ===");
661 println!("Passed: {}", passed);
662 println!("Failed: {}", failed);
663 if updated > 0 {
664 println!("Baselines Updated: {}", updated);
665 }
666
667 if failed > 0 {
668 eprintln!("\n=== Visual Tests FAILED ===");
669 Err(anyhow::anyhow!("{} tests failed", failed))
670 } else {
671 println!("\n=== All Visual Tests PASSED ===");
672 Ok(())
673 }
674}
675
676#[cfg(target_os = "macos")]
677enum TestResult {
678 Passed,
679 BaselineUpdated(PathBuf),
680}
681
682#[cfg(target_os = "macos")]
683fn run_visual_test(
684 test_name: &str,
685 window: gpui::AnyWindowHandle,
686 cx: &mut VisualTestAppContext,
687 update_baseline: bool,
688) -> Result<TestResult> {
689 // Ensure all pending work is done
690 cx.run_until_parked();
691
692 // Refresh the window to ensure it's fully rendered
693 cx.update_window(window, |_, window, _cx| {
694 window.refresh();
695 })?;
696
697 cx.run_until_parked();
698
699 // Capture the screenshot using direct texture capture
700 let screenshot = cx.capture_screenshot(window)?;
701
702 // Get paths
703 let baseline_path = get_baseline_path(test_name);
704 let output_dir = std::env::var("VISUAL_TEST_OUTPUT_DIR")
705 .unwrap_or_else(|_| "target/visual_tests".to_string());
706 let output_path = PathBuf::from(&output_dir).join(format!("{}.png", test_name));
707
708 // Ensure output directory exists
709 std::fs::create_dir_all(&output_dir)?;
710
711 // Always save the current screenshot
712 screenshot.save(&output_path)?;
713 println!(" Screenshot saved to: {}", output_path.display());
714
715 if update_baseline {
716 // Update the baseline
717 if let Some(parent) = baseline_path.parent() {
718 std::fs::create_dir_all(parent)?;
719 }
720 screenshot.save(&baseline_path)?;
721 println!(" Baseline updated: {}", baseline_path.display());
722 return Ok(TestResult::BaselineUpdated(baseline_path));
723 }
724
725 // Compare with baseline
726 if !baseline_path.exists() {
727 return Err(anyhow::anyhow!(
728 "Baseline not found: {}. Run with UPDATE_BASELINE=1 to create it.",
729 baseline_path.display()
730 ));
731 }
732
733 let baseline = image::open(&baseline_path)?.to_rgba8();
734 let comparison = compare_images(&screenshot, &baseline);
735
736 println!(
737 " Match: {:.2}% ({} different pixels)",
738 comparison.match_percentage * 100.0,
739 comparison.diff_pixel_count
740 );
741
742 if comparison.match_percentage >= MATCH_THRESHOLD {
743 Ok(TestResult::Passed)
744 } else {
745 // Save diff image
746 let diff_path = PathBuf::from(&output_dir).join(format!("{}_diff.png", test_name));
747 comparison.diff_image.save(&diff_path)?;
748 println!(" Diff image saved to: {}", diff_path.display());
749
750 Err(anyhow::anyhow!(
751 "Image mismatch: {:.2}% match (threshold: {:.2}%)",
752 comparison.match_percentage * 100.0,
753 MATCH_THRESHOLD * 100.0
754 ))
755 }
756}
757
758#[cfg(target_os = "macos")]
759fn get_baseline_path(test_name: &str) -> PathBuf {
760 // Get the workspace root (where Cargo.toml is)
761 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
762 let workspace_root = PathBuf::from(manifest_dir)
763 .parent()
764 .and_then(|p| p.parent())
765 .map(|p| p.to_path_buf())
766 .unwrap_or_else(|| PathBuf::from("."));
767
768 workspace_root
769 .join(BASELINE_DIR)
770 .join(format!("{}.png", test_name))
771}
772
773#[cfg(target_os = "macos")]
774struct ImageComparison {
775 match_percentage: f64,
776 diff_image: RgbaImage,
777 diff_pixel_count: u32,
778 #[allow(dead_code)]
779 total_pixels: u32,
780}
781
782#[cfg(target_os = "macos")]
783fn compare_images(actual: &RgbaImage, expected: &RgbaImage) -> ImageComparison {
784 let width = actual.width().max(expected.width());
785 let height = actual.height().max(expected.height());
786 let total_pixels = width * height;
787
788 let mut diff_image = RgbaImage::new(width, height);
789 let mut matching_pixels = 0u32;
790
791 for y in 0..height {
792 for x in 0..width {
793 let actual_pixel = if x < actual.width() && y < actual.height() {
794 *actual.get_pixel(x, y)
795 } else {
796 image::Rgba([0, 0, 0, 0])
797 };
798
799 let expected_pixel = if x < expected.width() && y < expected.height() {
800 *expected.get_pixel(x, y)
801 } else {
802 image::Rgba([0, 0, 0, 0])
803 };
804
805 if pixels_are_similar(&actual_pixel, &expected_pixel) {
806 matching_pixels += 1;
807 // Semi-transparent green for matching pixels
808 diff_image.put_pixel(x, y, image::Rgba([0, 255, 0, 64]));
809 } else {
810 // Bright red for differing pixels
811 diff_image.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
812 }
813 }
814 }
815
816 let match_percentage = matching_pixels as f64 / total_pixels as f64;
817 let diff_pixel_count = total_pixels - matching_pixels;
818
819 ImageComparison {
820 match_percentage,
821 diff_image,
822 diff_pixel_count,
823 total_pixels,
824 }
825}
826
827#[cfg(target_os = "macos")]
828fn pixels_are_similar(a: &image::Rgba<u8>, b: &image::Rgba<u8>) -> bool {
829 const TOLERANCE: i16 = 2;
830 (a.0[0] as i16 - b.0[0] as i16).abs() <= TOLERANCE
831 && (a.0[1] as i16 - b.0[1] as i16).abs() <= TOLERANCE
832 && (a.0[2] as i16 - b.0[2] as i16).abs() <= TOLERANCE
833 && (a.0[3] as i16 - b.0[3] as i16).abs() <= TOLERANCE
834}
835
836#[cfg(target_os = "macos")]
837fn create_test_files(project_path: &Path) {
838 // Create src directory
839 let src_dir = project_path.join("src");
840 std::fs::create_dir_all(&src_dir).expect("Failed to create src directory");
841
842 // Create main.rs
843 let main_rs = r#"fn main() {
844 println!("Hello, world!");
845
846 let x = 42;
847 let y = x * 2;
848
849 if y > 50 {
850 println!("y is greater than 50");
851 } else {
852 println!("y is not greater than 50");
853 }
854
855 for i in 0..10 {
856 println!("i = {}", i);
857 }
858}
859
860fn helper_function(a: i32, b: i32) -> i32 {
861 a + b
862}
863
864struct MyStruct {
865 field1: String,
866 field2: i32,
867}
868
869impl MyStruct {
870 fn new(name: &str, value: i32) -> Self {
871 Self {
872 field1: name.to_string(),
873 field2: value,
874 }
875 }
876
877 fn get_value(&self) -> i32 {
878 self.field2
879 }
880}
881"#;
882 std::fs::write(src_dir.join("main.rs"), main_rs).expect("Failed to write main.rs");
883
884 // Create lib.rs
885 let lib_rs = r#"//! A sample library for visual testing
886
887pub mod utils;
888
889/// A public function in the library
890pub fn library_function() -> String {
891 "Hello from lib".to_string()
892}
893
894#[cfg(test)]
895mod tests {
896 use super::*;
897
898 #[test]
899 fn it_works() {
900 assert_eq!(library_function(), "Hello from lib");
901 }
902}
903"#;
904 std::fs::write(src_dir.join("lib.rs"), lib_rs).expect("Failed to write lib.rs");
905
906 // Create utils.rs
907 let utils_rs = r#"//! Utility functions
908
909/// Format a number with commas
910pub fn format_number(n: u64) -> String {
911 let s = n.to_string();
912 let mut result = String::new();
913 for (i, c) in s.chars().rev().enumerate() {
914 if i > 0 && i % 3 == 0 {
915 result.push(',');
916 }
917 result.push(c);
918 }
919 result.chars().rev().collect()
920}
921
922/// Calculate fibonacci number
923pub fn fibonacci(n: u32) -> u64 {
924 match n {
925 0 => 0,
926 1 => 1,
927 _ => fibonacci(n - 1) + fibonacci(n - 2),
928 }
929}
930"#;
931 std::fs::write(src_dir.join("utils.rs"), utils_rs).expect("Failed to write utils.rs");
932
933 // Create Cargo.toml
934 let cargo_toml = r#"[package]
935name = "test_project"
936version = "0.1.0"
937edition = "2021"
938
939[dependencies]
940"#;
941 std::fs::write(project_path.join("Cargo.toml"), cargo_toml)
942 .expect("Failed to write Cargo.toml");
943
944 // Create README.md
945 let readme = r#"# Test Project
946
947This is a test project for visual testing of Zed.
948
949## Features
950
951- Feature 1
952- Feature 2
953- Feature 3
954
955## Usage
956
957```bash
958cargo run
959```
960"#;
961 std::fs::write(project_path.join("README.md"), readme).expect("Failed to write README.md");
962}
963
964#[cfg(target_os = "macos")]
965fn init_app_state(cx: &mut App) -> Arc<AppState> {
966 use fs::Fs;
967 use node_runtime::NodeRuntime;
968 use session::Session;
969 use settings::SettingsStore;
970
971 if !cx.has_global::<SettingsStore>() {
972 let settings_store = SettingsStore::test(cx);
973 cx.set_global(settings_store);
974 }
975
976 // Use the real filesystem instead of FakeFs so we can access actual files on disk
977 let fs: Arc<dyn Fs> = Arc::new(fs::RealFs::new(None, cx.background_executor().clone()));
978 <dyn Fs>::set_global(fs.clone(), cx);
979
980 let languages = Arc::new(language::LanguageRegistry::test(
981 cx.background_executor().clone(),
982 ));
983 let clock = Arc::new(clock::FakeSystemClock::new());
984 let http_client = http_client::FakeHttpClient::with_404_response();
985 let client = client::Client::new(clock, http_client, cx);
986 let session = cx.new(|cx| session::AppSession::new(Session::test(), cx));
987 let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx));
988 let workspace_store = cx.new(|cx| workspace::WorkspaceStore::new(client.clone(), cx));
989
990 theme_settings::init(theme::LoadThemes::JustBase, cx);
991 client::init(&client, cx);
992
993 let app_state = Arc::new(AppState {
994 client,
995 fs,
996 languages,
997 user_store,
998 workspace_store,
999 node_runtime: NodeRuntime::unavailable(),
1000 build_window_options: |_, _| Default::default(),
1001 session,
1002 });
1003 AppState::set_global(app_state.clone(), cx);
1004 app_state
1005}
1006
1007/// Runs visual tests for breakpoint hover states in the editor gutter.
1008///
1009/// This test captures three states:
1010/// 1. Gutter with line numbers, no breakpoint hover (baseline)
1011/// 2. Gutter with breakpoint hover indicator (gray circle)
1012/// 3. Gutter with breakpoint hover AND tooltip
1013#[cfg(target_os = "macos")]
1014fn run_breakpoint_hover_visual_tests(
1015 app_state: Arc<AppState>,
1016 cx: &mut VisualTestAppContext,
1017 update_baseline: bool,
1018) -> Result<TestResult> {
1019 // Create a temporary directory with a simple test file
1020 let temp_dir = tempfile::tempdir()?;
1021 let temp_path = temp_dir.keep();
1022 let canonical_temp = temp_path.canonicalize()?;
1023 let project_path = canonical_temp.join("project");
1024 std::fs::create_dir_all(&project_path)?;
1025
1026 // Create a simple file with a few lines
1027 let src_dir = project_path.join("src");
1028 std::fs::create_dir_all(&src_dir)?;
1029
1030 let test_content = r#"fn main() {
1031 println!("Hello");
1032 let x = 42;
1033}
1034"#;
1035 std::fs::write(src_dir.join("test.rs"), test_content)?;
1036
1037 // Create a small window - just big enough to show gutter and a few lines
1038 let window_size = size(px(300.0), px(200.0));
1039 let bounds = Bounds {
1040 origin: point(px(0.0), px(0.0)),
1041 size: window_size,
1042 };
1043
1044 // Create project
1045 let project = cx.update(|cx| {
1046 project::Project::local(
1047 app_state.client.clone(),
1048 app_state.node_runtime.clone(),
1049 app_state.user_store.clone(),
1050 app_state.languages.clone(),
1051 app_state.fs.clone(),
1052 None,
1053 project::LocalProjectFlags {
1054 init_worktree_trust: false,
1055 ..Default::default()
1056 },
1057 cx,
1058 )
1059 });
1060
1061 // Open workspace window
1062 let workspace_window: WindowHandle<Workspace> = cx
1063 .update(|cx| {
1064 cx.open_window(
1065 WindowOptions {
1066 window_bounds: Some(WindowBounds::Windowed(bounds)),
1067 focus: false,
1068 show: false,
1069 ..Default::default()
1070 },
1071 |window, cx| {
1072 cx.new(|cx| {
1073 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
1074 })
1075 },
1076 )
1077 })
1078 .context("Failed to open breakpoint test window")?;
1079
1080 cx.run_until_parked();
1081
1082 // Add the project as a worktree
1083 let add_worktree_task = workspace_window
1084 .update(cx, |workspace, _window, cx| {
1085 let project = workspace.project().clone();
1086 project.update(cx, |project, cx| {
1087 project.find_or_create_worktree(&project_path, true, cx)
1088 })
1089 })
1090 .context("Failed to start adding worktree")?;
1091
1092 cx.background_executor.allow_parking();
1093 let worktree_result = cx.foreground_executor.block_test(add_worktree_task);
1094 cx.background_executor.forbid_parking();
1095 worktree_result.context("Failed to add worktree")?;
1096
1097 cx.run_until_parked();
1098
1099 // Open the test file
1100 let open_file_task = workspace_window
1101 .update(cx, |workspace, window, cx| {
1102 let worktree = workspace.project().read(cx).worktrees(cx).next();
1103 if let Some(worktree) = worktree {
1104 let worktree_id = worktree.read(cx).id();
1105 let rel_path: std::sync::Arc<util::rel_path::RelPath> =
1106 util::rel_path::rel_path("src/test.rs").into();
1107 let project_path: project::ProjectPath = (worktree_id, rel_path).into();
1108 Some(workspace.open_path(project_path, None, true, window, cx))
1109 } else {
1110 None
1111 }
1112 })
1113 .log_err()
1114 .flatten();
1115
1116 if let Some(task) = open_file_task {
1117 cx.background_executor.allow_parking();
1118 cx.foreground_executor.block_test(task).log_err();
1119 cx.background_executor.forbid_parking();
1120 }
1121
1122 cx.run_until_parked();
1123
1124 // Wait for the editor to fully load
1125 for _ in 0..10 {
1126 cx.advance_clock(Duration::from_millis(100));
1127 cx.run_until_parked();
1128 }
1129
1130 // Refresh window
1131 cx.update_window(workspace_window.into(), |_, window, _cx| {
1132 window.refresh();
1133 })?;
1134
1135 cx.run_until_parked();
1136
1137 // Test 1: Gutter visible with line numbers, no breakpoint hover
1138 let test1_result = run_visual_test(
1139 "breakpoint_hover_none",
1140 workspace_window.into(),
1141 cx,
1142 update_baseline,
1143 )?;
1144
1145 // Test 2: Breakpoint hover indicator (circle) visible
1146 // The gutter is on the left side. We need to position the mouse over the gutter area
1147 // for line 1. The breakpoint indicator appears in the leftmost part of the gutter.
1148 //
1149 // The breakpoint hover requires multiple steps:
1150 // 1. Draw to register mouse listeners
1151 // 2. Mouse move to trigger gutter_hovered and create GutterHoverButton
1152 // 3. Wait 200ms for is_active to become true
1153 // 4. Draw again to render the indicator
1154 //
1155 // The gutter_position should be in the gutter area to trigger the gutter hover button.
1156 // The button_position should be directly over the breakpoint icon button for tooltip hover.
1157 // Based on debug output: button is at origin=(3.12, 66.5) with size=(14, 16)
1158 let gutter_position = point(px(30.0), px(85.0));
1159 let button_position = point(px(10.0), px(75.0)); // Center of the breakpoint button
1160
1161 // Step 1: Initial draw to register mouse listeners
1162 cx.update_window(workspace_window.into(), |_, window, cx| {
1163 window.draw(cx).clear();
1164 })?;
1165 cx.run_until_parked();
1166
1167 // Step 2: Simulate mouse move into gutter area
1168 cx.simulate_mouse_move(
1169 workspace_window.into(),
1170 gutter_position,
1171 None,
1172 Modifiers::default(),
1173 );
1174
1175 // Step 3: Advance clock past 200ms debounce
1176 cx.advance_clock(Duration::from_millis(300));
1177 cx.run_until_parked();
1178
1179 // Step 4: Draw again to pick up the indicator state change
1180 cx.update_window(workspace_window.into(), |_, window, cx| {
1181 window.draw(cx).clear();
1182 })?;
1183 cx.run_until_parked();
1184
1185 // Step 5: Another mouse move to keep hover state active
1186 cx.simulate_mouse_move(
1187 workspace_window.into(),
1188 gutter_position,
1189 None,
1190 Modifiers::default(),
1191 );
1192
1193 // Step 6: Final draw
1194 cx.update_window(workspace_window.into(), |_, window, cx| {
1195 window.draw(cx).clear();
1196 })?;
1197 cx.run_until_parked();
1198
1199 let test2_result = run_visual_test(
1200 "breakpoint_hover_circle",
1201 workspace_window.into(),
1202 cx,
1203 update_baseline,
1204 )?;
1205
1206 // Test 3: Breakpoint hover with tooltip visible
1207 // The tooltip delay is 500ms (TOOLTIP_SHOW_DELAY constant)
1208 // We need to position the mouse directly over the breakpoint button for the tooltip to show.
1209 // The button hitbox is approximately at (3.12, 66.5) with size (14, 16).
1210
1211 // Move mouse directly over the button to trigger tooltip hover
1212 cx.simulate_mouse_move(
1213 workspace_window.into(),
1214 button_position,
1215 None,
1216 Modifiers::default(),
1217 );
1218
1219 // Draw to register the button's tooltip hover listener
1220 cx.update_window(workspace_window.into(), |_, window, cx| {
1221 window.draw(cx).clear();
1222 })?;
1223 cx.run_until_parked();
1224
1225 // Move mouse over button again to trigger tooltip scheduling
1226 cx.simulate_mouse_move(
1227 workspace_window.into(),
1228 button_position,
1229 None,
1230 Modifiers::default(),
1231 );
1232
1233 // Advance clock past TOOLTIP_SHOW_DELAY (500ms)
1234 cx.advance_clock(TOOLTIP_SHOW_DELAY + Duration::from_millis(100));
1235 cx.run_until_parked();
1236
1237 // Draw to render the tooltip
1238 cx.update_window(workspace_window.into(), |_, window, cx| {
1239 window.draw(cx).clear();
1240 })?;
1241 cx.run_until_parked();
1242
1243 // Refresh window
1244 cx.update_window(workspace_window.into(), |_, window, _cx| {
1245 window.refresh();
1246 })?;
1247
1248 cx.run_until_parked();
1249
1250 let test3_result = run_visual_test(
1251 "breakpoint_hover_tooltip",
1252 workspace_window.into(),
1253 cx,
1254 update_baseline,
1255 )?;
1256
1257 // Clean up: remove worktrees to stop background scanning
1258 workspace_window
1259 .update(cx, |workspace, _window, cx| {
1260 let project = workspace.project().clone();
1261 project.update(cx, |project, cx| {
1262 let worktree_ids: Vec<_> =
1263 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
1264 for id in worktree_ids {
1265 project.remove_worktree(id, cx);
1266 }
1267 });
1268 })
1269 .log_err();
1270
1271 cx.run_until_parked();
1272
1273 // Close the window
1274 cx.update_window(workspace_window.into(), |_, window, _cx| {
1275 window.remove_window();
1276 })
1277 .log_err();
1278
1279 cx.run_until_parked();
1280
1281 // Give background tasks time to finish
1282 for _ in 0..15 {
1283 cx.advance_clock(Duration::from_millis(100));
1284 cx.run_until_parked();
1285 }
1286
1287 // Return combined result
1288 match (&test1_result, &test2_result, &test3_result) {
1289 (TestResult::Passed, TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
1290 (TestResult::BaselineUpdated(p), _, _)
1291 | (_, TestResult::BaselineUpdated(p), _)
1292 | (_, _, TestResult::BaselineUpdated(p)) => Ok(TestResult::BaselineUpdated(p.clone())),
1293 }
1294}
1295
1296/// Runs visual tests for the settings UI sub-page auto-open feature.
1297///
1298/// This test verifies that when opening settings via OpenSettingsAt with a path
1299/// that maps to a single SubPageLink, the sub-page is automatically opened.
1300///
1301/// This test captures two states:
1302/// 1. Settings opened with a path that maps to multiple items (no auto-open)
1303/// 2. Settings opened with a path that maps to a single SubPageLink (auto-opens sub-page)
1304#[cfg(target_os = "macos")]
1305fn run_settings_ui_subpage_visual_tests(
1306 app_state: Arc<AppState>,
1307 cx: &mut VisualTestAppContext,
1308 update_baseline: bool,
1309) -> Result<TestResult> {
1310 // Create a workspace window for dispatching actions
1311 let window_size = size(px(1280.0), px(800.0));
1312 let bounds = Bounds {
1313 origin: point(px(0.0), px(0.0)),
1314 size: window_size,
1315 };
1316
1317 let project = cx.update(|cx| {
1318 project::Project::local(
1319 app_state.client.clone(),
1320 app_state.node_runtime.clone(),
1321 app_state.user_store.clone(),
1322 app_state.languages.clone(),
1323 app_state.fs.clone(),
1324 None,
1325 project::LocalProjectFlags {
1326 init_worktree_trust: false,
1327 ..Default::default()
1328 },
1329 cx,
1330 )
1331 });
1332
1333 let workspace_window: WindowHandle<MultiWorkspace> = cx
1334 .update(|cx| {
1335 cx.open_window(
1336 WindowOptions {
1337 window_bounds: Some(WindowBounds::Windowed(bounds)),
1338 focus: false,
1339 show: false,
1340 ..Default::default()
1341 },
1342 |window, cx| {
1343 let workspace = cx.new(|cx| {
1344 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
1345 });
1346 cx.new(|cx| MultiWorkspace::new(workspace, window, cx))
1347 },
1348 )
1349 })
1350 .context("Failed to open workspace window")?;
1351
1352 cx.run_until_parked();
1353
1354 // Test 1: Open settings with a path that maps to multiple items (e.g., "agent")
1355 // This should NOT auto-open a sub-page since multiple items match
1356 workspace_window
1357 .update(cx, |_workspace, window, cx| {
1358 window.dispatch_action(
1359 Box::new(OpenSettingsAt {
1360 path: "agent".to_string(),
1361 }),
1362 cx,
1363 );
1364 })
1365 .context("Failed to dispatch OpenSettingsAt for multiple items")?;
1366
1367 cx.run_until_parked();
1368
1369 // Find the settings window
1370 let settings_window_1 = cx
1371 .update(|cx| {
1372 cx.windows()
1373 .into_iter()
1374 .find_map(|window| window.downcast::<SettingsWindow>())
1375 })
1376 .context("Settings window not found")?;
1377
1378 // Refresh and capture screenshot
1379 cx.update_window(settings_window_1.into(), |_, window, _cx| {
1380 window.refresh();
1381 })?;
1382 cx.run_until_parked();
1383
1384 let test1_result = run_visual_test(
1385 "settings_ui_no_auto_open",
1386 settings_window_1.into(),
1387 cx,
1388 update_baseline,
1389 )?;
1390
1391 // Close the settings window
1392 cx.update_window(settings_window_1.into(), |_, window, _cx| {
1393 window.remove_window();
1394 })
1395 .log_err();
1396 cx.run_until_parked();
1397
1398 // Test 2: Open settings with a path that maps to a single SubPageLink
1399 // "edit_predictions.providers" maps to the "Configure Providers" SubPageLink
1400 // This should auto-open the sub-page
1401 workspace_window
1402 .update(cx, |_workspace, window, cx| {
1403 window.dispatch_action(
1404 Box::new(OpenSettingsAt {
1405 path: "edit_predictions.providers".to_string(),
1406 }),
1407 cx,
1408 );
1409 })
1410 .context("Failed to dispatch OpenSettingsAt for single SubPageLink")?;
1411
1412 cx.run_until_parked();
1413
1414 // Find the new settings window
1415 let settings_window_2 = cx
1416 .update(|cx| {
1417 cx.windows()
1418 .into_iter()
1419 .find_map(|window| window.downcast::<SettingsWindow>())
1420 })
1421 .context("Settings window not found for sub-page test")?;
1422
1423 // Refresh and capture screenshot
1424 cx.update_window(settings_window_2.into(), |_, window, _cx| {
1425 window.refresh();
1426 })?;
1427 cx.run_until_parked();
1428
1429 let test2_result = run_visual_test(
1430 "settings_ui_subpage_auto_open",
1431 settings_window_2.into(),
1432 cx,
1433 update_baseline,
1434 )?;
1435
1436 // Clean up: close the settings window
1437 cx.update_window(settings_window_2.into(), |_, window, _cx| {
1438 window.remove_window();
1439 })
1440 .log_err();
1441 cx.run_until_parked();
1442
1443 // Clean up: close the workspace window
1444 cx.update_window(workspace_window.into(), |_, window, _cx| {
1445 window.remove_window();
1446 })
1447 .log_err();
1448 cx.run_until_parked();
1449
1450 // Give background tasks time to finish
1451 for _ in 0..5 {
1452 cx.advance_clock(Duration::from_millis(100));
1453 cx.run_until_parked();
1454 }
1455
1456 // Return combined result
1457 match (&test1_result, &test2_result) {
1458 (TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
1459 (TestResult::BaselineUpdated(p), _) | (_, TestResult::BaselineUpdated(p)) => {
1460 Ok(TestResult::BaselineUpdated(p.clone()))
1461 }
1462 }
1463}
1464
1465/// Runs visual tests for the diff review button in git diff views.
1466///
1467/// This test captures three states:
1468/// 1. Diff view with feature flag enabled (button visible)
1469/// 2. Diff view with feature flag disabled (no button)
1470/// 3. Regular editor with feature flag enabled (no button - only shows in diff views)
1471#[cfg(target_os = "macos")]
1472fn run_diff_review_visual_tests(
1473 app_state: Arc<AppState>,
1474 cx: &mut VisualTestAppContext,
1475 update_baseline: bool,
1476) -> Result<TestResult> {
1477 // Create a temporary directory with test files and a real git repo
1478 let temp_dir = tempfile::tempdir()?;
1479 let temp_path = temp_dir.keep();
1480 let canonical_temp = temp_path.canonicalize()?;
1481 let project_path = canonical_temp.join("project");
1482 std::fs::create_dir_all(&project_path)?;
1483
1484 // Initialize a real git repository
1485 std::process::Command::new("git")
1486 .args(["init"])
1487 .current_dir(&project_path)
1488 .output()?;
1489
1490 // Configure git user for commits
1491 std::process::Command::new("git")
1492 .args(["config", "user.email", "test@test.com"])
1493 .current_dir(&project_path)
1494 .output()?;
1495 std::process::Command::new("git")
1496 .args(["config", "user.name", "Test User"])
1497 .current_dir(&project_path)
1498 .output()?;
1499
1500 // Create a test file with original content
1501 let original_content = "// Original content\n";
1502 std::fs::write(project_path.join("thread-view.tsx"), original_content)?;
1503
1504 // Commit the original file
1505 std::process::Command::new("git")
1506 .args(["add", "thread-view.tsx"])
1507 .current_dir(&project_path)
1508 .output()?;
1509 std::process::Command::new("git")
1510 .args(["commit", "-m", "Initial commit"])
1511 .current_dir(&project_path)
1512 .output()?;
1513
1514 // Modify the file to create a diff
1515 let modified_content = r#"import { ScrollArea } from 'components';
1516import { ButtonAlt, Tooltip } from 'ui';
1517import { Message, FileEdit } from 'types';
1518import { AiPaneTabContext } from 'context';
1519"#;
1520 std::fs::write(project_path.join("thread-view.tsx"), modified_content)?;
1521
1522 // Create window for the diff view - sized to show just the editor
1523 let window_size = size(px(600.0), px(400.0));
1524 let bounds = Bounds {
1525 origin: point(px(0.0), px(0.0)),
1526 size: window_size,
1527 };
1528
1529 // Create project
1530 let project = cx.update(|cx| {
1531 project::Project::local(
1532 app_state.client.clone(),
1533 app_state.node_runtime.clone(),
1534 app_state.user_store.clone(),
1535 app_state.languages.clone(),
1536 app_state.fs.clone(),
1537 None,
1538 project::LocalProjectFlags {
1539 init_worktree_trust: false,
1540 ..Default::default()
1541 },
1542 cx,
1543 )
1544 });
1545
1546 // Add the test directory as a worktree
1547 let add_worktree_task = project.update(cx, |project, cx| {
1548 project.find_or_create_worktree(&project_path, true, cx)
1549 });
1550
1551 cx.background_executor.allow_parking();
1552 cx.foreground_executor
1553 .block_test(add_worktree_task)
1554 .log_err();
1555 cx.background_executor.forbid_parking();
1556
1557 cx.run_until_parked();
1558
1559 // Wait for worktree to be fully scanned and git status to be detected
1560 for _ in 0..5 {
1561 cx.advance_clock(Duration::from_millis(100));
1562 cx.run_until_parked();
1563 }
1564
1565 // Test 1: Diff view with feature flag enabled
1566 // Enable the feature flag
1567 cx.update(|cx| {
1568 cx.update_flags(true, vec!["diff-review".to_string()]);
1569 });
1570
1571 let workspace_window: WindowHandle<Workspace> = cx
1572 .update(|cx| {
1573 cx.open_window(
1574 WindowOptions {
1575 window_bounds: Some(WindowBounds::Windowed(bounds)),
1576 focus: false,
1577 show: false,
1578 ..Default::default()
1579 },
1580 |window, cx| {
1581 cx.new(|cx| {
1582 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
1583 })
1584 },
1585 )
1586 })
1587 .context("Failed to open diff review test window")?;
1588
1589 cx.run_until_parked();
1590
1591 // Create and add the ProjectDiff using the public deploy_at method
1592 workspace_window
1593 .update(cx, |workspace, window, cx| {
1594 ProjectDiff::deploy_at(workspace, None, window, cx);
1595 })
1596 .log_err();
1597
1598 // Wait for diff to render
1599 for _ in 0..5 {
1600 cx.advance_clock(Duration::from_millis(100));
1601 cx.run_until_parked();
1602 }
1603
1604 // Refresh window
1605 cx.update_window(workspace_window.into(), |_, window, _cx| {
1606 window.refresh();
1607 })?;
1608
1609 cx.run_until_parked();
1610
1611 // Capture Test 1: Diff with flag enabled
1612 let test1_result = run_visual_test(
1613 "diff_review_button_enabled",
1614 workspace_window.into(),
1615 cx,
1616 update_baseline,
1617 )?;
1618
1619 // Test 2: Diff view with feature flag disabled
1620 // Disable the feature flag
1621 cx.update(|cx| {
1622 cx.update_flags(false, vec![]);
1623 });
1624
1625 // Refresh window
1626 cx.update_window(workspace_window.into(), |_, window, _cx| {
1627 window.refresh();
1628 })?;
1629
1630 for _ in 0..3 {
1631 cx.advance_clock(Duration::from_millis(100));
1632 cx.run_until_parked();
1633 }
1634
1635 // Capture Test 2: Diff with flag disabled
1636 let test2_result = run_visual_test(
1637 "diff_review_button_disabled",
1638 workspace_window.into(),
1639 cx,
1640 update_baseline,
1641 )?;
1642
1643 // Test 3: Regular editor with flag enabled (should NOT show button)
1644 // Re-enable the feature flag
1645 cx.update(|cx| {
1646 cx.update_flags(true, vec!["diff-review".to_string()]);
1647 });
1648
1649 // Create a new window with just a regular editor
1650 let regular_window: WindowHandle<Workspace> = cx
1651 .update(|cx| {
1652 cx.open_window(
1653 WindowOptions {
1654 window_bounds: Some(WindowBounds::Windowed(bounds)),
1655 focus: false,
1656 show: false,
1657 ..Default::default()
1658 },
1659 |window, cx| {
1660 cx.new(|cx| {
1661 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
1662 })
1663 },
1664 )
1665 })
1666 .context("Failed to open regular editor window")?;
1667
1668 cx.run_until_parked();
1669
1670 // Open a regular file (not a diff view)
1671 let open_file_task = regular_window
1672 .update(cx, |workspace, window, cx| {
1673 let worktree = workspace.project().read(cx).worktrees(cx).next();
1674 if let Some(worktree) = worktree {
1675 let worktree_id = worktree.read(cx).id();
1676 let rel_path: std::sync::Arc<util::rel_path::RelPath> =
1677 util::rel_path::rel_path("thread-view.tsx").into();
1678 let project_path: project::ProjectPath = (worktree_id, rel_path).into();
1679 Some(workspace.open_path(project_path, None, true, window, cx))
1680 } else {
1681 None
1682 }
1683 })
1684 .log_err()
1685 .flatten();
1686
1687 if let Some(task) = open_file_task {
1688 cx.background_executor.allow_parking();
1689 cx.foreground_executor.block_test(task).log_err();
1690 cx.background_executor.forbid_parking();
1691 }
1692
1693 // Wait for file to open
1694 for _ in 0..3 {
1695 cx.advance_clock(Duration::from_millis(100));
1696 cx.run_until_parked();
1697 }
1698
1699 // Refresh window
1700 cx.update_window(regular_window.into(), |_, window, _cx| {
1701 window.refresh();
1702 })?;
1703
1704 cx.run_until_parked();
1705
1706 // Capture Test 3: Regular editor with flag enabled (no button)
1707 let test3_result = run_visual_test(
1708 "diff_review_button_regular_editor",
1709 regular_window.into(),
1710 cx,
1711 update_baseline,
1712 )?;
1713
1714 // Test 4: Show the diff review overlay on the regular editor
1715 regular_window
1716 .update(cx, |workspace, window, cx| {
1717 // Get the first editor from the workspace
1718 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1719 if let Some(editor) = editors.into_iter().next() {
1720 editor.update(cx, |editor, cx| {
1721 editor.show_diff_review_overlay(DisplayRow(1)..DisplayRow(1), window, cx);
1722 });
1723 }
1724 })
1725 .log_err();
1726
1727 // Wait for overlay to render
1728 for _ in 0..3 {
1729 cx.advance_clock(Duration::from_millis(100));
1730 cx.run_until_parked();
1731 }
1732
1733 // Refresh window
1734 cx.update_window(regular_window.into(), |_, window, _cx| {
1735 window.refresh();
1736 })?;
1737
1738 cx.run_until_parked();
1739
1740 // Capture Test 4: Regular editor with overlay shown
1741 let test4_result = run_visual_test(
1742 "diff_review_overlay_shown",
1743 regular_window.into(),
1744 cx,
1745 update_baseline,
1746 )?;
1747
1748 // Test 5: Type text into the diff review prompt and submit it
1749 // First, get the prompt editor from the overlay and type some text
1750 regular_window
1751 .update(cx, |workspace, window, cx| {
1752 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1753 if let Some(editor) = editors.into_iter().next() {
1754 editor.update(cx, |editor, cx| {
1755 // Get the prompt editor from the overlay and insert text
1756 if let Some(prompt_editor) = editor.diff_review_prompt_editor().cloned() {
1757 prompt_editor.update(cx, |prompt_editor: &mut editor::Editor, cx| {
1758 prompt_editor.insert(
1759 "This change needs better error handling",
1760 window,
1761 cx,
1762 );
1763 });
1764 }
1765 });
1766 }
1767 })
1768 .log_err();
1769
1770 // Wait for text to be inserted
1771 for _ in 0..3 {
1772 cx.advance_clock(Duration::from_millis(100));
1773 cx.run_until_parked();
1774 }
1775
1776 // Refresh window
1777 cx.update_window(regular_window.into(), |_, window, _cx| {
1778 window.refresh();
1779 })?;
1780
1781 cx.run_until_parked();
1782
1783 // Capture Test 5: Diff review overlay with typed text
1784 let test5_result = run_visual_test(
1785 "diff_review_overlay_with_text",
1786 regular_window.into(),
1787 cx,
1788 update_baseline,
1789 )?;
1790
1791 // Test 6: Submit a comment to store it locally
1792 regular_window
1793 .update(cx, |workspace, window, cx| {
1794 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1795 if let Some(editor) = editors.into_iter().next() {
1796 editor.update(cx, |editor, cx| {
1797 // Submit the comment that was typed in test 5
1798 editor.submit_diff_review_comment(window, cx);
1799 });
1800 }
1801 })
1802 .log_err();
1803
1804 // Wait for comment to be stored
1805 for _ in 0..3 {
1806 cx.advance_clock(Duration::from_millis(100));
1807 cx.run_until_parked();
1808 }
1809
1810 // Refresh window
1811 cx.update_window(regular_window.into(), |_, window, _cx| {
1812 window.refresh();
1813 })?;
1814
1815 cx.run_until_parked();
1816
1817 // Capture Test 6: Overlay with one stored comment
1818 let test6_result = run_visual_test(
1819 "diff_review_one_comment",
1820 regular_window.into(),
1821 cx,
1822 update_baseline,
1823 )?;
1824
1825 // Test 7: Add more comments to show multiple comments expanded
1826 regular_window
1827 .update(cx, |workspace, window, cx| {
1828 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1829 if let Some(editor) = editors.into_iter().next() {
1830 editor.update(cx, |editor, cx| {
1831 // Add second comment
1832 if let Some(prompt_editor) = editor.diff_review_prompt_editor().cloned() {
1833 prompt_editor.update(cx, |pe, cx| {
1834 pe.insert("Second comment about imports", window, cx);
1835 });
1836 }
1837 editor.submit_diff_review_comment(window, cx);
1838
1839 // Add third comment
1840 if let Some(prompt_editor) = editor.diff_review_prompt_editor().cloned() {
1841 prompt_editor.update(cx, |pe, cx| {
1842 pe.insert("Third comment about naming conventions", window, cx);
1843 });
1844 }
1845 editor.submit_diff_review_comment(window, cx);
1846 });
1847 }
1848 })
1849 .log_err();
1850
1851 // Wait for comments to be stored
1852 for _ in 0..3 {
1853 cx.advance_clock(Duration::from_millis(100));
1854 cx.run_until_parked();
1855 }
1856
1857 // Refresh window
1858 cx.update_window(regular_window.into(), |_, window, _cx| {
1859 window.refresh();
1860 })?;
1861
1862 cx.run_until_parked();
1863
1864 // Capture Test 7: Overlay with multiple comments expanded
1865 let test7_result = run_visual_test(
1866 "diff_review_multiple_comments_expanded",
1867 regular_window.into(),
1868 cx,
1869 update_baseline,
1870 )?;
1871
1872 // Test 8: Collapse the comments section
1873 regular_window
1874 .update(cx, |workspace, _window, cx| {
1875 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1876 if let Some(editor) = editors.into_iter().next() {
1877 editor.update(cx, |editor, cx| {
1878 // Toggle collapse using the public method
1879 editor.set_diff_review_comments_expanded(false, cx);
1880 });
1881 }
1882 })
1883 .log_err();
1884
1885 // Wait for UI to update
1886 for _ in 0..3 {
1887 cx.advance_clock(Duration::from_millis(100));
1888 cx.run_until_parked();
1889 }
1890
1891 // Refresh window
1892 cx.update_window(regular_window.into(), |_, window, _cx| {
1893 window.refresh();
1894 })?;
1895
1896 cx.run_until_parked();
1897
1898 // Capture Test 8: Comments collapsed
1899 let test8_result = run_visual_test(
1900 "diff_review_comments_collapsed",
1901 regular_window.into(),
1902 cx,
1903 update_baseline,
1904 )?;
1905
1906 // Clean up: remove worktrees to stop background scanning
1907 workspace_window
1908 .update(cx, |workspace, _window, cx| {
1909 let project = workspace.project().clone();
1910 project.update(cx, |project, cx| {
1911 let worktree_ids: Vec<_> =
1912 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
1913 for id in worktree_ids {
1914 project.remove_worktree(id, cx);
1915 }
1916 });
1917 })
1918 .log_err();
1919
1920 cx.run_until_parked();
1921
1922 // Close windows
1923 cx.update_window(workspace_window.into(), |_, window, _cx| {
1924 window.remove_window();
1925 })
1926 .log_err();
1927 cx.update_window(regular_window.into(), |_, window, _cx| {
1928 window.remove_window();
1929 })
1930 .log_err();
1931
1932 cx.run_until_parked();
1933
1934 // Give background tasks time to finish
1935 for _ in 0..15 {
1936 cx.advance_clock(Duration::from_millis(100));
1937 cx.run_until_parked();
1938 }
1939
1940 // Return combined result
1941 let all_results = [
1942 &test1_result,
1943 &test2_result,
1944 &test3_result,
1945 &test4_result,
1946 &test5_result,
1947 &test6_result,
1948 &test7_result,
1949 &test8_result,
1950 ];
1951
1952 // Combine results: if any test updated a baseline, return BaselineUpdated;
1953 // otherwise return Passed. The exhaustive match ensures the compiler
1954 // verifies we handle all TestResult variants.
1955 let result = all_results
1956 .iter()
1957 .fold(TestResult::Passed, |acc, r| match r {
1958 TestResult::Passed => acc,
1959 TestResult::BaselineUpdated(p) => TestResult::BaselineUpdated(p.clone()),
1960 });
1961 Ok(result)
1962}
1963
1964/// A stub AgentServer for visual testing that returns a pre-programmed connection.
1965#[derive(Clone)]
1966#[cfg(target_os = "macos")]
1967struct StubAgentServer {
1968 connection: StubAgentConnection,
1969}
1970
1971#[cfg(target_os = "macos")]
1972impl StubAgentServer {
1973 fn new(connection: StubAgentConnection) -> Self {
1974 Self { connection }
1975 }
1976}
1977
1978#[cfg(target_os = "macos")]
1979impl AgentServer for StubAgentServer {
1980 fn logo(&self) -> ui::IconName {
1981 ui::IconName::ZedAssistant
1982 }
1983
1984 fn agent_id(&self) -> AgentId {
1985 "Visual Test Agent".into()
1986 }
1987
1988 fn connect(
1989 &self,
1990 _delegate: AgentServerDelegate,
1991 _project: Entity<Project>,
1992 _cx: &mut App,
1993 ) -> gpui::Task<gpui::Result<Rc<dyn AgentConnection>>> {
1994 gpui::Task::ready(Ok(Rc::new(self.connection.clone())))
1995 }
1996
1997 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
1998 self
1999 }
2000}
2001
2002#[cfg(all(target_os = "macos", feature = "visual-tests"))]
2003fn run_agent_thread_view_test(
2004 app_state: Arc<AppState>,
2005 cx: &mut VisualTestAppContext,
2006 update_baseline: bool,
2007) -> Result<TestResult> {
2008 use agent::{AgentTool, ToolInput};
2009 use agent_ui::AgentPanel;
2010
2011 // Create a temporary directory with the test image
2012 // Canonicalize to resolve symlinks (on macOS, /var -> /private/var)
2013 // Use keep() to prevent auto-cleanup - we'll clean up manually after stopping background tasks
2014 let temp_dir = tempfile::tempdir()?;
2015 let temp_path = temp_dir.keep();
2016 let canonical_temp = temp_path.canonicalize()?;
2017 let project_path = canonical_temp.join("project");
2018 std::fs::create_dir_all(&project_path)?;
2019 let image_path = project_path.join("test-image.png");
2020 std::fs::write(&image_path, EMBEDDED_TEST_IMAGE)?;
2021
2022 // Create a project with the test image
2023 let project = cx.update(|cx| {
2024 project::Project::local(
2025 app_state.client.clone(),
2026 app_state.node_runtime.clone(),
2027 app_state.user_store.clone(),
2028 app_state.languages.clone(),
2029 app_state.fs.clone(),
2030 None,
2031 project::LocalProjectFlags {
2032 init_worktree_trust: false,
2033 ..Default::default()
2034 },
2035 cx,
2036 )
2037 });
2038
2039 // Add the test directory as a worktree
2040 let add_worktree_task = project.update(cx, |project, cx| {
2041 project.find_or_create_worktree(&project_path, true, cx)
2042 });
2043
2044 cx.background_executor.allow_parking();
2045 let (worktree, _) = cx
2046 .foreground_executor
2047 .block_test(add_worktree_task)
2048 .context("Failed to add worktree")?;
2049 cx.background_executor.forbid_parking();
2050
2051 cx.run_until_parked();
2052
2053 let worktree_name = cx.read(|cx| worktree.read(cx).root_name_str().to_string());
2054
2055 // Create the necessary entities for the ReadFileTool
2056 let action_log = cx.update(|cx| cx.new(|_| action_log::ActionLog::new(project.clone())));
2057
2058 // Create the ReadFileTool
2059 let tool = Arc::new(agent::ReadFileTool::new(project.clone(), action_log, true));
2060
2061 // Create a test event stream to capture tool output
2062 let (event_stream, mut event_receiver) = agent::ToolCallEventStream::test();
2063
2064 // Run the real ReadFileTool to get the actual image content
2065 let input = agent::ReadFileToolInput {
2066 path: format!("{}/test-image.png", worktree_name),
2067 start_line: None,
2068 end_line: None,
2069 };
2070 let run_task = cx.update(|cx| {
2071 tool.clone()
2072 .run(ToolInput::resolved(input), event_stream, cx)
2073 });
2074
2075 cx.background_executor.allow_parking();
2076 let run_result = cx.foreground_executor.block_test(run_task);
2077 cx.background_executor.forbid_parking();
2078 run_result.map_err(|e| match e {
2079 language_model::LanguageModelToolResultContent::Text(text) => {
2080 anyhow::anyhow!("ReadFileTool failed: {text}")
2081 }
2082 other => anyhow::anyhow!("ReadFileTool failed: {other:?}"),
2083 })?;
2084
2085 cx.run_until_parked();
2086
2087 // Collect the events from the tool execution
2088 let mut tool_content: Vec<acp::ToolCallContent> = Vec::new();
2089 let mut tool_locations: Vec<acp::ToolCallLocation> = Vec::new();
2090
2091 while let Ok(event) = event_receiver.try_recv() {
2092 if let Ok(agent::ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(
2093 update,
2094 ))) = event
2095 {
2096 if let Some(content) = update.fields.content {
2097 tool_content.extend(content);
2098 }
2099 if let Some(locations) = update.fields.locations {
2100 tool_locations.extend(locations);
2101 }
2102 }
2103 }
2104
2105 if tool_content.is_empty() {
2106 return Err(anyhow::anyhow!("ReadFileTool did not produce any content"));
2107 }
2108
2109 // Create stub connection with the real tool output
2110 let connection = StubAgentConnection::new();
2111 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
2112 acp::ToolCall::new(
2113 "read_file",
2114 format!("Read file `{}/test-image.png`", worktree_name),
2115 )
2116 .kind(acp::ToolKind::Read)
2117 .status(acp::ToolCallStatus::Completed)
2118 .locations(tool_locations)
2119 .content(tool_content),
2120 )]);
2121
2122 let stub_agent: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(connection));
2123
2124 // Create a window sized for the agent panel
2125 let window_size = size(px(500.0), px(900.0));
2126 let bounds = Bounds {
2127 origin: point(px(0.0), px(0.0)),
2128 size: window_size,
2129 };
2130
2131 let workspace_window: WindowHandle<Workspace> = cx
2132 .update(|cx| {
2133 cx.open_window(
2134 WindowOptions {
2135 window_bounds: Some(WindowBounds::Windowed(bounds)),
2136 focus: false,
2137 show: false,
2138 ..Default::default()
2139 },
2140 |window, cx| {
2141 cx.new(|cx| {
2142 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
2143 })
2144 },
2145 )
2146 })
2147 .context("Failed to open agent window")?;
2148
2149 cx.run_until_parked();
2150
2151 // Load the AgentPanel
2152 let (weak_workspace, async_window_cx) = workspace_window
2153 .update(cx, |workspace, window, cx| {
2154 (workspace.weak_handle(), window.to_async(cx))
2155 })
2156 .context("Failed to get workspace handle")?;
2157
2158 cx.background_executor.allow_parking();
2159 let panel = cx
2160 .foreground_executor
2161 .block_test(AgentPanel::load(weak_workspace, async_window_cx))
2162 .context("Failed to load AgentPanel")?;
2163 cx.background_executor.forbid_parking();
2164
2165 cx.update_window(workspace_window.into(), |_, _window, cx| {
2166 workspace_window
2167 .update(cx, |workspace, window, cx| {
2168 workspace.add_panel(panel.clone(), window, cx);
2169 workspace.open_panel::<AgentPanel>(window, cx);
2170 })
2171 .log_err();
2172 })?;
2173
2174 cx.run_until_parked();
2175
2176 // Inject the stub server and open the stub thread
2177 cx.update_window(workspace_window.into(), |_, window, cx| {
2178 panel.update(cx, |panel, cx| {
2179 panel.open_external_thread_with_server(stub_agent.clone(), window, cx);
2180 });
2181 })?;
2182
2183 cx.run_until_parked();
2184
2185 // Get the thread view and send a message
2186 let thread_view = cx
2187 .read(|cx| panel.read(cx).active_thread_view_for_tests().cloned())
2188 .ok_or_else(|| anyhow::anyhow!("No active thread view"))?;
2189
2190 let thread = cx
2191 .read(|cx| {
2192 thread_view
2193 .read(cx)
2194 .active_thread()
2195 .map(|active| active.read(cx).thread.clone())
2196 })
2197 .ok_or_else(|| anyhow::anyhow!("Thread not available"))?;
2198
2199 // Send the message to trigger the image response
2200 let send_future = thread.update(cx, |thread, cx| {
2201 thread.send(vec!["Show me the Zed logo".into()], cx)
2202 });
2203
2204 cx.background_executor.allow_parking();
2205 let send_result = cx.foreground_executor.block_test(send_future);
2206 cx.background_executor.forbid_parking();
2207 send_result.context("Failed to send message")?;
2208
2209 cx.run_until_parked();
2210
2211 // Get the tool call ID for expanding later
2212 let tool_call_id = cx
2213 .read(|cx| {
2214 thread.read(cx).entries().iter().find_map(|entry| {
2215 if let acp_thread::AgentThreadEntry::ToolCall(tool_call) = entry {
2216 Some(tool_call.id.clone())
2217 } else {
2218 None
2219 }
2220 })
2221 })
2222 .ok_or_else(|| anyhow::anyhow!("Expected a ToolCall entry in thread"))?;
2223
2224 cx.update_window(workspace_window.into(), |_, window, _cx| {
2225 window.refresh();
2226 })?;
2227
2228 cx.run_until_parked();
2229
2230 // Capture the COLLAPSED state
2231 let collapsed_result = run_visual_test(
2232 "agent_thread_with_image_collapsed",
2233 workspace_window.into(),
2234 cx,
2235 update_baseline,
2236 )?;
2237
2238 // Now expand the tool call so the image is visible
2239 thread_view.update(cx, |view, cx| {
2240 view.expand_tool_call(tool_call_id, cx);
2241 });
2242
2243 cx.run_until_parked();
2244
2245 cx.update_window(workspace_window.into(), |_, window, _cx| {
2246 window.refresh();
2247 })?;
2248
2249 cx.run_until_parked();
2250
2251 // Capture the EXPANDED state
2252 let expanded_result = run_visual_test(
2253 "agent_thread_with_image_expanded",
2254 workspace_window.into(),
2255 cx,
2256 update_baseline,
2257 )?;
2258
2259 // Remove the worktree from the project to stop background scanning tasks
2260 // This prevents "root path could not be canonicalized" errors when we clean up
2261 workspace_window
2262 .update(cx, |workspace, _window, cx| {
2263 let project = workspace.project().clone();
2264 project.update(cx, |project, cx| {
2265 let worktree_ids: Vec<_> =
2266 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
2267 for id in worktree_ids {
2268 project.remove_worktree(id, cx);
2269 }
2270 });
2271 })
2272 .log_err();
2273
2274 cx.run_until_parked();
2275
2276 // Close the window
2277 // Note: This may cause benign "editor::scroll window not found" errors from scrollbar
2278 // auto-hide timers that were scheduled before the window was closed. These errors
2279 // don't affect test results.
2280 cx.update_window(workspace_window.into(), |_, window, _cx| {
2281 window.remove_window();
2282 })
2283 .log_err();
2284
2285 // Run until all cleanup tasks complete
2286 cx.run_until_parked();
2287
2288 // Give background tasks time to finish, including scrollbar hide timers (1 second)
2289 for _ in 0..15 {
2290 cx.advance_clock(Duration::from_millis(100));
2291 cx.run_until_parked();
2292 }
2293
2294 // Note: We don't delete temp_path here because background worktree tasks may still
2295 // be running. The directory will be cleaned up when the process exits.
2296
2297 match (&collapsed_result, &expanded_result) {
2298 (TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
2299 (TestResult::BaselineUpdated(p), _) | (_, TestResult::BaselineUpdated(p)) => {
2300 Ok(TestResult::BaselineUpdated(p.clone()))
2301 }
2302 }
2303}
2304
2305/// Visual test for the Tool Permissions Settings UI page
2306///
2307/// Takes a screenshot showing the tool config page with matched patterns and verdict.
2308#[cfg(target_os = "macos")]
2309fn run_tool_permissions_visual_tests(
2310 app_state: Arc<AppState>,
2311 cx: &mut VisualTestAppContext,
2312 _update_baseline: bool,
2313) -> Result<TestResult> {
2314 use agent_settings::{AgentSettings, CompiledRegex, ToolPermissions, ToolRules};
2315 use collections::HashMap;
2316 use settings::ToolPermissionMode;
2317 use zed_actions::OpenSettingsAt;
2318
2319 // Set up tool permissions with "hi" as both always_deny and always_allow for terminal
2320 cx.update(|cx| {
2321 let mut tools = HashMap::default();
2322 tools.insert(
2323 Arc::from("terminal"),
2324 ToolRules {
2325 default: None,
2326 always_allow: vec![CompiledRegex::new("hi", false).unwrap()],
2327 always_deny: vec![CompiledRegex::new("hi", false).unwrap()],
2328 always_confirm: vec![],
2329 invalid_patterns: vec![],
2330 },
2331 );
2332 let mut settings = AgentSettings::get_global(cx).clone();
2333 settings.tool_permissions = ToolPermissions {
2334 default: ToolPermissionMode::Confirm,
2335 tools,
2336 };
2337 AgentSettings::override_global(settings, cx);
2338 });
2339
2340 // Create a minimal workspace to dispatch the settings action from
2341 let window_size = size(px(900.0), px(700.0));
2342 let bounds = Bounds {
2343 origin: point(px(0.0), px(0.0)),
2344 size: window_size,
2345 };
2346
2347 let project = cx.update(|cx| {
2348 project::Project::local(
2349 app_state.client.clone(),
2350 app_state.node_runtime.clone(),
2351 app_state.user_store.clone(),
2352 app_state.languages.clone(),
2353 app_state.fs.clone(),
2354 None,
2355 project::LocalProjectFlags {
2356 init_worktree_trust: false,
2357 ..Default::default()
2358 },
2359 cx,
2360 )
2361 });
2362
2363 let workspace_window: WindowHandle<MultiWorkspace> = cx
2364 .update(|cx| {
2365 cx.open_window(
2366 WindowOptions {
2367 window_bounds: Some(WindowBounds::Windowed(bounds)),
2368 focus: false,
2369 show: false,
2370 ..Default::default()
2371 },
2372 |window, cx| {
2373 let workspace = cx.new(|cx| {
2374 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
2375 });
2376 cx.new(|cx| MultiWorkspace::new(workspace, window, cx))
2377 },
2378 )
2379 })
2380 .context("Failed to open workspace window for settings test")?;
2381
2382 cx.run_until_parked();
2383
2384 // Dispatch the OpenSettingsAt action to open settings at the tool_permissions path
2385 workspace_window
2386 .update(cx, |_workspace, window, cx| {
2387 window.dispatch_action(
2388 Box::new(OpenSettingsAt {
2389 path: "agent.tool_permissions".to_string(),
2390 }),
2391 cx,
2392 );
2393 })
2394 .context("Failed to dispatch OpenSettingsAt action")?;
2395
2396 cx.run_until_parked();
2397
2398 // Give the settings window time to open and render
2399 for _ in 0..10 {
2400 cx.advance_clock(Duration::from_millis(50));
2401 cx.run_until_parked();
2402 }
2403
2404 // Find the settings window - it should be the newest window (last in the list)
2405 let all_windows = cx.update(|cx| cx.windows());
2406 let settings_window = all_windows.last().copied().context("No windows found")?;
2407
2408 let output_dir = std::env::var("VISUAL_TEST_OUTPUT_DIR")
2409 .unwrap_or_else(|_| "target/visual_tests".to_string());
2410 std::fs::create_dir_all(&output_dir).log_err();
2411
2412 // Navigate to the tool permissions sub-page using the public API
2413 let settings_window_handle = settings_window
2414 .downcast::<settings_ui::SettingsWindow>()
2415 .context("Failed to downcast to SettingsWindow")?;
2416
2417 settings_window_handle
2418 .update(cx, |settings_window, window, cx| {
2419 settings_window.navigate_to_sub_page("agent.tool_permissions", window, cx);
2420 })
2421 .context("Failed to navigate to tool permissions sub-page")?;
2422
2423 cx.run_until_parked();
2424
2425 // Give the sub-page time to render
2426 for _ in 0..10 {
2427 cx.advance_clock(Duration::from_millis(50));
2428 cx.run_until_parked();
2429 }
2430
2431 // Now navigate into a specific tool (Terminal) to show the tool config page
2432 settings_window_handle
2433 .update(cx, |settings_window, window, cx| {
2434 settings_window.push_dynamic_sub_page(
2435 "Terminal",
2436 "Configure Tool Rules",
2437 None,
2438 settings_ui::pages::render_terminal_tool_config,
2439 window,
2440 cx,
2441 );
2442 })
2443 .context("Failed to navigate to Terminal tool config")?;
2444
2445 cx.run_until_parked();
2446
2447 // Give the tool config page time to render
2448 for _ in 0..10 {
2449 cx.advance_clock(Duration::from_millis(50));
2450 cx.run_until_parked();
2451 }
2452
2453 // Refresh and redraw so the "Test Your Rules" input is present
2454 cx.update_window(settings_window, |_, window, cx| {
2455 window.draw(cx).clear();
2456 })
2457 .log_err();
2458 cx.run_until_parked();
2459
2460 cx.update_window(settings_window, |_, window, _cx| {
2461 window.refresh();
2462 })
2463 .log_err();
2464 cx.run_until_parked();
2465
2466 // Focus the first tab stop in the window (the "Test Your Rules" editor
2467 // has tab_index(0) and tab_stop(true)) and type "hi" into it.
2468 cx.update_window(settings_window, |_, window, cx| {
2469 window.focus_next(cx);
2470 })
2471 .log_err();
2472 cx.run_until_parked();
2473
2474 cx.simulate_input(settings_window, "hi");
2475
2476 // Let the UI update with the matched patterns
2477 for _ in 0..5 {
2478 cx.advance_clock(Duration::from_millis(50));
2479 cx.run_until_parked();
2480 }
2481
2482 // Refresh and redraw
2483 cx.update_window(settings_window, |_, window, cx| {
2484 window.draw(cx).clear();
2485 })
2486 .log_err();
2487 cx.run_until_parked();
2488
2489 cx.update_window(settings_window, |_, window, _cx| {
2490 window.refresh();
2491 })
2492 .log_err();
2493 cx.run_until_parked();
2494
2495 // Save screenshot: Tool config page with "hi" typed and matched patterns visible
2496 let tool_config_output_path =
2497 PathBuf::from(&output_dir).join("tool_permissions_test_rules.png");
2498
2499 if let Ok(screenshot) = cx.capture_screenshot(settings_window) {
2500 screenshot.save(&tool_config_output_path).log_err();
2501 println!(
2502 "Screenshot (test rules) saved to: {}",
2503 tool_config_output_path.display()
2504 );
2505 }
2506
2507 // Clean up - close the settings window
2508 cx.update_window(settings_window, |_, window, _cx| {
2509 window.remove_window();
2510 })
2511 .log_err();
2512
2513 // Close the workspace window
2514 cx.update_window(workspace_window.into(), |_, window, _cx| {
2515 window.remove_window();
2516 })
2517 .log_err();
2518
2519 cx.run_until_parked();
2520
2521 // Give background tasks time to finish
2522 for _ in 0..5 {
2523 cx.advance_clock(Duration::from_millis(100));
2524 cx.run_until_parked();
2525 }
2526
2527 // Return success - we're just capturing screenshots, not comparing baselines
2528 Ok(TestResult::Passed)
2529}
2530
2531#[cfg(target_os = "macos")]
2532fn run_multi_workspace_sidebar_visual_tests(
2533 app_state: Arc<AppState>,
2534 cx: &mut VisualTestAppContext,
2535 update_baseline: bool,
2536) -> Result<TestResult> {
2537 // Create temporary directories to act as worktrees for active workspaces
2538 let temp_dir = tempfile::tempdir()?;
2539 let temp_path = temp_dir.keep();
2540 let canonical_temp = temp_path.canonicalize()?;
2541
2542 let workspace1_dir = canonical_temp.join("private-test-remote");
2543 let workspace2_dir = canonical_temp.join("zed");
2544 std::fs::create_dir_all(&workspace1_dir)?;
2545 std::fs::create_dir_all(&workspace2_dir)?;
2546
2547 // Create both projects upfront so we can build both workspaces during
2548 // window creation, before the MultiWorkspace entity exists.
2549 // This avoids a re-entrant read panic that occurs when Workspace::new
2550 // tries to access the window root (MultiWorkspace) while it's being updated.
2551 let project1 = cx.update(|cx| {
2552 project::Project::local(
2553 app_state.client.clone(),
2554 app_state.node_runtime.clone(),
2555 app_state.user_store.clone(),
2556 app_state.languages.clone(),
2557 app_state.fs.clone(),
2558 None,
2559 project::LocalProjectFlags {
2560 init_worktree_trust: false,
2561 ..Default::default()
2562 },
2563 cx,
2564 )
2565 });
2566
2567 let project2 = cx.update(|cx| {
2568 project::Project::local(
2569 app_state.client.clone(),
2570 app_state.node_runtime.clone(),
2571 app_state.user_store.clone(),
2572 app_state.languages.clone(),
2573 app_state.fs.clone(),
2574 None,
2575 project::LocalProjectFlags {
2576 init_worktree_trust: false,
2577 ..Default::default()
2578 },
2579 cx,
2580 )
2581 });
2582
2583 let window_size = size(px(1280.0), px(800.0));
2584 let bounds = Bounds {
2585 origin: point(px(0.0), px(0.0)),
2586 size: window_size,
2587 };
2588
2589 // Open a MultiWorkspace window with both workspaces created at construction time
2590 let multi_workspace_window: WindowHandle<MultiWorkspace> = cx
2591 .update(|cx| {
2592 cx.open_window(
2593 WindowOptions {
2594 window_bounds: Some(WindowBounds::Windowed(bounds)),
2595 focus: false,
2596 show: false,
2597 ..Default::default()
2598 },
2599 |window, cx| {
2600 let workspace1 = cx.new(|cx| {
2601 Workspace::new(None, project1.clone(), app_state.clone(), window, cx)
2602 });
2603 let workspace2 = cx.new(|cx| {
2604 Workspace::new(None, project2.clone(), app_state.clone(), window, cx)
2605 });
2606 cx.new(|cx| {
2607 let mut multi_workspace = MultiWorkspace::new(workspace1, window, cx);
2608 multi_workspace.activate(workspace2, None, window, cx);
2609 multi_workspace
2610 })
2611 },
2612 )
2613 })
2614 .context("Failed to open MultiWorkspace window")?;
2615
2616 cx.run_until_parked();
2617
2618 // Add worktree to workspace 1 (index 0) so it shows as "private-test-remote"
2619 let add_worktree1_task = multi_workspace_window
2620 .update(cx, |multi_workspace, _window, cx| {
2621 let workspace1 = multi_workspace.workspaces().next().unwrap();
2622 let project = workspace1.read(cx).project().clone();
2623 project.update(cx, |project, cx| {
2624 project.find_or_create_worktree(&workspace1_dir, true, cx)
2625 })
2626 })
2627 .context("Failed to start adding worktree 1")?;
2628
2629 cx.background_executor.allow_parking();
2630 cx.foreground_executor
2631 .block_test(add_worktree1_task)
2632 .context("Failed to add worktree 1")?;
2633 cx.background_executor.forbid_parking();
2634
2635 cx.run_until_parked();
2636
2637 // Add worktree to workspace 2 (index 1) so it shows as "zed"
2638 let add_worktree2_task = multi_workspace_window
2639 .update(cx, |multi_workspace, _window, cx| {
2640 let workspace2 = multi_workspace.workspaces().nth(1).unwrap();
2641 let project = workspace2.read(cx).project().clone();
2642 project.update(cx, |project, cx| {
2643 project.find_or_create_worktree(&workspace2_dir, true, cx)
2644 })
2645 })
2646 .context("Failed to start adding worktree 2")?;
2647
2648 cx.background_executor.allow_parking();
2649 cx.foreground_executor
2650 .block_test(add_worktree2_task)
2651 .context("Failed to add worktree 2")?;
2652 cx.background_executor.forbid_parking();
2653
2654 cx.run_until_parked();
2655
2656 // Switch to workspace 1 so it's highlighted as active (index 0)
2657 multi_workspace_window
2658 .update(cx, |multi_workspace, window, cx| {
2659 let workspace = multi_workspace.workspaces().next().unwrap().clone();
2660 multi_workspace.activate(workspace, None, window, cx);
2661 })
2662 .context("Failed to activate workspace 1")?;
2663
2664 cx.run_until_parked();
2665
2666 // Create the sidebar outside the MultiWorkspace update to avoid a
2667 // re-entrant read panic (Sidebar::new reads the MultiWorkspace).
2668 let sidebar = cx
2669 .update_window(multi_workspace_window.into(), |root_view, window, cx| {
2670 let multi_workspace_handle: Entity<MultiWorkspace> = root_view.downcast().unwrap();
2671 cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx))
2672 })
2673 .context("Failed to create sidebar")?;
2674
2675 multi_workspace_window
2676 .update(cx, |multi_workspace, _window, cx| {
2677 multi_workspace.register_sidebar(sidebar.clone(), cx);
2678 })
2679 .context("Failed to register sidebar")?;
2680
2681 cx.run_until_parked();
2682
2683 // Save test threads to the ThreadStore for each workspace
2684 let save_tasks = multi_workspace_window
2685 .update(cx, |multi_workspace, _window, cx| {
2686 let thread_store = agent::ThreadStore::global(cx);
2687 let workspaces: Vec<_> = multi_workspace.workspaces().cloned().collect();
2688 let mut tasks = Vec::new();
2689
2690 for (index, workspace) in workspaces.iter().enumerate() {
2691 let workspace_ref = workspace.read(cx);
2692 let mut paths = Vec::new();
2693 for worktree in workspace_ref.worktrees(cx) {
2694 let worktree_ref = worktree.read(cx);
2695 if worktree_ref.is_visible() {
2696 paths.push(worktree_ref.abs_path().to_path_buf());
2697 }
2698 }
2699 let path_list = util::path_list::PathList::new(&paths);
2700
2701 let (session_id, title, updated_at) = match index {
2702 0 => (
2703 "visual-test-thread-0",
2704 "Refine thread view scrolling behavior",
2705 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 6, 15, 10, 30, 0)
2706 .unwrap(),
2707 ),
2708 1 => (
2709 "visual-test-thread-1",
2710 "Add line numbers option to FileEditBlock",
2711 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 6, 15, 11, 0, 0)
2712 .unwrap(),
2713 ),
2714 _ => continue,
2715 };
2716
2717 let task = thread_store.update(cx, |store, cx| {
2718 store.save_thread(
2719 acp::SessionId::new(Arc::from(session_id)),
2720 agent::DbThread {
2721 title: title.to_string().into(),
2722 messages: Vec::new(),
2723 updated_at,
2724 detailed_summary: None,
2725 initial_project_snapshot: None,
2726 cumulative_token_usage: Default::default(),
2727 request_token_usage: Default::default(),
2728 model: None,
2729 profile: None,
2730 imported: false,
2731 subagent_context: None,
2732 speed: None,
2733 thinking_enabled: false,
2734 thinking_effort: None,
2735 ui_scroll_position: None,
2736 draft_prompt: None,
2737 },
2738 path_list,
2739 cx,
2740 )
2741 });
2742 tasks.push(task);
2743 }
2744 tasks
2745 })
2746 .context("Failed to create test threads")?;
2747
2748 cx.background_executor.allow_parking();
2749 for task in save_tasks {
2750 cx.foreground_executor
2751 .block_test(task)
2752 .context("Failed to save test thread")?;
2753 }
2754 cx.background_executor.forbid_parking();
2755
2756 cx.run_until_parked();
2757
2758 // Open the sidebar
2759 multi_workspace_window
2760 .update(cx, |multi_workspace, window, cx| {
2761 multi_workspace.toggle_sidebar(window, cx);
2762 })
2763 .context("Failed to toggle sidebar")?;
2764
2765 // Let rendering settle
2766 for _ in 0..10 {
2767 cx.advance_clock(Duration::from_millis(100));
2768 cx.run_until_parked();
2769 }
2770
2771 // Refresh the window
2772 cx.update_window(multi_workspace_window.into(), |_, window, _cx| {
2773 window.refresh();
2774 })?;
2775
2776 cx.run_until_parked();
2777
2778 // Capture: sidebar open with active workspaces and recent projects
2779 let test_result = run_visual_test(
2780 "multi_workspace_sidebar_open",
2781 multi_workspace_window.into(),
2782 cx,
2783 update_baseline,
2784 )?;
2785
2786 // Clean up worktrees
2787 multi_workspace_window
2788 .update(cx, |multi_workspace, _window, cx| {
2789 for workspace in multi_workspace.workspaces() {
2790 let project = workspace.read(cx).project().clone();
2791 project.update(cx, |project, cx| {
2792 let worktree_ids: Vec<_> =
2793 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
2794 for id in worktree_ids {
2795 project.remove_worktree(id, cx);
2796 }
2797 });
2798 }
2799 })
2800 .log_err();
2801
2802 cx.run_until_parked();
2803
2804 // Close the window
2805 cx.update_window(multi_workspace_window.into(), |_, window, _cx| {
2806 window.remove_window();
2807 })
2808 .log_err();
2809
2810 cx.run_until_parked();
2811
2812 for _ in 0..15 {
2813 cx.advance_clock(Duration::from_millis(100));
2814 cx.run_until_parked();
2815 }
2816
2817 Ok(test_result)
2818}
2819
2820#[cfg(target_os = "macos")]
2821struct ErrorWrappingTestView;
2822
2823#[cfg(target_os = "macos")]
2824impl gpui::Render for ErrorWrappingTestView {
2825 fn render(
2826 &mut self,
2827 _window: &mut gpui::Window,
2828 cx: &mut gpui::Context<Self>,
2829 ) -> impl gpui::IntoElement {
2830 use ui::{Button, Callout, IconName, LabelSize, Severity, prelude::*, v_flex};
2831
2832 let long_error_message = "Rate limit reached for gpt-5.2-codex in organization \
2833 org-QmYpir6k6dkULKU1XUSN6pal on tokens per min (TPM): Limit 500000, Used 442480, \
2834 Requested 59724. Please try again in 264ms. Visit \
2835 https://platform.openai.com/account/rate-limits to learn more.";
2836
2837 let retry_description = "Retrying. Next attempt in 4 seconds (Attempt 1 of 2).";
2838
2839 v_flex()
2840 .size_full()
2841 .bg(cx.theme().colors().background)
2842 .p_4()
2843 .gap_4()
2844 .child(
2845 Callout::new()
2846 .icon(IconName::Warning)
2847 .severity(Severity::Warning)
2848 .title(long_error_message)
2849 .description(retry_description),
2850 )
2851 .child(
2852 Callout::new()
2853 .severity(Severity::Error)
2854 .icon(IconName::XCircle)
2855 .title("An Error Happened")
2856 .description(long_error_message)
2857 .actions_slot(Button::new("dismiss", "Dismiss").label_size(LabelSize::Small)),
2858 )
2859 .child(
2860 Callout::new()
2861 .severity(Severity::Error)
2862 .icon(IconName::XCircle)
2863 .title(long_error_message)
2864 .actions_slot(Button::new("retry", "Retry").label_size(LabelSize::Small)),
2865 )
2866 }
2867}
2868
2869#[cfg(target_os = "macos")]
2870struct ThreadItemBranchNameTestView;
2871
2872#[cfg(target_os = "macos")]
2873impl gpui::Render for ThreadItemBranchNameTestView {
2874 fn render(
2875 &mut self,
2876 _window: &mut gpui::Window,
2877 cx: &mut gpui::Context<Self>,
2878 ) -> impl gpui::IntoElement {
2879 use ui::{
2880 IconName, Label, LabelSize, ThreadItem, ThreadItemWorktreeInfo, WorktreeKind,
2881 prelude::*,
2882 };
2883
2884 let section_label = |text: &str| {
2885 Label::new(text.to_string())
2886 .size(LabelSize::Small)
2887 .color(Color::Muted)
2888 };
2889
2890 let container = || {
2891 v_flex()
2892 .w_80()
2893 .border_1()
2894 .border_color(cx.theme().colors().border_variant)
2895 .bg(cx.theme().colors().panel_background)
2896 };
2897
2898 v_flex()
2899 .size_full()
2900 .bg(cx.theme().colors().background)
2901 .p_4()
2902 .gap_3()
2903 .child(
2904 Label::new("ThreadItem Branch Names")
2905 .size(LabelSize::Large)
2906 .color(Color::Default),
2907 )
2908 .child(section_label(
2909 "Linked worktree with branch (worktree / branch)",
2910 ))
2911 .child(
2912 container().child(
2913 ThreadItem::new("ti-linked-branch", "Fix scrolling behavior")
2914 .icon(IconName::AiClaude)
2915 .timestamp("5m")
2916 .worktrees(vec![ThreadItemWorktreeInfo {
2917 worktree_name: Some("jade-glen".into()),
2918 full_path: "/worktrees/jade-glen/zed".into(),
2919 highlight_positions: Vec::new(),
2920 kind: WorktreeKind::Linked,
2921 branch_name: Some("fix-scrolling".into()),
2922 }]),
2923 ),
2924 )
2925 .child(section_label(
2926 "Linked worktree without branch (detached HEAD)",
2927 ))
2928 .child(
2929 container().child(
2930 ThreadItem::new("ti-linked-no-branch", "Review worktree cleanup")
2931 .icon(IconName::AiClaude)
2932 .timestamp("1h")
2933 .worktrees(vec![ThreadItemWorktreeInfo {
2934 worktree_name: Some("focal-arrow".into()),
2935 full_path: "/worktrees/focal-arrow/zed".into(),
2936 highlight_positions: Vec::new(),
2937 kind: WorktreeKind::Linked,
2938 branch_name: None,
2939 }]),
2940 ),
2941 )
2942 .child(section_label("Main worktree with branch (nothing shown)"))
2943 .child(
2944 container().child(
2945 ThreadItem::new("ti-main-branch", "Request for Long Classic Poem")
2946 .icon(IconName::ZedAgent)
2947 .timestamp("2d")
2948 .worktrees(vec![ThreadItemWorktreeInfo {
2949 worktree_name: Some("zed".into()),
2950 full_path: "/projects/zed".into(),
2951 highlight_positions: Vec::new(),
2952 kind: WorktreeKind::Main,
2953 branch_name: Some("main".into()),
2954 }]),
2955 ),
2956 )
2957 .child(section_label(
2958 "Main worktree without branch (nothing shown)",
2959 ))
2960 .child(
2961 container().child(
2962 ThreadItem::new("ti-main-no-branch", "Simple greeting thread")
2963 .icon(IconName::ZedAgent)
2964 .timestamp("3d")
2965 .worktrees(vec![ThreadItemWorktreeInfo {
2966 worktree_name: Some("zed".into()),
2967 full_path: "/projects/zed".into(),
2968 highlight_positions: Vec::new(),
2969 kind: WorktreeKind::Main,
2970 branch_name: None,
2971 }]),
2972 ),
2973 )
2974 .child(section_label("Linked worktree where name matches branch"))
2975 .child(
2976 container().child(
2977 ThreadItem::new("ti-same-name", "Implement feature")
2978 .icon(IconName::AiClaude)
2979 .timestamp("6d")
2980 .worktrees(vec![ThreadItemWorktreeInfo {
2981 worktree_name: Some("stoic-reed".into()),
2982 full_path: "/worktrees/stoic-reed/zed".into(),
2983 highlight_positions: Vec::new(),
2984 kind: WorktreeKind::Linked,
2985 branch_name: Some("stoic-reed".into()),
2986 }]),
2987 ),
2988 )
2989 .child(section_label(
2990 "Manually opened linked worktree (main_path resolves to original repo)",
2991 ))
2992 .child(
2993 container().child(
2994 ThreadItem::new("ti-manual-linked", "Robust Git Worktree Rollback")
2995 .icon(IconName::ZedAgent)
2996 .timestamp("40m")
2997 .worktrees(vec![ThreadItemWorktreeInfo {
2998 worktree_name: Some("focal-arrow".into()),
2999 full_path: "/worktrees/focal-arrow/zed".into(),
3000 highlight_positions: Vec::new(),
3001 kind: WorktreeKind::Linked,
3002 branch_name: Some("persist-worktree-3-wiring".into()),
3003 }]),
3004 ),
3005 )
3006 .child(section_label(
3007 "Linked worktree + branch + diff stats + timestamp",
3008 ))
3009 .child(
3010 container().child(
3011 ThreadItem::new("ti-linked-full", "Full metadata with diff stats")
3012 .icon(IconName::AiClaude)
3013 .timestamp("3w")
3014 .added(42)
3015 .removed(17)
3016 .worktrees(vec![ThreadItemWorktreeInfo {
3017 worktree_name: Some("jade-glen".into()),
3018 full_path: "/worktrees/jade-glen/zed".into(),
3019 highlight_positions: Vec::new(),
3020 kind: WorktreeKind::Linked,
3021 branch_name: Some("feature-branch".into()),
3022 }]),
3023 ),
3024 )
3025 .child(section_label("Long branch name truncation with diff stats"))
3026 .child(
3027 container().child(
3028 ThreadItem::new("ti-long-branch", "Overflow test with very long branch")
3029 .icon(IconName::AiClaude)
3030 .timestamp("2d")
3031 .added(108)
3032 .removed(53)
3033 .worktrees(vec![ThreadItemWorktreeInfo {
3034 worktree_name: Some("my-project".into()),
3035 full_path: "/worktrees/my-project/zed".into(),
3036 highlight_positions: Vec::new(),
3037 kind: WorktreeKind::Linked,
3038 branch_name: Some(
3039 "fix-very-long-branch-name-that-should-truncate".into(),
3040 ),
3041 }]),
3042 ),
3043 )
3044 .child(section_label(
3045 "Main worktree with branch + diff stats + timestamp (branch hidden)",
3046 ))
3047 .child(
3048 container().child(
3049 ThreadItem::new("ti-main-full", "Main worktree with everything")
3050 .icon(IconName::ZedAgent)
3051 .timestamp("5m")
3052 .added(23)
3053 .removed(8)
3054 .worktrees(vec![ThreadItemWorktreeInfo {
3055 worktree_name: Some("zed".into()),
3056 full_path: "/projects/zed".into(),
3057 highlight_positions: Vec::new(),
3058 kind: WorktreeKind::Main,
3059 branch_name: Some("sidebar-show-branch-name".into()),
3060 }]),
3061 ),
3062 )
3063 }
3064}
3065
3066#[cfg(target_os = "macos")]
3067fn run_thread_item_branch_name_visual_tests(
3068 _app_state: Arc<AppState>,
3069 cx: &mut VisualTestAppContext,
3070 update_baseline: bool,
3071) -> Result<TestResult> {
3072 let window_size = size(px(400.0), px(1150.0));
3073 let bounds = Bounds {
3074 origin: point(px(0.0), px(0.0)),
3075 size: window_size,
3076 };
3077
3078 let window = cx
3079 .update(|cx| {
3080 cx.open_window(
3081 WindowOptions {
3082 window_bounds: Some(WindowBounds::Windowed(bounds)),
3083 focus: false,
3084 show: false,
3085 ..Default::default()
3086 },
3087 |_window, cx| cx.new(|_| ThreadItemBranchNameTestView),
3088 )
3089 })
3090 .context("Failed to open thread item branch name test window")?;
3091
3092 cx.run_until_parked();
3093
3094 cx.update_window(window.into(), |_, window, _cx| {
3095 window.refresh();
3096 })?;
3097
3098 cx.run_until_parked();
3099
3100 let test_result = run_visual_test(
3101 "thread_item_branch_names",
3102 window.into(),
3103 cx,
3104 update_baseline,
3105 )?;
3106
3107 cx.update_window(window.into(), |_, window, _cx| {
3108 window.remove_window();
3109 })
3110 .log_err();
3111
3112 cx.run_until_parked();
3113
3114 for _ in 0..15 {
3115 cx.advance_clock(Duration::from_millis(100));
3116 cx.run_until_parked();
3117 }
3118
3119 Ok(test_result)
3120}
3121
3122#[cfg(target_os = "macos")]
3123struct ThreadItemIconDecorationsTestView;
3124
3125#[cfg(target_os = "macos")]
3126impl gpui::Render for ThreadItemIconDecorationsTestView {
3127 fn render(
3128 &mut self,
3129 _window: &mut gpui::Window,
3130 cx: &mut gpui::Context<Self>,
3131 ) -> impl gpui::IntoElement {
3132 use ui::{IconName, Label, LabelSize, ThreadItem, prelude::*};
3133
3134 let section_label = |text: &str| {
3135 Label::new(text.to_string())
3136 .size(LabelSize::Small)
3137 .color(Color::Muted)
3138 };
3139
3140 let container = || {
3141 v_flex()
3142 .w_80()
3143 .border_1()
3144 .border_color(cx.theme().colors().border_variant)
3145 .bg(cx.theme().colors().panel_background)
3146 };
3147
3148 v_flex()
3149 .size_full()
3150 .bg(cx.theme().colors().background)
3151 .p_4()
3152 .gap_3()
3153 .child(
3154 Label::new("ThreadItem Icon Decorations")
3155 .size(LabelSize::Large)
3156 .color(Color::Default),
3157 )
3158 .child(section_label("No decoration (default idle)"))
3159 .child(
3160 container()
3161 .child(ThreadItem::new("ti-none", "Default idle thread").timestamp("1:00 AM")),
3162 )
3163 .child(section_label("Blue dot (notified)"))
3164 .child(
3165 container().child(
3166 ThreadItem::new("ti-done", "Generation completed successfully")
3167 .timestamp("1:05 AM")
3168 .notified(true),
3169 ),
3170 )
3171 .child(section_label("Yellow triangle (waiting for confirmation)"))
3172 .child(
3173 container().child(
3174 ThreadItem::new("ti-waiting", "Waiting for user confirmation")
3175 .timestamp("1:10 AM")
3176 .status(ui::AgentThreadStatus::WaitingForConfirmation),
3177 ),
3178 )
3179 .child(section_label("Red X (error)"))
3180 .child(
3181 container().child(
3182 ThreadItem::new("ti-error", "Failed to connect to server")
3183 .timestamp("1:15 AM")
3184 .status(ui::AgentThreadStatus::Error),
3185 ),
3186 )
3187 .child(section_label("Spinner (running)"))
3188 .child(
3189 container().child(
3190 ThreadItem::new("ti-running", "Generating response...")
3191 .icon(IconName::AiClaude)
3192 .timestamp("1:20 AM")
3193 .status(ui::AgentThreadStatus::Running),
3194 ),
3195 )
3196 .child(section_label(
3197 "Spinner + yellow triangle (waiting for confirmation)",
3198 ))
3199 .child(
3200 container().child(
3201 ThreadItem::new("ti-running-waiting", "Running but needs confirmation")
3202 .icon(IconName::AiClaude)
3203 .timestamp("1:25 AM")
3204 .status(ui::AgentThreadStatus::WaitingForConfirmation),
3205 ),
3206 )
3207 }
3208}
3209
3210#[cfg(target_os = "macos")]
3211fn run_thread_item_icon_decorations_visual_tests(
3212 _app_state: Arc<AppState>,
3213 cx: &mut VisualTestAppContext,
3214 update_baseline: bool,
3215) -> Result<TestResult> {
3216 let window_size = size(px(400.0), px(600.0));
3217 let bounds = Bounds {
3218 origin: point(px(0.0), px(0.0)),
3219 size: window_size,
3220 };
3221
3222 let window = cx
3223 .update(|cx| {
3224 cx.open_window(
3225 WindowOptions {
3226 window_bounds: Some(WindowBounds::Windowed(bounds)),
3227 focus: false,
3228 show: false,
3229 ..Default::default()
3230 },
3231 |_window, cx| cx.new(|_| ThreadItemIconDecorationsTestView),
3232 )
3233 })
3234 .context("Failed to open thread item icon decorations test window")?;
3235
3236 cx.run_until_parked();
3237
3238 cx.update_window(window.into(), |_, window, _cx| {
3239 window.refresh();
3240 })?;
3241
3242 cx.run_until_parked();
3243
3244 let test_result = run_visual_test(
3245 "thread_item_icon_decorations",
3246 window.into(),
3247 cx,
3248 update_baseline,
3249 )?;
3250
3251 cx.update_window(window.into(), |_, window, _cx| {
3252 window.remove_window();
3253 })
3254 .log_err();
3255
3256 cx.run_until_parked();
3257
3258 for _ in 0..15 {
3259 cx.advance_clock(Duration::from_millis(100));
3260 cx.run_until_parked();
3261 }
3262
3263 Ok(test_result)
3264}
3265
3266#[cfg(target_os = "macos")]
3267fn run_error_wrapping_visual_tests(
3268 _app_state: Arc<AppState>,
3269 cx: &mut VisualTestAppContext,
3270 update_baseline: bool,
3271) -> Result<TestResult> {
3272 let window_size = size(px(500.0), px(400.0));
3273 let bounds = Bounds {
3274 origin: point(px(0.0), px(0.0)),
3275 size: window_size,
3276 };
3277
3278 let window = cx
3279 .update(|cx| {
3280 cx.open_window(
3281 WindowOptions {
3282 window_bounds: Some(WindowBounds::Windowed(bounds)),
3283 focus: false,
3284 show: false,
3285 ..Default::default()
3286 },
3287 |_window, cx| cx.new(|_| ErrorWrappingTestView),
3288 )
3289 })
3290 .context("Failed to open error wrapping test window")?;
3291
3292 cx.run_until_parked();
3293
3294 cx.update_window(window.into(), |_, window, _cx| {
3295 window.refresh();
3296 })?;
3297
3298 cx.run_until_parked();
3299
3300 let test_result =
3301 run_visual_test("error_message_wrapping", window.into(), cx, update_baseline)?;
3302
3303 cx.update_window(window.into(), |_, window, _cx| {
3304 window.remove_window();
3305 })
3306 .log_err();
3307
3308 cx.run_until_parked();
3309
3310 for _ in 0..15 {
3311 cx.advance_clock(Duration::from_millis(100));
3312 cx.run_until_parked();
3313 }
3314
3315 Ok(test_result)
3316}
3317
3318#[cfg(target_os = "macos")]
3319/// Helper to create a project, add a worktree at the given path, and return the project.
3320fn create_project_with_worktree(
3321 worktree_dir: &Path,
3322 app_state: &Arc<AppState>,
3323 cx: &mut VisualTestAppContext,
3324) -> Result<Entity<Project>> {
3325 let project = cx.update(|cx| {
3326 project::Project::local(
3327 app_state.client.clone(),
3328 app_state.node_runtime.clone(),
3329 app_state.user_store.clone(),
3330 app_state.languages.clone(),
3331 app_state.fs.clone(),
3332 None,
3333 project::LocalProjectFlags {
3334 init_worktree_trust: false,
3335 ..Default::default()
3336 },
3337 cx,
3338 )
3339 });
3340
3341 let add_task = cx.update(|cx| {
3342 project.update(cx, |project, cx| {
3343 project.find_or_create_worktree(worktree_dir, true, cx)
3344 })
3345 });
3346
3347 cx.background_executor.allow_parking();
3348 cx.foreground_executor
3349 .block_test(add_task)
3350 .context("Failed to add worktree")?;
3351 cx.background_executor.forbid_parking();
3352
3353 cx.run_until_parked();
3354 Ok(project)
3355}
3356
3357#[cfg(target_os = "macos")]
3358fn open_sidebar_test_window(
3359 projects: Vec<Entity<Project>>,
3360 app_state: &Arc<AppState>,
3361 cx: &mut VisualTestAppContext,
3362) -> Result<WindowHandle<MultiWorkspace>> {
3363 anyhow::ensure!(!projects.is_empty(), "need at least one project");
3364
3365 let window_size = size(px(400.0), px(600.0));
3366 let bounds = Bounds {
3367 origin: point(px(0.0), px(0.0)),
3368 size: window_size,
3369 };
3370
3371 let mut projects_iter = projects.into_iter();
3372 let first_project = projects_iter
3373 .next()
3374 .ok_or_else(|| anyhow::anyhow!("need at least one project"))?;
3375 let remaining: Vec<_> = projects_iter.collect();
3376
3377 let multi_workspace_window: WindowHandle<MultiWorkspace> = cx
3378 .update(|cx| {
3379 cx.open_window(
3380 WindowOptions {
3381 window_bounds: Some(WindowBounds::Windowed(bounds)),
3382 focus: false,
3383 show: false,
3384 ..Default::default()
3385 },
3386 |window, cx| {
3387 let first_ws = cx.new(|cx| {
3388 Workspace::new(None, first_project.clone(), app_state.clone(), window, cx)
3389 });
3390 cx.new(|cx| {
3391 let mut mw = MultiWorkspace::new(first_ws, window, cx);
3392 for project in remaining {
3393 let ws = cx.new(|cx| {
3394 Workspace::new(None, project, app_state.clone(), window, cx)
3395 });
3396 mw.activate(ws, None, window, cx);
3397 }
3398 mw
3399 })
3400 },
3401 )
3402 })
3403 .context("Failed to open MultiWorkspace window")?;
3404
3405 cx.run_until_parked();
3406
3407 // Create the sidebar outside the MultiWorkspace update to avoid a
3408 // re-entrant read panic (Sidebar::new reads the MultiWorkspace).
3409 let sidebar = cx
3410 .update_window(multi_workspace_window.into(), |root_view, window, cx| {
3411 let mw_handle: Entity<MultiWorkspace> = root_view
3412 .downcast()
3413 .map_err(|_| anyhow::anyhow!("Failed to downcast root view to MultiWorkspace"))?;
3414 Ok::<_, anyhow::Error>(cx.new(|cx| sidebar::Sidebar::new(mw_handle, window, cx)))
3415 })
3416 .context("Failed to create sidebar")??;
3417
3418 multi_workspace_window
3419 .update(cx, |mw, _window, cx| {
3420 mw.register_sidebar(sidebar.clone(), cx);
3421 })
3422 .context("Failed to register sidebar")?;
3423
3424 cx.run_until_parked();
3425
3426 // Open the sidebar
3427 multi_workspace_window
3428 .update(cx, |mw, window, cx| {
3429 mw.toggle_sidebar(window, cx);
3430 })
3431 .context("Failed to toggle sidebar")?;
3432
3433 // Let rendering settle
3434 for _ in 0..10 {
3435 cx.advance_clock(Duration::from_millis(100));
3436 cx.run_until_parked();
3437 }
3438
3439 // Refresh the window
3440 cx.update_window(multi_workspace_window.into(), |_, window, _cx| {
3441 window.refresh();
3442 })?;
3443
3444 cx.run_until_parked();
3445
3446 Ok(multi_workspace_window)
3447}
3448
3449#[cfg(target_os = "macos")]
3450fn cleanup_sidebar_test_window(
3451 window: WindowHandle<MultiWorkspace>,
3452 cx: &mut VisualTestAppContext,
3453) -> Result<()> {
3454 window.update(cx, |mw, _window, cx| {
3455 for workspace in mw.workspaces() {
3456 let project = workspace.read(cx).project().clone();
3457 project.update(cx, |project, cx| {
3458 let ids: Vec<_> = project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
3459 for id in ids {
3460 project.remove_worktree(id, cx);
3461 }
3462 });
3463 }
3464 })?;
3465
3466 cx.run_until_parked();
3467
3468 cx.update_window(window.into(), |_, window, _cx| {
3469 window.remove_window();
3470 })?;
3471
3472 cx.run_until_parked();
3473
3474 for _ in 0..15 {
3475 cx.advance_clock(Duration::from_millis(100));
3476 cx.run_until_parked();
3477 }
3478
3479 Ok(())
3480}
3481
3482#[cfg(target_os = "macos")]
3483fn run_sidebar_duplicate_project_names_visual_tests(
3484 app_state: Arc<AppState>,
3485 cx: &mut VisualTestAppContext,
3486 update_baseline: bool,
3487) -> Result<TestResult> {
3488 let temp_dir = tempfile::tempdir()?;
3489 let temp_path = temp_dir.keep();
3490 let canonical_temp = temp_path.canonicalize()?;
3491
3492 // Create directory structure where every leaf directory is named "zed" but
3493 // lives at a distinct path. This lets us test that the sidebar correctly
3494 // disambiguates projects whose names would otherwise collide.
3495 //
3496 // code/zed/ — project1 (single worktree)
3497 // code/foo/zed/ — project2 (single worktree)
3498 // code/bar/zed/ — project3, first worktree
3499 // code/baz/zed/ — project3, second worktree
3500 //
3501 // No two projects share a worktree path, so ProjectGroupBuilder will
3502 // place each in its own group.
3503 let code_zed = canonical_temp.join("code").join("zed");
3504 let foo_zed = canonical_temp.join("code").join("foo").join("zed");
3505 let bar_zed = canonical_temp.join("code").join("bar").join("zed");
3506 let baz_zed = canonical_temp.join("code").join("baz").join("zed");
3507 std::fs::create_dir_all(&code_zed)?;
3508 std::fs::create_dir_all(&foo_zed)?;
3509 std::fs::create_dir_all(&bar_zed)?;
3510 std::fs::create_dir_all(&baz_zed)?;
3511
3512 cx.update(|cx| {
3513 cx.update_flags(true, vec!["agent-v2".to_string()]);
3514 });
3515
3516 let mut has_baseline_update = None;
3517
3518 // Two single-worktree projects whose leaf name is "zed"
3519 {
3520 let project1 = create_project_with_worktree(&code_zed, &app_state, cx)?;
3521 let project2 = create_project_with_worktree(&foo_zed, &app_state, cx)?;
3522
3523 let window = open_sidebar_test_window(vec![project1, project2], &app_state, cx)?;
3524
3525 let result = run_visual_test(
3526 "sidebar_two_projects_same_leaf_name",
3527 window.into(),
3528 cx,
3529 update_baseline,
3530 );
3531
3532 cleanup_sidebar_test_window(window, cx)?;
3533 match result? {
3534 TestResult::Passed => {}
3535 TestResult::BaselineUpdated(path) => {
3536 has_baseline_update = Some(path);
3537 }
3538 }
3539 }
3540
3541 // Three projects, third has two worktrees (all leaf names "zed")
3542 //
3543 // project1: code/zed
3544 // project2: code/foo/zed
3545 // project3: code/bar/zed + code/baz/zed
3546 //
3547 // Each project has a unique set of worktree paths, so they form
3548 // separate groups. The sidebar must disambiguate all three.
3549 {
3550 let project1 = create_project_with_worktree(&code_zed, &app_state, cx)?;
3551 let project2 = create_project_with_worktree(&foo_zed, &app_state, cx)?;
3552
3553 let project3 = create_project_with_worktree(&bar_zed, &app_state, cx)?;
3554 let add_second_worktree = cx.update(|cx| {
3555 project3.update(cx, |project, cx| {
3556 project.find_or_create_worktree(&baz_zed, true, cx)
3557 })
3558 });
3559 cx.background_executor.allow_parking();
3560 cx.foreground_executor
3561 .block_test(add_second_worktree)
3562 .context("Failed to add second worktree to project 3")?;
3563 cx.background_executor.forbid_parking();
3564 cx.run_until_parked();
3565
3566 let window = open_sidebar_test_window(vec![project1, project2, project3], &app_state, cx)?;
3567
3568 let result = run_visual_test(
3569 "sidebar_three_projects_with_multi_worktree",
3570 window.into(),
3571 cx,
3572 update_baseline,
3573 );
3574
3575 cleanup_sidebar_test_window(window, cx)?;
3576 match result? {
3577 TestResult::Passed => {}
3578 TestResult::BaselineUpdated(path) => {
3579 has_baseline_update = Some(path);
3580 }
3581 }
3582 }
3583
3584 if let Some(path) = has_baseline_update {
3585 Ok(TestResult::BaselineUpdated(path))
3586 } else {
3587 Ok(TestResult::Passed)
3588 }
3589}