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// All macOS-specific imports grouped together
  46#[cfg(target_os = "macos")]
  47use {
  48    acp_thread::{AgentConnection, StubAgentConnection},
  49    agent_client_protocol as acp,
  50    agent_servers::{AgentServer, AgentServerDelegate},
  51    anyhow::{Context as _, Result},
  52    assets::Assets,
  53    editor::display_map::DisplayRow,
  54    feature_flags::FeatureFlagAppExt as _,
  55    git_ui::project_diff::ProjectDiff,
  56    gpui::{
  57        App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString, VisualTestAppContext,
  58        WindowBounds, WindowHandle, WindowOptions, point, px, size,
  59    },
  60    image::RgbaImage,
  61    project_panel::ProjectPanel,
  62    settings::{NotifyWhenAgentWaiting, Settings as _},
  63    std::{
  64        any::Any,
  65        path::{Path, PathBuf},
  66        rc::Rc,
  67        sync::Arc,
  68        time::Duration,
  69    },
  70    watch,
  71    workspace::{AppState, Workspace},
  72};
  73
  74// All macOS-specific constants grouped together
  75#[cfg(target_os = "macos")]
  76mod constants {
  77    use std::time::Duration;
  78
  79    /// Baseline images are stored relative to this file
  80    pub const BASELINE_DIR: &str = "crates/zed/test_fixtures/visual_tests";
  81
  82    /// Embedded test image (Zed app icon) for visual tests.
  83    pub const EMBEDDED_TEST_IMAGE: &[u8] = include_bytes!("../resources/app-icon.png");
  84
  85    /// Threshold for image comparison (0.0 to 1.0)
  86    /// Images must match at least this percentage to pass
  87    pub const MATCH_THRESHOLD: f64 = 0.99;
  88
  89    /// Tooltip show delay - must match TOOLTIP_SHOW_DELAY in gpui/src/elements/div.rs
  90    pub const TOOLTIP_SHOW_DELAY: Duration = Duration::from_millis(500);
  91}
  92
  93#[cfg(target_os = "macos")]
  94use constants::*;
  95
  96#[cfg(target_os = "macos")]
  97fn main() {
  98    // Set ZED_STATELESS early to prevent file system access to real config directories
  99    // This must be done before any code accesses zed_env_vars::ZED_STATELESS
 100    // SAFETY: We're at the start of main(), before any threads are spawned
 101    unsafe {
 102        std::env::set_var("ZED_STATELESS", "1");
 103    }
 104
 105    env_logger::builder()
 106        .filter_level(log::LevelFilter::Info)
 107        .init();
 108
 109    let update_baseline = std::env::var("UPDATE_BASELINE").is_ok();
 110
 111    // Create a temporary directory for test files
 112    // Canonicalize the path to resolve symlinks (on macOS, /var -> /private/var)
 113    // which prevents "path does not exist" errors during worktree scanning
 114    // Use keep() to prevent auto-cleanup - background worktree tasks may still be running
 115    // when tests complete, so we let the OS clean up temp directories on process exit
 116    let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
 117    let temp_path = temp_dir.keep();
 118    let canonical_temp = temp_path
 119        .canonicalize()
 120        .expect("Failed to canonicalize temp directory");
 121    let project_path = canonical_temp.join("project");
 122    std::fs::create_dir_all(&project_path).expect("Failed to create project directory");
 123
 124    // Create test files in the real filesystem
 125    create_test_files(&project_path);
 126
 127    let test_result = std::panic::catch_unwind(|| run_visual_tests(project_path, update_baseline));
 128
 129    // Note: We don't delete temp_path here because background worktree tasks may still
 130    // be running. The directory will be cleaned up when the process exits or by the OS.
 131
 132    match test_result {
 133        Ok(Ok(())) => {}
 134        Ok(Err(e)) => {
 135            eprintln!("Visual tests failed: {}", e);
 136            std::process::exit(1);
 137        }
 138        Err(_) => {
 139            eprintln!("Visual tests panicked");
 140            std::process::exit(1);
 141        }
 142    }
 143}
 144
 145#[cfg(target_os = "macos")]
 146fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> {
 147    // Create the visual test context with deterministic task scheduling
 148    // Use real Assets so that SVG icons render properly
 149    let mut cx = VisualTestAppContext::with_asset_source(Arc::new(Assets));
 150
 151    // Load embedded fonts (IBM Plex Sans, Lilex, etc.) so UI renders with correct fonts
 152    cx.update(|cx| {
 153        Assets.load_fonts(cx).unwrap();
 154    });
 155
 156    // Initialize settings store with real default settings (not test settings)
 157    // Test settings use Courier font, but we want the real Zed fonts for visual tests
 158    cx.update(|cx| {
 159        settings::init(cx);
 160    });
 161
 162    // Create AppState using the test initialization
 163    let app_state = cx.update(|cx| init_app_state(cx));
 164
 165    // Initialize all Zed subsystems
 166    cx.update(|cx| {
 167        gpui_tokio::init(cx);
 168        theme::init(theme::LoadThemes::JustBase, cx);
 169        client::init(&app_state.client, cx);
 170        audio::init(cx);
 171        workspace::init(app_state.clone(), cx);
 172        release_channel::init(semver::Version::new(0, 0, 0), cx);
 173        command_palette::init(cx);
 174        editor::init(cx);
 175        call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
 176        title_bar::init(cx);
 177        project_panel::init(cx);
 178        outline_panel::init(cx);
 179        terminal_view::init(cx);
 180        image_viewer::init(cx);
 181        search::init(cx);
 182        prompt_store::init(cx);
 183        language_model::init(app_state.client.clone(), cx);
 184        language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
 185        git_ui::init(cx);
 186
 187        // Load default keymaps so tooltips can show keybindings like "f9" for ToggleBreakpoint
 188        // We load a minimal set of editor keybindings needed for visual tests
 189        cx.bind_keys([KeyBinding::new(
 190            "f9",
 191            editor::actions::ToggleBreakpoint,
 192            Some("Editor"),
 193        )]);
 194
 195        // Disable agent notifications during visual tests to avoid popup windows
 196        agent_settings::AgentSettings::override_global(
 197            agent_settings::AgentSettings {
 198                notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
 199                play_sound_when_agent_done: false,
 200                ..agent_settings::AgentSettings::get_global(cx).clone()
 201            },
 202            cx,
 203        );
 204    });
 205
 206    // Run until all initialization tasks complete
 207    cx.run_until_parked();
 208
 209    // Open workspace window
 210    let window_size = size(px(1280.0), px(800.0));
 211    let bounds = Bounds {
 212        origin: point(px(0.0), px(0.0)),
 213        size: window_size,
 214    };
 215
 216    // Create a project for the workspace
 217    let project = cx.update(|cx| {
 218        project::Project::local(
 219            app_state.client.clone(),
 220            app_state.node_runtime.clone(),
 221            app_state.user_store.clone(),
 222            app_state.languages.clone(),
 223            app_state.fs.clone(),
 224            None,
 225            false,
 226            cx,
 227        )
 228    });
 229
 230    let workspace_window: WindowHandle<Workspace> = cx
 231        .update(|cx| {
 232            cx.open_window(
 233                WindowOptions {
 234                    window_bounds: Some(WindowBounds::Windowed(bounds)),
 235                    focus: false,
 236                    show: false,
 237                    ..Default::default()
 238                },
 239                |window, cx| {
 240                    cx.new(|cx| {
 241                        Workspace::new(None, project.clone(), app_state.clone(), window, cx)
 242                    })
 243                },
 244            )
 245        })
 246        .context("Failed to open workspace window")?;
 247
 248    cx.run_until_parked();
 249
 250    // Add the test project as a worktree
 251    let add_worktree_task = workspace_window
 252        .update(&mut cx, |workspace, _window, cx| {
 253            let project = workspace.project().clone();
 254            project.update(cx, |project, cx| {
 255                project.find_or_create_worktree(&project_path, true, cx)
 256            })
 257        })
 258        .context("Failed to start adding worktree")?;
 259
 260    // Use block_test to wait for the worktree task
 261    // block_test runs both foreground and background tasks, which is needed because
 262    // worktree creation spawns foreground tasks via cx.spawn
 263    // Allow parking since filesystem operations happen outside the test dispatcher
 264    cx.background_executor.allow_parking();
 265    let worktree_result = cx.foreground_executor.block_test(add_worktree_task);
 266    cx.background_executor.forbid_parking();
 267    worktree_result.context("Failed to add worktree")?;
 268
 269    cx.run_until_parked();
 270
 271    // Create and add the project panel
 272    let (weak_workspace, async_window_cx) = workspace_window
 273        .update(&mut cx, |workspace, window, cx| {
 274            (workspace.weak_handle(), window.to_async(cx))
 275        })
 276        .context("Failed to get workspace handle")?;
 277
 278    cx.background_executor.allow_parking();
 279    let panel = cx
 280        .foreground_executor
 281        .block_test(ProjectPanel::load(weak_workspace, async_window_cx))
 282        .context("Failed to load project panel")?;
 283    cx.background_executor.forbid_parking();
 284
 285    workspace_window
 286        .update(&mut cx, |workspace, window, cx| {
 287            workspace.add_panel(panel, window, cx);
 288        })
 289        .ok();
 290
 291    cx.run_until_parked();
 292
 293    // Open the project panel
 294    workspace_window
 295        .update(&mut cx, |workspace, window, cx| {
 296            workspace.open_panel::<ProjectPanel>(window, cx);
 297        })
 298        .ok();
 299
 300    cx.run_until_parked();
 301
 302    // Open main.rs in the editor
 303    let open_file_task = workspace_window
 304        .update(&mut cx, |workspace, window, cx| {
 305            let worktree = workspace.project().read(cx).worktrees(cx).next();
 306            if let Some(worktree) = worktree {
 307                let worktree_id = worktree.read(cx).id();
 308                let rel_path: std::sync::Arc<util::rel_path::RelPath> =
 309                    util::rel_path::rel_path("src/main.rs").into();
 310                let project_path: project::ProjectPath = (worktree_id, rel_path).into();
 311                Some(workspace.open_path(project_path, None, true, window, cx))
 312            } else {
 313                None
 314            }
 315        })
 316        .ok()
 317        .flatten();
 318
 319    if let Some(task) = open_file_task {
 320        cx.background_executor.allow_parking();
 321        let block_result = cx.foreground_executor.block_test(task);
 322        cx.background_executor.forbid_parking();
 323        if let Ok(item) = block_result {
 324            workspace_window
 325                .update(&mut cx, |workspace, window, cx| {
 326                    let pane = workspace.active_pane().clone();
 327                    pane.update(cx, |pane, cx| {
 328                        if let Some(index) = pane.index_for_item(item.as_ref()) {
 329                            pane.activate_item(index, true, true, window, cx);
 330                        }
 331                    });
 332                })
 333                .ok();
 334        }
 335    }
 336
 337    cx.run_until_parked();
 338
 339    // Request a window refresh
 340    cx.update_window(workspace_window.into(), |_, window, _cx| {
 341        window.refresh();
 342    })
 343    .ok();
 344
 345    cx.run_until_parked();
 346
 347    // Track test results
 348    let mut passed = 0;
 349    let mut failed = 0;
 350    let mut updated = 0;
 351
 352    // Run Test 1: Project Panel (with project panel visible)
 353    println!("\n--- Test 1: project_panel ---");
 354    match run_visual_test(
 355        "project_panel",
 356        workspace_window.into(),
 357        &mut cx,
 358        update_baseline,
 359    ) {
 360        Ok(TestResult::Passed) => {
 361            println!("✓ project_panel: PASSED");
 362            passed += 1;
 363        }
 364        Ok(TestResult::BaselineUpdated(_)) => {
 365            println!("✓ project_panel: Baseline updated");
 366            updated += 1;
 367        }
 368        Err(e) => {
 369            eprintln!("✗ project_panel: FAILED - {}", e);
 370            failed += 1;
 371        }
 372    }
 373
 374    // Run Test 2: Workspace with Editor
 375    println!("\n--- Test 2: workspace_with_editor ---");
 376
 377    // Close project panel for this test
 378    workspace_window
 379        .update(&mut cx, |workspace, window, cx| {
 380            workspace.close_panel::<ProjectPanel>(window, cx);
 381        })
 382        .ok();
 383
 384    cx.run_until_parked();
 385
 386    match run_visual_test(
 387        "workspace_with_editor",
 388        workspace_window.into(),
 389        &mut cx,
 390        update_baseline,
 391    ) {
 392        Ok(TestResult::Passed) => {
 393            println!("✓ workspace_with_editor: PASSED");
 394            passed += 1;
 395        }
 396        Ok(TestResult::BaselineUpdated(_)) => {
 397            println!("✓ workspace_with_editor: Baseline updated");
 398            updated += 1;
 399        }
 400        Err(e) => {
 401            eprintln!("✗ workspace_with_editor: FAILED - {}", e);
 402            failed += 1;
 403        }
 404    }
 405
 406    // Run Test 3: Agent Thread View tests
 407    #[cfg(feature = "visual-tests")]
 408    {
 409        println!("\n--- Test 3: agent_thread_with_image (collapsed + expanded) ---");
 410        match run_agent_thread_view_test(app_state.clone(), &mut cx, update_baseline) {
 411            Ok(TestResult::Passed) => {
 412                println!("✓ agent_thread_with_image (collapsed + expanded): PASSED");
 413                passed += 1;
 414            }
 415            Ok(TestResult::BaselineUpdated(_)) => {
 416                println!("✓ agent_thread_with_image: Baselines updated (collapsed + expanded)");
 417                updated += 1;
 418            }
 419            Err(e) => {
 420                eprintln!("✗ agent_thread_with_image: FAILED - {}", e);
 421                failed += 1;
 422            }
 423        }
 424    }
 425
 426    // Run Test 4: Subagent Cards visual tests
 427    #[cfg(feature = "visual-tests")]
 428    {
 429        println!("\n--- Test 4: subagent_cards (running, completed, expanded) ---");
 430        match run_subagent_visual_tests(app_state.clone(), &mut cx, update_baseline) {
 431            Ok(TestResult::Passed) => {
 432                println!("✓ subagent_cards: PASSED");
 433                passed += 1;
 434            }
 435            Ok(TestResult::BaselineUpdated(_)) => {
 436                println!("✓ subagent_cards: Baselines updated");
 437                updated += 1;
 438            }
 439            Err(e) => {
 440                eprintln!("✗ subagent_cards: FAILED - {}", e);
 441                failed += 1;
 442            }
 443        }
 444    }
 445
 446    // Run Test 5: Breakpoint Hover visual tests
 447    println!("\n--- Test 5: breakpoint_hover (3 variants) ---");
 448    match run_breakpoint_hover_visual_tests(app_state.clone(), &mut cx, update_baseline) {
 449        Ok(TestResult::Passed) => {
 450            println!("✓ breakpoint_hover: PASSED");
 451            passed += 1;
 452        }
 453        Ok(TestResult::BaselineUpdated(_)) => {
 454            println!("✓ breakpoint_hover: Baselines updated");
 455            updated += 1;
 456        }
 457        Err(e) => {
 458            eprintln!("✗ breakpoint_hover: FAILED - {}", e);
 459            failed += 1;
 460        }
 461    }
 462
 463    // Run Test 6: Diff Review Button visual tests
 464    println!("\n--- Test 6: diff_review_button (3 variants) ---");
 465    match run_diff_review_visual_tests(app_state.clone(), &mut cx, update_baseline) {
 466        Ok(TestResult::Passed) => {
 467            println!("✓ diff_review_button: PASSED");
 468            passed += 1;
 469        }
 470        Ok(TestResult::BaselineUpdated(_)) => {
 471            println!("✓ diff_review_button: Baselines updated");
 472            updated += 1;
 473        }
 474        Err(e) => {
 475            eprintln!("✗ diff_review_button: FAILED - {}", e);
 476            failed += 1;
 477        }
 478    }
 479
 480    // Clean up the main workspace's worktree to stop background scanning tasks
 481    // This prevents "root path could not be canonicalized" errors when main() drops temp_dir
 482    workspace_window
 483        .update(&mut cx, |workspace, _window, cx| {
 484            let project = workspace.project().clone();
 485            project.update(cx, |project, cx| {
 486                let worktree_ids: Vec<_> =
 487                    project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
 488                for id in worktree_ids {
 489                    project.remove_worktree(id, cx);
 490                }
 491            });
 492        })
 493        .ok();
 494
 495    cx.run_until_parked();
 496
 497    // Close the main window
 498    let _ = cx.update_window(workspace_window.into(), |_, window, _cx| {
 499        window.remove_window();
 500    });
 501
 502    // Run until all cleanup tasks complete
 503    cx.run_until_parked();
 504
 505    // Give background tasks time to finish, including scrollbar hide timers (1 second)
 506    for _ in 0..15 {
 507        cx.advance_clock(Duration::from_millis(100));
 508        cx.run_until_parked();
 509    }
 510
 511    // Print summary
 512    println!("\n=== Test Summary ===");
 513    println!("Passed: {}", passed);
 514    println!("Failed: {}", failed);
 515    if updated > 0 {
 516        println!("Baselines Updated: {}", updated);
 517    }
 518
 519    if failed > 0 {
 520        eprintln!("\n=== Visual Tests FAILED ===");
 521        Err(anyhow::anyhow!("{} tests failed", failed))
 522    } else {
 523        println!("\n=== All Visual Tests PASSED ===");
 524        Ok(())
 525    }
 526}
 527
 528#[cfg(target_os = "macos")]
 529enum TestResult {
 530    Passed,
 531    BaselineUpdated(PathBuf),
 532}
 533
 534#[cfg(target_os = "macos")]
 535fn run_visual_test(
 536    test_name: &str,
 537    window: gpui::AnyWindowHandle,
 538    cx: &mut VisualTestAppContext,
 539    update_baseline: bool,
 540) -> Result<TestResult> {
 541    // Ensure all pending work is done
 542    cx.run_until_parked();
 543
 544    // Refresh the window to ensure it's fully rendered
 545    cx.update_window(window, |_, window, _cx| {
 546        window.refresh();
 547    })?;
 548
 549    cx.run_until_parked();
 550
 551    // Capture the screenshot using direct texture capture
 552    let screenshot = cx.capture_screenshot(window)?;
 553
 554    // Get paths
 555    let baseline_path = get_baseline_path(test_name);
 556    let output_dir = std::env::var("VISUAL_TEST_OUTPUT_DIR")
 557        .unwrap_or_else(|_| "target/visual_tests".to_string());
 558    let output_path = PathBuf::from(&output_dir).join(format!("{}.png", test_name));
 559
 560    // Ensure output directory exists
 561    std::fs::create_dir_all(&output_dir)?;
 562
 563    // Always save the current screenshot
 564    screenshot.save(&output_path)?;
 565    println!("  Screenshot saved to: {}", output_path.display());
 566
 567    if update_baseline {
 568        // Update the baseline
 569        if let Some(parent) = baseline_path.parent() {
 570            std::fs::create_dir_all(parent)?;
 571        }
 572        screenshot.save(&baseline_path)?;
 573        println!("  Baseline updated: {}", baseline_path.display());
 574        return Ok(TestResult::BaselineUpdated(baseline_path));
 575    }
 576
 577    // Compare with baseline
 578    if !baseline_path.exists() {
 579        return Err(anyhow::anyhow!(
 580            "Baseline not found: {}. Run with UPDATE_BASELINE=1 to create it.",
 581            baseline_path.display()
 582        ));
 583    }
 584
 585    let baseline = image::open(&baseline_path)?.to_rgba8();
 586    let comparison = compare_images(&screenshot, &baseline);
 587
 588    println!(
 589        "  Match: {:.2}% ({} different pixels)",
 590        comparison.match_percentage * 100.0,
 591        comparison.diff_pixel_count
 592    );
 593
 594    if comparison.match_percentage >= MATCH_THRESHOLD {
 595        Ok(TestResult::Passed)
 596    } else {
 597        // Save diff image
 598        let diff_path = PathBuf::from(&output_dir).join(format!("{}_diff.png", test_name));
 599        comparison.diff_image.save(&diff_path)?;
 600        println!("  Diff image saved to: {}", diff_path.display());
 601
 602        Err(anyhow::anyhow!(
 603            "Image mismatch: {:.2}% match (threshold: {:.2}%)",
 604            comparison.match_percentage * 100.0,
 605            MATCH_THRESHOLD * 100.0
 606        ))
 607    }
 608}
 609
 610#[cfg(target_os = "macos")]
 611fn get_baseline_path(test_name: &str) -> PathBuf {
 612    // Get the workspace root (where Cargo.toml is)
 613    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
 614    let workspace_root = PathBuf::from(manifest_dir)
 615        .parent()
 616        .and_then(|p| p.parent())
 617        .map(|p| p.to_path_buf())
 618        .unwrap_or_else(|| PathBuf::from("."));
 619
 620    workspace_root
 621        .join(BASELINE_DIR)
 622        .join(format!("{}.png", test_name))
 623}
 624
 625#[cfg(target_os = "macos")]
 626struct ImageComparison {
 627    match_percentage: f64,
 628    diff_image: RgbaImage,
 629    diff_pixel_count: u32,
 630    #[allow(dead_code)]
 631    total_pixels: u32,
 632}
 633
 634#[cfg(target_os = "macos")]
 635fn compare_images(actual: &RgbaImage, expected: &RgbaImage) -> ImageComparison {
 636    let width = actual.width().max(expected.width());
 637    let height = actual.height().max(expected.height());
 638    let total_pixels = width * height;
 639
 640    let mut diff_image = RgbaImage::new(width, height);
 641    let mut matching_pixels = 0u32;
 642
 643    for y in 0..height {
 644        for x in 0..width {
 645            let actual_pixel = if x < actual.width() && y < actual.height() {
 646                *actual.get_pixel(x, y)
 647            } else {
 648                image::Rgba([0, 0, 0, 0])
 649            };
 650
 651            let expected_pixel = if x < expected.width() && y < expected.height() {
 652                *expected.get_pixel(x, y)
 653            } else {
 654                image::Rgba([0, 0, 0, 0])
 655            };
 656
 657            if pixels_are_similar(&actual_pixel, &expected_pixel) {
 658                matching_pixels += 1;
 659                // Semi-transparent green for matching pixels
 660                diff_image.put_pixel(x, y, image::Rgba([0, 255, 0, 64]));
 661            } else {
 662                // Bright red for differing pixels
 663                diff_image.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
 664            }
 665        }
 666    }
 667
 668    let match_percentage = matching_pixels as f64 / total_pixels as f64;
 669    let diff_pixel_count = total_pixels - matching_pixels;
 670
 671    ImageComparison {
 672        match_percentage,
 673        diff_image,
 674        diff_pixel_count,
 675        total_pixels,
 676    }
 677}
 678
 679#[cfg(target_os = "macos")]
 680fn pixels_are_similar(a: &image::Rgba<u8>, b: &image::Rgba<u8>) -> bool {
 681    const TOLERANCE: i16 = 2;
 682    (a.0[0] as i16 - b.0[0] as i16).abs() <= TOLERANCE
 683        && (a.0[1] as i16 - b.0[1] as i16).abs() <= TOLERANCE
 684        && (a.0[2] as i16 - b.0[2] as i16).abs() <= TOLERANCE
 685        && (a.0[3] as i16 - b.0[3] as i16).abs() <= TOLERANCE
 686}
 687
 688#[cfg(target_os = "macos")]
 689fn create_test_files(project_path: &Path) {
 690    // Create src directory
 691    let src_dir = project_path.join("src");
 692    std::fs::create_dir_all(&src_dir).expect("Failed to create src directory");
 693
 694    // Create main.rs
 695    let main_rs = r#"fn main() {
 696    println!("Hello, world!");
 697
 698    let x = 42;
 699    let y = x * 2;
 700
 701    if y > 50 {
 702        println!("y is greater than 50");
 703    } else {
 704        println!("y is not greater than 50");
 705    }
 706
 707    for i in 0..10 {
 708        println!("i = {}", i);
 709    }
 710}
 711
 712fn helper_function(a: i32, b: i32) -> i32 {
 713    a + b
 714}
 715
 716struct MyStruct {
 717    field1: String,
 718    field2: i32,
 719}
 720
 721impl MyStruct {
 722    fn new(name: &str, value: i32) -> Self {
 723        Self {
 724            field1: name.to_string(),
 725            field2: value,
 726        }
 727    }
 728
 729    fn get_value(&self) -> i32 {
 730        self.field2
 731    }
 732}
 733"#;
 734    std::fs::write(src_dir.join("main.rs"), main_rs).expect("Failed to write main.rs");
 735
 736    // Create lib.rs
 737    let lib_rs = r#"//! A sample library for visual testing
 738
 739pub mod utils;
 740
 741/// A public function in the library
 742pub fn library_function() -> String {
 743    "Hello from lib".to_string()
 744}
 745
 746#[cfg(test)]
 747mod tests {
 748    use super::*;
 749
 750    #[test]
 751    fn it_works() {
 752        assert_eq!(library_function(), "Hello from lib");
 753    }
 754}
 755"#;
 756    std::fs::write(src_dir.join("lib.rs"), lib_rs).expect("Failed to write lib.rs");
 757
 758    // Create utils.rs
 759    let utils_rs = r#"//! Utility functions
 760
 761/// Format a number with commas
 762pub fn format_number(n: u64) -> String {
 763    let s = n.to_string();
 764    let mut result = String::new();
 765    for (i, c) in s.chars().rev().enumerate() {
 766        if i > 0 && i % 3 == 0 {
 767            result.push(',');
 768        }
 769        result.push(c);
 770    }
 771    result.chars().rev().collect()
 772}
 773
 774/// Calculate fibonacci number
 775pub fn fibonacci(n: u32) -> u64 {
 776    match n {
 777        0 => 0,
 778        1 => 1,
 779        _ => fibonacci(n - 1) + fibonacci(n - 2),
 780    }
 781}
 782"#;
 783    std::fs::write(src_dir.join("utils.rs"), utils_rs).expect("Failed to write utils.rs");
 784
 785    // Create Cargo.toml
 786    let cargo_toml = r#"[package]
 787name = "test_project"
 788version = "0.1.0"
 789edition = "2021"
 790
 791[dependencies]
 792"#;
 793    std::fs::write(project_path.join("Cargo.toml"), cargo_toml)
 794        .expect("Failed to write Cargo.toml");
 795
 796    // Create README.md
 797    let readme = r#"# Test Project
 798
 799This is a test project for visual testing of Zed.
 800
 801## Features
 802
 803- Feature 1
 804- Feature 2
 805- Feature 3
 806
 807## Usage
 808
 809```bash
 810cargo run
 811```
 812"#;
 813    std::fs::write(project_path.join("README.md"), readme).expect("Failed to write README.md");
 814}
 815
 816#[cfg(target_os = "macos")]
 817fn init_app_state(cx: &mut App) -> Arc<AppState> {
 818    use fs::Fs;
 819    use node_runtime::NodeRuntime;
 820    use session::Session;
 821    use settings::SettingsStore;
 822
 823    if !cx.has_global::<SettingsStore>() {
 824        let settings_store = SettingsStore::test(cx);
 825        cx.set_global(settings_store);
 826    }
 827
 828    // Use the real filesystem instead of FakeFs so we can access actual files on disk
 829    let fs: Arc<dyn Fs> = Arc::new(fs::RealFs::new(None, cx.background_executor().clone()));
 830    <dyn Fs>::set_global(fs.clone(), cx);
 831
 832    let languages = Arc::new(language::LanguageRegistry::test(
 833        cx.background_executor().clone(),
 834    ));
 835    let clock = Arc::new(clock::FakeSystemClock::new());
 836    let http_client = http_client::FakeHttpClient::with_404_response();
 837    let client = client::Client::new(clock, http_client, cx);
 838    let session = cx.new(|cx| session::AppSession::new(Session::test(), cx));
 839    let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx));
 840    let workspace_store = cx.new(|cx| workspace::WorkspaceStore::new(client.clone(), cx));
 841
 842    theme::init(theme::LoadThemes::JustBase, cx);
 843    client::init(&client, cx);
 844
 845    Arc::new(AppState {
 846        client,
 847        fs,
 848        languages,
 849        user_store,
 850        workspace_store,
 851        node_runtime: NodeRuntime::unavailable(),
 852        build_window_options: |_, _| Default::default(),
 853        session,
 854    })
 855}
 856
 857/// Runs visual tests for breakpoint hover states in the editor gutter.
 858///
 859/// This test captures three states:
 860/// 1. Gutter with line numbers, no breakpoint hover (baseline)
 861/// 2. Gutter with breakpoint hover indicator (gray circle)
 862/// 3. Gutter with breakpoint hover AND tooltip
 863#[cfg(target_os = "macos")]
 864fn run_breakpoint_hover_visual_tests(
 865    app_state: Arc<AppState>,
 866    cx: &mut VisualTestAppContext,
 867    update_baseline: bool,
 868) -> Result<TestResult> {
 869    // Create a temporary directory with a simple test file
 870    let temp_dir = tempfile::tempdir()?;
 871    let temp_path = temp_dir.keep();
 872    let canonical_temp = temp_path.canonicalize()?;
 873    let project_path = canonical_temp.join("project");
 874    std::fs::create_dir_all(&project_path)?;
 875
 876    // Create a simple file with a few lines
 877    let src_dir = project_path.join("src");
 878    std::fs::create_dir_all(&src_dir)?;
 879
 880    let test_content = r#"fn main() {
 881    println!("Hello");
 882    let x = 42;
 883}
 884"#;
 885    std::fs::write(src_dir.join("test.rs"), test_content)?;
 886
 887    // Create a small window - just big enough to show gutter and a few lines
 888    let window_size = size(px(300.0), px(200.0));
 889    let bounds = Bounds {
 890        origin: point(px(0.0), px(0.0)),
 891        size: window_size,
 892    };
 893
 894    // Create project
 895    let project = cx.update(|cx| {
 896        project::Project::local(
 897            app_state.client.clone(),
 898            app_state.node_runtime.clone(),
 899            app_state.user_store.clone(),
 900            app_state.languages.clone(),
 901            app_state.fs.clone(),
 902            None,
 903            false,
 904            cx,
 905        )
 906    });
 907
 908    // Open workspace window
 909    let workspace_window: WindowHandle<Workspace> = cx
 910        .update(|cx| {
 911            cx.open_window(
 912                WindowOptions {
 913                    window_bounds: Some(WindowBounds::Windowed(bounds)),
 914                    focus: false,
 915                    show: false,
 916                    ..Default::default()
 917                },
 918                |window, cx| {
 919                    cx.new(|cx| {
 920                        Workspace::new(None, project.clone(), app_state.clone(), window, cx)
 921                    })
 922                },
 923            )
 924        })
 925        .context("Failed to open breakpoint test window")?;
 926
 927    cx.run_until_parked();
 928
 929    // Add the project as a worktree
 930    let add_worktree_task = workspace_window
 931        .update(cx, |workspace, _window, cx| {
 932            let project = workspace.project().clone();
 933            project.update(cx, |project, cx| {
 934                project.find_or_create_worktree(&project_path, true, cx)
 935            })
 936        })
 937        .context("Failed to start adding worktree")?;
 938
 939    cx.background_executor.allow_parking();
 940    let worktree_result = cx.foreground_executor.block_test(add_worktree_task);
 941    cx.background_executor.forbid_parking();
 942    worktree_result.context("Failed to add worktree")?;
 943
 944    cx.run_until_parked();
 945
 946    // Open the test file
 947    let open_file_task = workspace_window
 948        .update(cx, |workspace, window, cx| {
 949            let worktree = workspace.project().read(cx).worktrees(cx).next();
 950            if let Some(worktree) = worktree {
 951                let worktree_id = worktree.read(cx).id();
 952                let rel_path: std::sync::Arc<util::rel_path::RelPath> =
 953                    util::rel_path::rel_path("src/test.rs").into();
 954                let project_path: project::ProjectPath = (worktree_id, rel_path).into();
 955                Some(workspace.open_path(project_path, None, true, window, cx))
 956            } else {
 957                None
 958            }
 959        })
 960        .ok()
 961        .flatten();
 962
 963    if let Some(task) = open_file_task {
 964        cx.background_executor.allow_parking();
 965        let _ = cx.foreground_executor.block_test(task);
 966        cx.background_executor.forbid_parking();
 967    }
 968
 969    cx.run_until_parked();
 970
 971    // Wait for the editor to fully load
 972    for _ in 0..10 {
 973        cx.advance_clock(Duration::from_millis(100));
 974        cx.run_until_parked();
 975    }
 976
 977    // Refresh window
 978    cx.update_window(workspace_window.into(), |_, window, _cx| {
 979        window.refresh();
 980    })?;
 981
 982    cx.run_until_parked();
 983
 984    // Test 1: Gutter visible with line numbers, no breakpoint hover
 985    let test1_result = run_visual_test(
 986        "breakpoint_hover_none",
 987        workspace_window.into(),
 988        cx,
 989        update_baseline,
 990    )?;
 991
 992    // Test 2: Breakpoint hover indicator (circle) visible
 993    // The gutter is on the left side. We need to position the mouse over the gutter area
 994    // for line 1. The breakpoint indicator appears in the leftmost part of the gutter.
 995    //
 996    // The breakpoint hover requires multiple steps:
 997    // 1. Draw to register mouse listeners
 998    // 2. Mouse move to trigger gutter_hovered and create PhantomBreakpointIndicator
 999    // 3. Wait 200ms for is_active to become true
