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