visual_test_runner.rs

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