1000    // 4. Draw again to render the indicator
1001    //
1002    // The gutter_position should be in the gutter area to trigger the phantom breakpoint.
1003    // The button_position should be directly over the breakpoint icon button for tooltip hover.
1004    // Based on debug output: button is at origin=(3.12, 66.5) with size=(14, 16)
1005    let gutter_position = point(px(30.0), px(85.0));
1006    let button_position = point(px(10.0), px(75.0)); // Center of the breakpoint button
1007
1008    // Step 1: Initial draw to register mouse listeners
1009    cx.update_window(workspace_window.into(), |_, window, cx| {
1010        window.draw(cx).clear();
1011    })?;
1012    cx.run_until_parked();
1013
1014    // Step 2: Simulate mouse move into gutter area
1015    cx.simulate_mouse_move(
1016        workspace_window.into(),
1017        gutter_position,
1018        None,
1019        Modifiers::default(),
1020    );
1021
1022    // Step 3: Advance clock past 200ms debounce
1023    cx.advance_clock(Duration::from_millis(300));
1024    cx.run_until_parked();
1025
1026    // Step 4: Draw again to pick up the indicator state change
1027    cx.update_window(workspace_window.into(), |_, window, cx| {
1028        window.draw(cx).clear();
1029    })?;
1030    cx.run_until_parked();
1031
1032    // Step 5: Another mouse move to keep hover state active
1033    cx.simulate_mouse_move(
1034        workspace_window.into(),
1035        gutter_position,
1036        None,
1037        Modifiers::default(),
1038    );
1039
1040    // Step 6: Final draw
1041    cx.update_window(workspace_window.into(), |_, window, cx| {
1042        window.draw(cx).clear();
1043    })?;
1044    cx.run_until_parked();
1045
1046    let test2_result = run_visual_test(
1047        "breakpoint_hover_circle",
1048        workspace_window.into(),
1049        cx,
1050        update_baseline,
1051    )?;
1052
1053    // Test 3: Breakpoint hover with tooltip visible
1054    // The tooltip delay is 500ms (TOOLTIP_SHOW_DELAY constant)
1055    // We need to position the mouse directly over the breakpoint button for the tooltip to show.
1056    // The button hitbox is approximately at (3.12, 66.5) with size (14, 16).
1057
1058    // Move mouse directly over the button to trigger tooltip hover
1059    cx.simulate_mouse_move(
1060        workspace_window.into(),
1061        button_position,
1062        None,
1063        Modifiers::default(),
1064    );
1065
1066    // Draw to register the button's tooltip hover listener
1067    cx.update_window(workspace_window.into(), |_, window, cx| {
1068        window.draw(cx).clear();
1069    })?;
1070    cx.run_until_parked();
1071
1072    // Move mouse over button again to trigger tooltip scheduling
1073    cx.simulate_mouse_move(
1074        workspace_window.into(),
1075        button_position,
1076        None,
1077        Modifiers::default(),
1078    );
1079
1080    // Advance clock past TOOLTIP_SHOW_DELAY (500ms)
1081    cx.advance_clock(TOOLTIP_SHOW_DELAY + Duration::from_millis(100));
1082    cx.run_until_parked();
1083
1084    // Draw to render the tooltip
1085    cx.update_window(workspace_window.into(), |_, window, cx| {
1086        window.draw(cx).clear();
1087    })?;
1088    cx.run_until_parked();
1089
1090    // Refresh window
1091    cx.update_window(workspace_window.into(), |_, window, _cx| {
1092        window.refresh();
1093    })?;
1094
1095    cx.run_until_parked();
1096
1097    let test3_result = run_visual_test(
1098        "breakpoint_hover_tooltip",
1099        workspace_window.into(),
1100        cx,
1101        update_baseline,
1102    )?;
1103
1104    // Clean up: remove worktrees to stop background scanning
1105    workspace_window
1106        .update(cx, |workspace, _window, cx| {
1107            let project = workspace.project().clone();
1108            project.update(cx, |project, cx| {
1109                let worktree_ids: Vec<_> =
1110                    project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
1111                for id in worktree_ids {
1112                    project.remove_worktree(id, cx);
1113                }
1114            });
1115        })
1116        .ok();
1117
1118    cx.run_until_parked();
1119
1120    // Close the window
1121    let _ = cx.update_window(workspace_window.into(), |_, window, _cx| {
1122        window.remove_window();
1123    });
1124
1125    cx.run_until_parked();
1126
1127    // Give background tasks time to finish
1128    for _ in 0..15 {
1129        cx.advance_clock(Duration::from_millis(100));
1130        cx.run_until_parked();
1131    }
1132
1133    // Return combined result
1134    match (&test1_result, &test2_result, &test3_result) {
1135        (TestResult::Passed, TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
1136        (TestResult::BaselineUpdated(p), _, _)
1137        | (_, TestResult::BaselineUpdated(p), _)
1138        | (_, _, TestResult::BaselineUpdated(p)) => Ok(TestResult::BaselineUpdated(p.clone())),
1139    }
1140}
1141
1142/// Runs visual tests for the diff review button in git diff views.
1143///
1144/// This test captures three states:
1145/// 1. Diff view with feature flag enabled (button visible)
1146/// 2. Diff view with feature flag disabled (no button)
1147/// 3. Regular editor with feature flag enabled (no button - only shows in diff views)
1148#[cfg(target_os = "macos")]
1149fn run_diff_review_visual_tests(
1150    app_state: Arc<AppState>,
1151    cx: &mut VisualTestAppContext,
1152    update_baseline: bool,
1153) -> Result<TestResult> {
1154    // Create a temporary directory with test files and a real git repo
1155    let temp_dir = tempfile::tempdir()?;
1156    let temp_path = temp_dir.keep();
1157    let canonical_temp = temp_path.canonicalize()?;
1158    let project_path = canonical_temp.join("project");
1159    std::fs::create_dir_all(&project_path)?;
1160
1161    // Initialize a real git repository
1162    std::process::Command::new("git")
1163        .args(["init"])
1164        .current_dir(&project_path)
1165        .output()?;
1166
1167    // Configure git user for commits
1168    std::process::Command::new("git")
1169        .args(["config", "user.email", "test@test.com"])
1170        .current_dir(&project_path)
1171        .output()?;
1172    std::process::Command::new("git")
1173        .args(["config", "user.name", "Test User"])
1174        .current_dir(&project_path)
1175        .output()?;
1176
1177    // Create a test file with original content
1178    let original_content = "// Original content\n";
1179    std::fs::write(project_path.join("thread-view.tsx"), original_content)?;
1180
1181    // Commit the original file
1182    std::process::Command::new("git")
1183        .args(["add", "thread-view.tsx"])
1184        .current_dir(&project_path)
1185        .output()?;
1186    std::process::Command::new("git")
1187        .args(["commit", "-m", "Initial commit"])
1188        .current_dir(&project_path)
1189        .output()?;
1190
1191    // Modify the file to create a diff
1192    let modified_content = r#"import { ScrollArea } from 'components';
1193import { ButtonAlt, Tooltip } from 'ui';
1194import { Message, FileEdit } from 'types';
1195import { AiPaneTabContext } from 'context';
1196"#;
1197    std::fs::write(project_path.join("thread-view.tsx"), modified_content)?;
1198
1199    // Create window for the diff view - sized to show just the editor
1200    let window_size = size(px(600.0), px(400.0));
1201    let bounds = Bounds {
1202        origin: point(px(0.0), px(0.0)),
1203        size: window_size,
1204    };
1205
1206    // Create project
1207    let project = cx.update(|cx| {
1208        project::Project::local(
1209            app_state.client.clone(),
1210            app_state.node_runtime.clone(),
1211            app_state.user_store.clone(),
1212            app_state.languages.clone(),
1213            app_state.fs.clone(),
1214            None,
1215            false,
1216            cx,
1217        )
1218    });
1219
1220    // Add the test directory as a worktree
1221    let add_worktree_task = project.update(cx, |project, cx| {
1222        project.find_or_create_worktree(&project_path, true, cx)
1223    });
1224
1225    cx.background_executor.allow_parking();
1226    let _ = cx.foreground_executor.block_test(add_worktree_task);
1227    cx.background_executor.forbid_parking();
1228
1229    cx.run_until_parked();
1230
1231    // Wait for worktree to be fully scanned and git status to be detected
1232    for _ in 0..5 {
1233        cx.advance_clock(Duration::from_millis(100));
1234        cx.run_until_parked();
1235    }
1236
1237    // Test 1: Diff view with feature flag enabled
1238    // Enable the feature flag
1239    cx.update(|cx| {
1240        cx.update_flags(true, vec!["diff-review".to_string()]);
1241    });
1242
1243    let workspace_window: WindowHandle<Workspace> = cx
1244        .update(|cx| {
1245            cx.open_window(
1246                WindowOptions {
1247                    window_bounds: Some(WindowBounds::Windowed(bounds)),
1248                    focus: false,
1249                    show: false,
1250                    ..Default::default()
1251                },
1252                |window, cx| {
1253                    cx.new(|cx| {
1254                        Workspace::new(None, project.clone(), app_state.clone(), window, cx)
1255                    })
1256                },
1257            )
1258        })
1259        .context("Failed to open diff review test window")?;
1260
1261    cx.run_until_parked();
1262
1263    // Create and add the ProjectDiff using the public deploy_at method
1264    workspace_window
1265        .update(cx, |workspace, window, cx| {
1266            ProjectDiff::deploy_at(workspace, None, window, cx);
1267        })
1268        .ok();
1269
1270    // Wait for diff to render
1271    for _ in 0..5 {
1272        cx.advance_clock(Duration::from_millis(100));
1273        cx.run_until_parked();
1274    }
1275
1276    // Refresh window
1277    cx.update_window(workspace_window.into(), |_, window, _cx| {
1278        window.refresh();
1279    })?;
1280
1281    cx.run_until_parked();
1282
1283    // Capture Test 1: Diff with flag enabled
1284    let test1_result = run_visual_test(
1285        "diff_review_button_enabled",
1286        workspace_window.into(),
1287        cx,
1288        update_baseline,
1289    )?;
1290
1291    // Test 2: Diff view with feature flag disabled
1292    // Disable the feature flag
1293    cx.update(|cx| {
1294        cx.update_flags(false, vec![]);
1295    });
1296
1297    // Refresh window
1298    cx.update_window(workspace_window.into(), |_, window, _cx| {
1299        window.refresh();
1300    })?;
1301
1302    for _ in 0..3 {
1303        cx.advance_clock(Duration::from_millis(100));
1304        cx.run_until_parked();
1305    }
1306
1307    // Capture Test 2: Diff with flag disabled
1308    let test2_result = run_visual_test(
1309        "diff_review_button_disabled",
1310        workspace_window.into(),
1311        cx,
1312        update_baseline,
1313    )?;
1314
1315    // Test 3: Regular editor with flag enabled (should NOT show button)
1316    // Re-enable the feature flag
1317    cx.update(|cx| {
1318        cx.update_flags(true, vec!["diff-review".to_string()]);
1319    });
1320
1321    // Create a new window with just a regular editor
1322    let regular_window: WindowHandle<Workspace> = cx
1323        .update(|cx| {
1324            cx.open_window(
1325                WindowOptions {
1326                    window_bounds: Some(WindowBounds::Windowed(bounds)),
1327                    focus: false,
1328                    show: false,
1329                    ..Default::default()
1330                },
1331                |window, cx| {
1332                    cx.new(|cx| {
1333                        Workspace::new(None, project.clone(), app_state.clone(), window, cx)
1334                    })
1335                },
1336            )
1337        })
1338        .context("Failed to open regular editor window")?;
1339
1340    cx.run_until_parked();
1341
1342    // Open a regular file (not a diff view)
1343    let open_file_task = regular_window
1344        .update(cx, |workspace, window, cx| {
1345            let worktree = workspace.project().read(cx).worktrees(cx).next();
1346            if let Some(worktree) = worktree {
1347                let worktree_id = worktree.read(cx).id();
1348                let rel_path: std::sync::Arc<util::rel_path::RelPath> =
1349                    util::rel_path::rel_path("thread-view.tsx").into();
1350                let project_path: project::ProjectPath = (worktree_id, rel_path).into();
1351                Some(workspace.open_path(project_path, None, true, window, cx))
1352            } else {
1353                None
1354            }
1355        })
1356        .ok()
1357        .flatten();
1358
1359    if let Some(task) = open_file_task {
1360        cx.background_executor.allow_parking();
1361        let _ = cx.foreground_executor.block_test(task);
1362        cx.background_executor.forbid_parking();
1363    }
1364
1365    // Wait for file to open
1366    for _ in 0..3 {
1367        cx.advance_clock(Duration::from_millis(100));
1368        cx.run_until_parked();
1369    }
1370
1371    // Refresh window
1372    cx.update_window(regular_window.into(), |_, window, _cx| {
1373        window.refresh();
1374    })?;
1375
1376    cx.run_until_parked();
1377
1378    // Capture Test 3: Regular editor with flag enabled (no button)
1379    let test3_result = run_visual_test(
1380        "diff_review_button_regular_editor",
1381        regular_window.into(),
1382        cx,
1383        update_baseline,
1384    )?;
1385
1386    // Test 4: Show the diff review overlay on the regular editor
1387    regular_window
1388        .update(cx, |workspace, window, cx| {
1389            // Get the first editor from the workspace
1390            let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1391            if let Some(editor) = editors.into_iter().next() {
1392                editor.update(cx, |editor, cx| {
1393                    editor.show_diff_review_overlay(DisplayRow(1), window, cx);
1394                });
1395            }
1396        })
1397        .ok();
1398
1399    // Wait for overlay to render
1400    for _ in 0..3 {
1401        cx.advance_clock(Duration::from_millis(100));
1402        cx.run_until_parked();
1403    }
1404
1405    // Refresh window
1406    cx.update_window(regular_window.into(), |_, window, _cx| {
1407        window.refresh();
1408    })?;
1409
1410    cx.run_until_parked();
1411
1412    // Capture Test 4: Regular editor with overlay shown
1413    let test4_result = run_visual_test(
1414        "diff_review_overlay_shown",
1415        regular_window.into(),
1416        cx,
1417        update_baseline,
1418    )?;
1419
1420    // Test 5: Type text into the diff review prompt and submit it
1421    // First, get the prompt editor from the overlay and type some text
1422    regular_window
1423        .update(cx, |workspace, window, cx| {
1424            let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1425            if let Some(editor) = editors.into_iter().next() {
1426                editor.update(cx, |editor, cx| {
1427                    // Get the prompt editor from the overlay and insert text
1428                    if let Some(prompt_editor) = editor.diff_review_prompt_editor().cloned() {
1429                        prompt_editor.update(cx, |prompt_editor: &mut editor::Editor, cx| {
1430                            prompt_editor.insert(
1431                                "This change needs better error handling",
1432                                window,
1433                                cx,
1434                            );
1435                        });
1436                    }
1437                });
1438            }
1439        })
1440        .ok();
1441
1442    // Wait for text to be inserted
1443    for _ in 0..3 {
1444        cx.advance_clock(Duration::from_millis(100));
1445        cx.run_until_parked();
1446    }
1447
1448    // Refresh window
1449    cx.update_window(regular_window.into(), |_, window, _cx| {
1450        window.refresh();
1451    })?;
1452
1453    cx.run_until_parked();
1454
1455    // Capture Test 5: Diff review overlay with typed text
1456    let test5_result = run_visual_test(
1457        "diff_review_overlay_with_text",
1458        regular_window.into(),
1459        cx,
1460        update_baseline,
1461    )?;
1462
1463    // Test 6: Submit a comment to store it locally
1464    regular_window
1465        .update(cx, |workspace, window, cx| {
1466            let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1467            if let Some(editor) = editors.into_iter().next() {
1468                editor.update(cx, |editor, cx| {
1469                    // Submit the comment that was typed in test 5
1470                    editor.submit_diff_review_comment(window, cx);
1471                });
1472            }
1473        })
1474        .ok();
1475
1476    // Wait for comment to be stored
1477    for _ in 0..3 {
1478        cx.advance_clock(Duration::from_millis(100));
1479        cx.run_until_parked();
1480    }
1481
1482    // Refresh window
1483    cx.update_window(regular_window.into(), |_, window, _cx| {
1484        window.refresh();
1485    })?;
1486
1487    cx.run_until_parked();
1488
1489    // Capture Test 6: Overlay with one stored comment
1490    let test6_result = run_visual_test(
1491        "diff_review_one_comment",
1492        regular_window.into(),
1493        cx,
1494        update_baseline,
1495    )?;
1496
1497    // Test 7: Add more comments to show multiple comments expanded
1498    regular_window
1499        .update(cx, |workspace, window, cx| {
1500            let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1501            if let Some(editor) = editors.into_iter().next() {
1502                editor.update(cx, |editor, cx| {
1503                    // Add second comment
1504                    if let Some(prompt_editor) = editor.diff_review_prompt_editor().cloned() {
1505                        prompt_editor.update(cx, |pe, cx| {
1506                            pe.insert("Second comment about imports", window, cx);
1507                        });
1508                    }
1509                    editor.submit_diff_review_comment(window, cx);
1510
1511                    // Add third comment
1512                    if let Some(prompt_editor) = editor.diff_review_prompt_editor().cloned() {
1513                        prompt_editor.update(cx, |pe, cx| {
1514                            pe.insert("Third comment about naming conventions", window, cx);
1515                        });
1516                    }
1517                    editor.submit_diff_review_comment(window, cx);
1518                });
1519            }
1520        })
1521        .ok();
1522
1523    // Wait for comments to be stored
1524    for _ in 0..3 {
1525        cx.advance_clock(Duration::from_millis(100));
1526        cx.run_until_parked();
1527    }
1528
1529    // Refresh window
1530    cx.update_window(regular_window.into(), |_, window, _cx| {
1531        window.refresh();
1532    })?;
1533
1534    cx.run_until_parked();
1535
1536    // Capture Test 7: Overlay with multiple comments expanded
1537    let test7_result = run_visual_test(
1538        "diff_review_multiple_comments_expanded",
1539        regular_window.into(),
1540        cx,
1541        update_baseline,
1542    )?;
1543
1544    // Test 8: Collapse the comments section
1545    regular_window
1546        .update(cx, |workspace, _window, cx| {
1547            let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1548            if let Some(editor) = editors.into_iter().next() {
1549                editor.update(cx, |editor, cx| {
1550                    // Toggle collapse using the public method
1551                    editor.set_diff_review_comments_expanded(false, cx);
1552                });
1553            }
1554        })
1555        .ok();
1556
1557    // Wait for UI to update
1558    for _ in 0..3 {
1559        cx.advance_clock(Duration::from_millis(100));
1560        cx.run_until_parked();
1561    }
1562
1563    // Refresh window
1564    cx.update_window(regular_window.into(), |_, window, _cx| {
1565        window.refresh();
1566    })?;
1567
1568    cx.run_until_parked();
1569
1570    // Capture Test 8: Comments collapsed
1571    let test8_result = run_visual_test(
1572        "diff_review_comments_collapsed",
1573        regular_window.into(),
1574        cx,
1575        update_baseline,
1576    )?;
1577
1578    // Clean up: remove worktrees to stop background scanning
1579    workspace_window
1580        .update(cx, |workspace, _window, cx| {
1581            let project = workspace.project().clone();
1582            project.update(cx, |project, cx| {
1583                let worktree_ids: Vec<_> =
1584                    project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
1585                for id in worktree_ids {
1586                    project.remove_worktree(id, cx);
1587                }
1588            });
1589        })
1590        .ok();
1591
1592    cx.run_until_parked();
1593
1594    // Close windows
1595    let _ = cx.update_window(workspace_window.into(), |_, window, _cx| {
1596        window.remove_window();
1597    });
1598    let _ = cx.update_window(regular_window.into(), |_, window, _cx| {
1599        window.remove_window();
1600    });
1601
1602    cx.run_until_parked();
1603
1604    // Give background tasks time to finish
1605    for _ in 0..15 {
1606        cx.advance_clock(Duration::from_millis(100));
1607        cx.run_until_parked();
1608    }
1609
1610    // Return combined result
1611    let all_results = [
1612        &test1_result,
1613        &test2_result,
1614        &test3_result,
1615        &test4_result,
1616        &test5_result,
1617        &test6_result,
1618        &test7_result,
1619        &test8_result,
1620    ];
1621
1622    // Combine results: if any test updated a baseline, return BaselineUpdated;
1623    // otherwise return Passed. The exhaustive match ensures the compiler
1624    // verifies we handle all TestResult variants.
1625    let result = all_results
1626        .iter()
1627        .fold(TestResult::Passed, |acc, r| match r {
1628            TestResult::Passed => acc,
1629            TestResult::BaselineUpdated(p) => TestResult::BaselineUpdated(p.clone()),
1630        });
1631    Ok(result)
1632}
1633
1634/// A stub AgentServer for visual testing that returns a pre-programmed connection.
1635#[derive(Clone)]
1636#[cfg(target_os = "macos")]
1637struct StubAgentServer {
1638    connection: StubAgentConnection,
1639}
1640
1641#[cfg(target_os = "macos")]
1642impl StubAgentServer {
1643    fn new(connection: StubAgentConnection) -> Self {
1644        Self { connection }
1645    }
1646}
1647
1648#[cfg(target_os = "macos")]
1649impl AgentServer for StubAgentServer {
1650    fn logo(&self) -> ui::IconName {
1651        ui::IconName::ZedAssistant
1652    }
1653
1654    fn name(&self) -> SharedString {
1655        "Visual Test Agent".into()
1656    }
1657
1658    fn connect(
1659        &self,
1660        _root_dir: Option<&Path>,
1661        _delegate: AgentServerDelegate,
1662        _cx: &mut App,
1663    ) -> gpui::Task<gpui::Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
1664        gpui::Task::ready(Ok((Rc::new(self.connection.clone()), None)))
1665    }
1666
1667    fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
1668        self
1669    }
1670}
1671
1672#[cfg(all(target_os = "macos", feature = "visual-tests"))]
1673fn run_subagent_visual_tests(
1674    app_state: Arc<AppState>,
1675    cx: &mut VisualTestAppContext,
1676    update_baseline: bool,
1677) -> Result<TestResult> {
1678    use acp_thread::{
1679        AcpThread, SUBAGENT_TOOL_NAME, ToolCallUpdateSubagentThread, meta_with_tool_name,
1680    };
1681    use agent_ui::AgentPanel;
1682
1683    // Create a temporary project directory
1684    let temp_dir = tempfile::tempdir()?;
1685    let temp_path = temp_dir.keep();
1686    let canonical_temp = temp_path.canonicalize()?;
1687    let project_path = canonical_temp.join("project");
1688    std::fs::create_dir_all(&project_path)?;
1689
1690    // Create a project
1691    let project = cx.update(|cx| {
1692        project::Project::local(
1693            app_state.client.clone(),
1694            app_state.node_runtime.clone(),
1695            app_state.user_store.clone(),
1696            app_state.languages.clone(),
1697            app_state.fs.clone(),
1698            None,
1699            false,
1700            cx,
1701        )
1702    });
1703
1704    // Add the test directory as a worktree
1705    let add_worktree_task = project.update(cx, |project, cx| {
1706        project.find_or_create_worktree(&project_path, true, cx)
1707    });
1708
1709    let _ = cx.foreground_executor.block_test(add_worktree_task);
1710
1711    cx.run_until_parked();
1712
1713    // Create stub connection - we'll manually inject the subagent content
1714    let connection = StubAgentConnection::new();
1715
1716    // Create a subagent tool call (in progress state)
1717    let tool_call = acp::ToolCall::new("subagent-tool-1", "2 subagents")
1718        .kind(acp::ToolKind::Other)
1719        .meta(meta_with_tool_name(SUBAGENT_TOOL_NAME))
1720        .status(acp::ToolCallStatus::InProgress);
1721
1722    connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
1723
1724    let stub_agent: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(connection.clone()));
1725
1726    // Create a window sized for the agent panel
1727    let window_size = size(px(600.0), px(700.0));
1728    let bounds = Bounds {
1729        origin: point(px(0.0), px(0.0)),
1730        size: window_size,
1731    };
1732
1733    let workspace_window: WindowHandle<Workspace> = cx
1734        .update(|cx| {
1735            cx.open_window(
1736                WindowOptions {
1737                    window_bounds: Some(WindowBounds::Windowed(bounds)),
1738                    focus: false,
1739                    show: false,
1740                    ..Default::default()
1741                },
1742                |window, cx| {
1743                    cx.new(|cx| {
1744                        Workspace::new(None, project.clone(), app_state.clone(), window, cx)
1745                    })
1746                },
1747            )
1748        })
1749        .context("Failed to open agent window")?;
1750
1751    cx.run_until_parked();
1752
1753    // Load the AgentPanel
1754    let (weak_workspace, async_window_cx) = workspace_window
1755        .update(cx, |workspace, window, cx| {
1756            (workspace.weak_handle(), window.to_async(cx))
1757        })
1758        .context("Failed to get workspace handle")?;
1759
1760    let prompt_builder =
1761        cx.update(|cx| prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx));
1762    let panel = cx
1763        .foreground_executor
1764        .block_test(AgentPanel::load(
1765            weak_workspace,
1766            prompt_builder,
1767            async_window_cx,
1768        ))
1769        .context("Failed to load AgentPanel")?;
1770
1771    cx.update_window(workspace_window.into(), |_, _window, cx| {
1772        workspace_window
1773            .update(cx, |workspace, window, cx| {
1774                workspace.add_panel(panel.clone(), window, cx);
1775                workspace.open_panel::<AgentPanel>(window, cx);
1776            })
1777            .ok();
1778    })?;
1779
1780    cx.run_until_parked();
1781
1782    // Open the stub thread
1783    cx.update_window(workspace_window.into(), |_, window, cx| {
1784        panel.update(cx, |panel: &mut agent_ui::AgentPanel, cx| {
1785            panel.open_external_thread_with_server(stub_agent.clone(), window, cx);
1786        });
1787    })?;
1788
1789    cx.run_until_parked();
1790
1791    // Get the thread view and send a message to trigger the subagent tool call
1792    let thread_view = cx
1793        .read(|cx| panel.read(cx).active_thread_view_for_tests().cloned())
1794        .ok_or_else(|| anyhow::anyhow!("No active thread view"))?;
1795
1796    let thread = cx
1797        .read(|cx| thread_view.read(cx).thread().cloned())
1798        .ok_or_else(|| anyhow::anyhow!("Thread not available"))?;
1799
1800    // Send the message to trigger the subagent response
1801    let send_future = thread.update(cx, |thread: &mut acp_thread::AcpThread, cx| {
1802        thread.send(vec!["Run two subagents".into()], cx)
1803    });
1804
1805    let _ = cx.foreground_executor.block_test(send_future);
1806
1807    cx.run_until_parked();
1808
1809    // Get the tool call ID
1810    let tool_call_id = cx
1811        .read(|cx| {
1812            thread.read(cx).entries().iter().find_map(|entry| {
1813                if let acp_thread::AgentThreadEntry::ToolCall(tool_call) = entry {
1814                    Some(tool_call.id.clone())
1815                } else {
1816                    None
1817                }
1818            })
1819        })
1820        .ok_or_else(|| anyhow::anyhow!("Expected a ToolCall entry in thread"))?;
1821
1822    // Create two subagent AcpThreads and inject them
1823    let subagent1 = cx.update(|cx| {
1824        let action_log = cx.new(|_| action_log::ActionLog::new(project.clone()));
1825        let session_id = acp::SessionId::new("subagent-1");
1826        cx.new(|cx| {
1827            let mut thread = AcpThread::new(
1828                "Exploring test-repo",
1829                Rc::new(connection.clone()),
1830                project.clone(),
1831                action_log,
1832                session_id,
1833                watch::Receiver::constant(acp::PromptCapabilities::new()),
1834                cx,
1835            );
1836            // Add some content to this subagent
1837            thread.push_assistant_content_block(
1838                "## Summary of test-repo\n\nThis is a test repository with:\n\n- **Files:** test.txt\n- **Purpose:** Testing".into(),
1839                false,
1840                cx,
1841            );
1842            thread
1843        })
1844    });
1845
1846    let subagent2 = cx.update(|cx| {
1847        let action_log = cx.new(|_| action_log::ActionLog::new(project.clone()));
1848        let session_id = acp::SessionId::new("subagent-2");
1849        cx.new(|cx| {
1850            let mut thread = AcpThread::new(
1851                "Exploring test-worktree",
1852                Rc::new(connection.clone()),
1853                project.clone(),
1854                action_log,
1855                session_id,
1856                watch::Receiver::constant(acp::PromptCapabilities::new()),
1857                cx,
1858            );
1859            // Add some content to this subagent
1860            thread.push_assistant_content_block(
1861                "## Summary of test-worktree\n\nThis directory contains:\n\n- A single `config.json` file\n- Basic project setup".into(),
1862                false,
1863                cx,
1864            );
1865            thread
1866        })
1867    });
1868
1869    // Inject subagent threads into the tool call
1870    thread.update(cx, |thread: &mut acp_thread::AcpThread, cx| {
1871        thread
1872            .update_tool_call(
1873                ToolCallUpdateSubagentThread {
1874                    id: tool_call_id.clone(),
1875                    thread: subagent1,
1876                },
1877                cx,
1878            )
1879            .ok();
1880        thread
1881            .update_tool_call(
1882                ToolCallUpdateSubagentThread {
1883                    id: tool_call_id.clone(),
1884                    thread: subagent2,
1885                },
1886                cx,
1887            )
1888            .ok();
1889    });
1890
1891    cx.run_until_parked();
1892
1893    cx.update_window(workspace_window.into(), |_, window, _cx| {
1894        window.refresh();
1895    })?;
1896
1897    cx.run_until_parked();
1898
1899    // Capture subagents in RUNNING state (tool call still in progress)
1900    let running_result = run_visual_test(
1901        "subagent_cards_running",
1902        workspace_window.into(),
1903        cx,
1904        update_baseline,
1905    )?;
1906
1907    // Now mark the tool call as completed by updating it through the thread
1908    thread.update(cx, |thread: &mut acp_thread::AcpThread, cx| {
1909        thread
1910            .handle_session_update(
1911                acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
1912                    tool_call_id.clone(),
1913                    acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed),
1914                )),
1915                cx,
1916            )
1917            .ok();
1918    });
1919
1920    cx.run_until_parked();
1921
1922    cx.update_window(workspace_window.into(), |_, window, _cx| {
1923        window.refresh();
1924    })?;
1925
1926    cx.run_until_parked();
1927
1928    // Capture subagents in COMPLETED state
1929    let completed_result = run_visual_test(
1930        "subagent_cards_completed",
1931        workspace_window.into(),
1932        cx,
1933        update_baseline,
1934    )?;
1935
1936    // Expand the first subagent
1937    thread_view.update(cx, |view: &mut agent_ui::acp::AcpThreadView, cx| {
1938        view.expand_subagent(acp::SessionId::new("subagent-1"), cx);
1939    });
1940
1941    cx.run_until_parked();
1942
1943    cx.update_window(workspace_window.into(), |_, window, _cx| {
1944        window.refresh();
1945    })?;
1946
1947    cx.run_until_parked();
1948
1949    // Capture subagent in EXPANDED state
1950    let expanded_result = run_visual_test(
1951        "subagent_cards_expanded",
1952        workspace_window.into(),
1953        cx,
1954        update_baseline,
1955    )?;
1956
1957    // Cleanup
1958    workspace_window
1959        .update(cx, |workspace, _window, cx| {
1960            let project = workspace.project().clone();
1961            project.update(cx, |project, cx| {
1962                let worktree_ids: Vec<_> =
1963                    project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
1964                for id in worktree_ids {
1965                    project.remove_worktree(id, cx);
1966                }
1967            });
1968        })
1969        .ok();
1970
1971    cx.run_until_parked();
1972
1973    let _ = cx.update_window(workspace_window.into(), |_, window, _cx| {
1974        window.remove_window();
1975    });
1976
1977    cx.run_until_parked();
1978
1979    for _ in 0..15 {
1980        cx.advance_clock(Duration::from_millis(100));
1981        cx.run_until_parked();
1982    }
1983
1984    match (&running_result, &completed_result, &expanded_result) {
1985        (TestResult::Passed, TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
1986        (TestResult::BaselineUpdated(p), _, _)
1987        | (_, TestResult::BaselineUpdated(p), _)
1988        | (_, _, TestResult::BaselineUpdated(p)) => Ok(TestResult::BaselineUpdated(p.clone())),
1989    }
1990}
1991
1992#[cfg(all(target_os = "macos", feature = "visual-tests"))]
1993fn run_agent_thread_view_test(
1994    app_state: Arc<AppState>,
1995    cx: &mut VisualTestAppContext,
1996    update_baseline: bool,
1997) -> Result<TestResult> {
1998    use agent::AgentTool;
1999    use agent_ui::AgentPanel;
2000
2001    // Create a temporary directory with the test image
2002    // Canonicalize to resolve symlinks (on macOS, /var -> /private/var)
2003    // Use keep() to prevent auto-cleanup - we'll clean up manually after stopping background tasks
2004    let temp_dir = tempfile::tempdir()?;
2005    let temp_path = temp_dir.keep();
2006    let canonical_temp = temp_path.canonicalize()?;
2007    let project_path = canonical_temp.join("project");
2008    std::fs::create_dir_all(&project_path)?;
2009    let image_path = project_path.join("test-image.png");
2010    std::fs::write(&image_path, EMBEDDED_TEST_IMAGE)?;
2011
2012    // Create a project with the test image
2013    let project = cx.update(|cx| {
2014        project::Project::local(
2015            app_state.client.clone(),
2016            app_state.node_runtime.clone(),
2017            app_state.user_store.clone(),
2018            app_state.languages.clone(),
2019            app_state.fs.clone(),
2020            None,
2021            false,
2022            cx,
2023        )
2024    });
2025
2026    // Add the test directory as a worktree
2027    let add_worktree_task = project.update(cx, |project, cx| {
2028        project.find_or_create_worktree(&project_path, true, cx)
2029    });
2030
2031    cx.background_executor.allow_parking();
2032    let (worktree, _) = cx
2033        .foreground_executor
2034        .block_test(add_worktree_task)
2035        .context("Failed to add worktree")?;
2036    cx.background_executor.forbid_parking();
2037
2038    cx.run_until_parked();
2039
2040    let worktree_name = cx.read(|cx| worktree.read(cx).root_name_str().to_string());
2041
2042    // Create the necessary entities for the ReadFileTool
2043    let action_log = cx.update(|cx| cx.new(|_| action_log::ActionLog::new(project.clone())));
2044    let context_server_registry = cx.update(|cx| {
2045        cx.new(|cx| agent::ContextServerRegistry::new(project.read(cx).context_server_store(), cx))
2046    });
2047    let fake_model = Arc::new(language_model::fake_provider::FakeLanguageModel::default());
2048    let project_context = cx.update(|cx| cx.new(|_| prompt_store::ProjectContext::default()));
2049
2050    // Create the agent Thread
2051    let thread = cx.update(|cx| {
2052        cx.new(|cx| {
2053            agent::Thread::new(
2054                project.clone(),
2055                project_context,
2056                context_server_registry,
2057                agent::Templates::new(),
2058                Some(fake_model),
2059                cx,
2060            )
2061        })
2062    });
2063
2064    // Create the ReadFileTool
2065    let tool = Arc::new(agent::ReadFileTool::new(
2066        thread.downgrade(),
2067        project.clone(),
2068        action_log,
2069    ));
2070
2071    // Create a test event stream to capture tool output
2072    let (event_stream, mut event_receiver) = agent::ToolCallEventStream::test();
2073
2074    // Run the real ReadFileTool to get the actual image content
2075    let input = agent::ReadFileToolInput {
2076        path: format!("{}/test-image.png", worktree_name),
2077        start_line: None,
2078        end_line: None,
2079    };
2080    let run_task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
2081
2082    cx.background_executor.allow_parking();
2083    let run_result = cx.foreground_executor.block_test(run_task);
2084    cx.background_executor.forbid_parking();
2085    run_result.context("ReadFileTool failed")?;
2086
2087    cx.run_until_parked();
2088
2089    // Collect the events from the tool execution
2090    let mut tool_content: Vec<acp::ToolCallContent> = Vec::new();
2091    let mut tool_locations: Vec<acp::ToolCallLocation> = Vec::new();
2092
2093    while let Ok(Some(event)) = event_receiver.try_next() {
2094        if let Ok(agent::ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(
2095            update,
2096        ))) = event
2097        {
2098            if let Some(content) = update.fields.content {
2099                tool_content.extend(content);
2100            }
2101            if let Some(locations) = update.fields.locations {
2102                tool_locations.extend(locations);
2103            }
2104        }
2105    }
2106
2107    if tool_content.is_empty() {
2108        return Err(anyhow::anyhow!("ReadFileTool did not produce any content"));
2109    }
2110
2111    // Create stub connection with the real tool output
2112    let connection = StubAgentConnection::new();
2113    connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
2114        acp::ToolCall::new(
2115            "read_file",
2116            format!("Read file `{}/test-image.png`", worktree_name),
2117        )
2118        .kind(acp::ToolKind::Read)
2119        .status(acp::ToolCallStatus::Completed)
2120        .locations(tool_locations)
2121        .content(tool_content),
2122    )]);
2123
2124    let stub_agent: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(connection));
2125
2126    // Create a window sized for the agent panel
2127    let window_size = size(px(500.0), px(900.0));
2128    let bounds = Bounds {
2129        origin: point(px(0.0), px(0.0)),
2130        size: window_size,
2131    };
2132
2133    let workspace_window: WindowHandle<Workspace> = cx
2134        .update(|cx| {
2135            cx.open_window(
2136                WindowOptions {
2137                    window_bounds: Some(WindowBounds::Windowed(bounds)),
2138                    focus: false,
2139                    show: false,
2140                    ..Default::default()
2141                },
2142                |window, cx| {
2143                    cx.new(|cx| {
2144                        Workspace::new(None, project.clone(), app_state.clone(), window, cx)
2145                    })
2146                },
2147            )
2148        })
2149        .context("Failed to open agent window")?;
2150
2151    cx.run_until_parked();
2152
2153    // Load the AgentPanel
2154    let (weak_workspace, async_window_cx) = workspace_window
2155        .update(cx, |workspace, window, cx| {
2156            (workspace.weak_handle(), window.to_async(cx))
2157        })
2158        .context("Failed to get workspace handle")?;
2159
2160    let prompt_builder =
2161        cx.update(|cx| prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx));
2162    cx.background_executor.allow_parking();
2163    let panel = cx
2164        .foreground_executor
2165        .block_test(AgentPanel::load(
2166            weak_workspace,
2167            prompt_builder,
2168            async_window_cx,
2169        ))
2170        .context("Failed to load AgentPanel")?;
2171    cx.background_executor.forbid_parking();
2172
2173    cx.update_window(workspace_window.into(), |_, _window, cx| {
2174        workspace_window
2175            .update(cx, |workspace, window, cx| {
2176                workspace.add_panel(panel.clone(), window, cx);
2177                workspace.open_panel::<AgentPanel>(window, cx);
2178            })
2179            .ok();
2180    })?;
2181
2182    cx.run_until_parked();
2183
2184    // Inject the stub server and open the stub thread
2185    cx.update_window(workspace_window.into(), |_, window, cx| {
2186        panel.update(cx, |panel, cx| {
2187            panel.open_external_thread_with_server(stub_agent.clone(), window, cx);
2188        });
2189    })?;
2190
2191    cx.run_until_parked();
2192
2193    // Get the thread view and send a message
2194    let thread_view = cx
2195        .read(|cx| panel.read(cx).active_thread_view_for_tests().cloned())
2196        .ok_or_else(|| anyhow::anyhow!("No active thread view"))?;
2197
2198    let thread = cx
2199        .read(|cx| thread_view.read(cx).thread().cloned())
2200        .ok_or_else(|| anyhow::anyhow!("Thread not available"))?;
2201
2202    // Send the message to trigger the image response
2203    let send_future = thread.update(cx, |thread, cx| {
2204        thread.send(vec!["Show me the Zed logo".into()], cx)
2205    });
2206
2207    cx.background_executor.allow_parking();
2208    let send_result = cx.foreground_executor.block_test(send_future);
2209    cx.background_executor.forbid_parking();
2210    send_result.context("Failed to send message")?;
2211
2212    cx.run_until_parked();
2213
2214    // Get the tool call ID for expanding later
2215    let tool_call_id = cx
2216        .read(|cx| {
2217            thread.read(cx).entries().iter().find_map(|entry| {
2218                if let acp_thread::AgentThreadEntry::ToolCall(tool_call) = entry {
2219                    Some(tool_call.id.clone())
2220                } else {
2221                    None
2222                }
2223            })
2224        })
2225        .ok_or_else(|| anyhow::anyhow!("Expected a ToolCall entry in thread"))?;
2226
2227    cx.update_window(workspace_window.into(), |_, window, _cx| {
2228        window.refresh();
2229    })?;
2230
2231    cx.run_until_parked();
2232
2233    // Capture the COLLAPSED state
2234    let collapsed_result = run_visual_test(
2235        "agent_thread_with_image_collapsed",
2236        workspace_window.into(),
2237        cx,
2238        update_baseline,
2239    )?;
2240
2241    // Now expand the tool call so the image is visible
2242    thread_view.update(cx, |view, cx| {
2243        view.expand_tool_call(tool_call_id, cx);
2244    });
2245
2246    cx.run_until_parked();
2247
2248    cx.update_window(workspace_window.into(), |_, window, _cx| {
2249        window.refresh();
2250    })?;
2251
2252    cx.run_until_parked();
2253
2254    // Capture the EXPANDED state
2255    let expanded_result = run_visual_test(
2256        "agent_thread_with_image_expanded",
2257        workspace_window.into(),
2258        cx,
2259        update_baseline,
2260    )?;
2261
2262    // Remove the worktree from the project to stop background scanning tasks
2263    // This prevents "root path could not be canonicalized" errors when we clean up
2264    workspace_window
2265        .update(cx, |workspace, _window, cx| {
2266            let project = workspace.project().clone();
2267            project.update(cx, |project, cx| {
2268                let worktree_ids: Vec<_> =
2269                    project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
2270                for id in worktree_ids {
2271                    project.remove_worktree(id, cx);
2272                }
2273            });
2274        })
2275        .ok();
2276
2277    cx.run_until_parked();
2278
2279    // Close the window
2280    // Note: This may cause benign "editor::scroll window not found" errors from scrollbar
2281    // auto-hide timers that were scheduled before the window was closed. These errors
2282    // don't affect test results.
2283    let _ = cx.update_window(workspace_window.into(), |_, window, _cx| {
2284        window.remove_window();
2285    });
2286
2287    // Run until all cleanup tasks complete
2288    cx.run_until_parked();
2289
2290    // Give background tasks time to finish, including scrollbar hide timers (1 second)
2291    for _ in 0..15 {
2292        cx.advance_clock(Duration::from_millis(100));
2293        cx.run_until_parked();
2294    }
2295
2296    // Note: We don't delete temp_path here because background worktree tasks may still
2297    // be running. The directory will be cleaned up when the process exits.
2298
2299    match (&collapsed_result, &expanded_result) {
2300        (TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
2301        (TestResult::BaselineUpdated(p), _) | (_, TestResult::BaselineUpdated(p)) => {
2302            Ok(TestResult::BaselineUpdated(p.clone()))
2303        }
2304    }
2305}