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