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