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