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