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