visual_test_runner.rs

  1//! Visual Test Runner
  2//!
  3//! This binary runs visual regression tests for Zed's UI. It captures screenshots
  4//! of real Zed windows and compares them against baseline images.
  5//!
  6//! ## How It Works
  7//!
  8//! This tool uses direct texture capture - it renders the scene to a Metal texture
  9//! and reads the pixels back directly. This approach:
 10//! - Does NOT require Screen Recording permission
 11//! - Does NOT require the window to be visible on screen
 12//! - Captures raw GPUI output without system window chrome
 13//!
 14//! ## Usage
 15//!
 16//! Run the visual tests:
 17//!   cargo run -p zed --bin zed_visual_test_runner --features visual-tests
 18//!
 19//! Update baseline images (when UI intentionally changes):
 20//!   UPDATE_BASELINE=1 cargo run -p zed --bin zed_visual_test_runner --features visual-tests
 21//!
 22//! ## Environment Variables
 23//!
 24//!   UPDATE_BASELINE - Set to update baseline images instead of comparing
 25//!   VISUAL_TEST_OUTPUT_DIR - Directory to save test output (default: target/visual_tests)
 26
 27use anyhow::{Context, Result};
 28use gpui::{
 29    App, AppContext as _, Application, Bounds, Pixels, Size, Window, WindowBounds, WindowHandle,
 30    WindowOptions, point, px,
 31};
 32use image::RgbaImage;
 33use project_panel::ProjectPanel;
 34
 35use std::any::Any;
 36use std::path::{Path, PathBuf};
 37use std::rc::Rc;
 38use std::sync::Arc;
 39use workspace::{AppState, Workspace};
 40
 41use acp_thread::{AgentConnection, StubAgentConnection};
 42use agent_client_protocol as acp;
 43use agent_servers::{AgentServer, AgentServerDelegate};
 44use gpui::SharedString;
 45
 46/// Baseline images are stored relative to this file
 47const BASELINE_DIR: &str = "crates/zed/test_fixtures/visual_tests";
 48
 49/// Embedded test image (Zed app icon) for visual tests.
 50const EMBEDDED_TEST_IMAGE: &[u8] = include_bytes!("../resources/app-icon.png");
 51
 52/// Threshold for image comparison (0.0 to 1.0)
 53/// Images must match at least this percentage to pass
 54const MATCH_THRESHOLD: f64 = 0.99;
 55
 56/// Window size for workspace tests (project panel, editor)
 57fn workspace_window_size() -> Size<Pixels> {
 58    Size {
 59        width: px(1280.0),
 60        height: px(800.0),
 61    }
 62}
 63
 64/// Window size for agent panel tests
 65fn agent_panel_window_size() -> Size<Pixels> {
 66    Size {
 67        width: px(500.0),
 68        height: px(900.0),
 69    }
 70}
 71
 72/// Helper struct for setting up test workspaces
 73struct TestWorkspace {
 74    window: WindowHandle<Workspace>,
 75}
 76
 77impl TestWorkspace {
 78    async fn new(
 79        app_state: Arc<AppState>,
 80        window_size: Size<Pixels>,
 81        project_path: &Path,
 82        cx: &mut gpui::AsyncApp,
 83    ) -> Result<Self> {
 84        let project = cx.update(|cx| {
 85            project::Project::local(
 86                app_state.client.clone(),
 87                app_state.node_runtime.clone(),
 88                app_state.user_store.clone(),
 89                app_state.languages.clone(),
 90                app_state.fs.clone(),
 91                None,
 92                false,
 93                cx,
 94            )
 95        });
 96
 97        project
 98            .update(cx, |project, cx| {
 99                project.find_or_create_worktree(project_path, true, cx)
100            })
101            .await?;
102
103        let bounds = Bounds {
104            origin: point(px(0.0), px(0.0)),
105            size: window_size,
106        };
107
108        let window: WindowHandle<Workspace> = cx.update(|cx| {
109            cx.open_window(
110                WindowOptions {
111                    window_bounds: Some(WindowBounds::Windowed(bounds)),
112                    focus: false,
113                    show: false,
114                    ..Default::default()
115                },
116                |window, cx| {
117                    cx.new(|cx| {
118                        Workspace::new(None, project.clone(), app_state.clone(), window, cx)
119                    })
120                },
121            )
122        })?;
123
124        cx.background_executor()
125            .timer(std::time::Duration::from_millis(100))
126            .await;
127
128        Ok(Self { window })
129    }
130}
131
132async fn setup_project_panel(
133    workspace: &TestWorkspace,
134    cx: &mut gpui::AsyncApp,
135) -> Result<gpui::Entity<ProjectPanel>> {
136    let panel_task = workspace.window.update(cx, |_workspace, window, cx| {
137        let weak_workspace = cx.weak_entity();
138        let async_window_cx = window.to_async(cx);
139        window.spawn(cx, async move |_cx| {
140            ProjectPanel::load(weak_workspace, async_window_cx).await
141        })
142    })?;
143
144    let panel = panel_task.await?;
145
146    workspace.window.update(cx, |ws, window, cx| {
147        ws.add_panel(panel.clone(), window, cx);
148        ws.open_panel::<ProjectPanel>(window, cx);
149    })?;
150
151    cx.background_executor()
152        .timer(std::time::Duration::from_millis(100))
153        .await;
154
155    Ok(panel)
156}
157
158async fn open_file(
159    workspace: &TestWorkspace,
160    relative_path: &str,
161    cx: &mut gpui::AsyncApp,
162) -> Result<()> {
163    let open_file_task = workspace.window.update(cx, |ws, window, cx| {
164        let worktree = ws.project().read(cx).worktrees(cx).next();
165        if let Some(worktree) = worktree {
166            let worktree_id = worktree.read(cx).id();
167            let rel_path: std::sync::Arc<util::rel_path::RelPath> =
168                util::rel_path::rel_path(relative_path).into();
169            let project_path: project::ProjectPath = (worktree_id, rel_path).into();
170            Some(ws.open_path(project_path, None, true, window, cx))
171        } else {
172            None
173        }
174    })?;
175
176    if let Some(task) = open_file_task {
177        let item = task.await?;
178        workspace.window.update(cx, |ws, window, cx| {
179            let pane = ws.active_pane().clone();
180            pane.update(cx, |pane, cx| {
181                if let Some(index) = pane.index_for_item(item.as_ref()) {
182                    pane.activate_item(index, true, true, window, cx);
183                }
184            });
185        })?;
186    }
187
188    cx.background_executor()
189        .timer(std::time::Duration::from_millis(100))
190        .await;
191
192    Ok(())
193}
194
195fn main() {
196    env_logger::builder()
197        .filter_level(log::LevelFilter::Info)
198        .init();
199
200    let update_baseline = std::env::var("UPDATE_BASELINE").is_ok();
201
202    // Create a temporary directory for test files
203    let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
204    let project_path = temp_dir.path().join("project");
205    std::fs::create_dir_all(&project_path).expect("Failed to create project directory");
206
207    // Create test files in the real filesystem
208    create_test_files(&project_path);
209
210    let test_result = std::panic::catch_unwind(|| {
211        let project_path = project_path;
212        Application::new()
213            .with_assets(assets::Assets)
214            .run(move |cx| {
215                // Load embedded fonts (Zed Sans and Zed Mono)
216                assets::Assets.load_fonts(cx).unwrap();
217
218                // Initialize settings store with real default settings (not test settings)
219                // Test settings use Courier font, but we want the real Zed fonts for visual tests
220                settings::init(cx);
221
222                // Create AppState using the production-like initialization
223                let app_state = init_app_state(cx);
224
225                // Initialize all Zed subsystems
226                gpui_tokio::init(cx);
227                theme::init(theme::LoadThemes::JustBase, cx);
228                client::init(&app_state.client, cx);
229                audio::init(cx);
230                workspace::init(app_state.clone(), cx);
231                release_channel::init(semver::Version::new(0, 0, 0), cx);
232                command_palette::init(cx);
233                editor::init(cx);
234                call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
235                title_bar::init(cx);
236                project_panel::init(cx);
237                outline_panel::init(cx);
238                terminal_view::init(cx);
239                image_viewer::init(cx);
240                search::init(cx);
241                prompt_store::init(cx);
242                language_model::init(app_state.client.clone(), cx);
243                language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
244
245                // Clone app_state for the async block
246                let app_state_for_tests = app_state.clone();
247
248                // Spawn async task to set up the UI and capture screenshot
249                cx.spawn(async move |mut cx| {
250                    let project_path_clone = project_path.clone();
251
252                    // Create the test workspace
253                    let workspace = match TestWorkspace::new(
254                        app_state_for_tests.clone(),
255                        workspace_window_size(),
256                        &project_path_clone,
257                        &mut cx,
258                    )
259                    .await
260                    {
261                        Ok(ws) => ws,
262                        Err(e) => {
263                            log::error!("Failed to create workspace: {}", e);
264                            cx.update(|cx| cx.quit());
265                            std::process::exit(1);
266                        }
267                    };
268
269                    // Set up project panel
270                    if let Err(e) = setup_project_panel(&workspace, &mut cx).await {
271                        log::error!("Failed to setup project panel: {}", e);
272                        cx.update(|cx| cx.quit());
273                        std::process::exit(1);
274                    }
275
276                    // Open main.rs in the editor
277                    if let Err(e) = open_file(&workspace, "src/main.rs", &mut cx).await {
278                        log::error!("Failed to open file: {}", e);
279                        cx.update(|cx| cx.quit());
280                        std::process::exit(1);
281                    }
282
283                    // Request a window refresh to ensure all pending effects are processed
284                    cx.refresh();
285                    cx.background_executor()
286                        .timer(std::time::Duration::from_millis(500))
287                        .await;
288
289                    // Track if any test failed
290                    let mut any_failed = false;
291
292                    // Run Test 1: Project Panel (with project panel visible)
293                    if run_visual_test(
294                        "project_panel",
295                        workspace.window.into(),
296                        &mut cx,
297                        update_baseline,
298                    )
299                    .await
300                    .is_err()
301                    {
302                        any_failed = true;
303                    }
304
305                    // Close the project panel for the second test
306                    cx.update(|cx| {
307                        workspace
308                            .window
309                            .update(cx, |ws, window, cx| {
310                                ws.close_panel::<ProjectPanel>(window, cx);
311                            })
312                            .ok();
313                    });
314
315                    // Refresh and wait for panel to close
316                    cx.refresh();
317                    cx.background_executor()
318                        .timer(std::time::Duration::from_millis(100))
319                        .await;
320
321                    // Run Test 2: Workspace with Editor (without project panel)
322                    if run_visual_test(
323                        "workspace_with_editor",
324                        workspace.window.into(),
325                        &mut cx,
326                        update_baseline,
327                    )
328                    .await
329                    .is_err()
330                    {
331                        any_failed = true;
332                    }
333
334                    // Run Test 3: Agent Thread View with Image (collapsed and expanded)
335                    if run_agent_thread_view_test(
336                        app_state_for_tests.clone(),
337                        &mut cx,
338                        update_baseline,
339                    )
340                    .await
341                    .is_err()
342                    {
343                        any_failed = true;
344                    }
345
346                    if any_failed {
347                        cx.update(|cx| cx.quit());
348                        std::process::exit(1);
349                    }
350
351                    cx.update(|cx| cx.quit());
352                })
353                .detach();
354            });
355    });
356
357    // Keep temp_dir alive until we're done
358    drop(temp_dir);
359
360    if test_result.is_err() {
361        std::process::exit(1);
362    }
363}
364
365enum TestResult {
366    Passed,
367    BaselineUpdated(PathBuf),
368}
369
370async fn run_visual_test(
371    test_name: &str,
372    window: gpui::AnyWindowHandle,
373    cx: &mut gpui::AsyncApp,
374    update_baseline: bool,
375) -> Result<TestResult> {
376    // Capture the screenshot using direct texture capture (no ScreenCaptureKit needed)
377    let screenshot = cx.update(|cx| capture_screenshot(window, cx))?;
378
379    // Get paths
380    let baseline_path = get_baseline_path(test_name);
381    let output_dir = std::env::var("VISUAL_TEST_OUTPUT_DIR")
382        .unwrap_or_else(|_| "target/visual_tests".to_string());
383    let actual_path = Path::new(&output_dir).join(format!("{}.png", test_name));
384
385    // Create output directory
386    if let Some(parent) = actual_path.parent() {
387        std::fs::create_dir_all(parent)?;
388    }
389
390    // Save the actual screenshot
391    screenshot.save(&actual_path)?;
392
393    if update_baseline {
394        // Update the baseline
395        if let Some(parent) = baseline_path.parent() {
396            std::fs::create_dir_all(parent)?;
397        }
398        screenshot.save(&baseline_path)?;
399        return Ok(TestResult::BaselineUpdated(baseline_path));
400    }
401
402    // Compare against baseline
403    if !baseline_path.exists() {
404        return Err(anyhow::anyhow!(
405            "Baseline image not found: {}\n\
406             Run with UPDATE_BASELINE=1 to create it.",
407            baseline_path.display()
408        ));
409    }
410
411    let baseline = image::open(&baseline_path)
412        .context("Failed to load baseline image")?
413        .to_rgba8();
414
415    let comparison = compare_images(&baseline, &screenshot);
416
417    if comparison.match_percentage >= MATCH_THRESHOLD {
418        Ok(TestResult::Passed)
419    } else {
420        // Save the diff image for debugging
421        if let Some(diff_image) = comparison.diff_image {
422            let diff_path = Path::new(&output_dir).join(format!("{}_diff.png", test_name));
423            diff_image.save(&diff_path)?;
424        }
425
426        Err(anyhow::anyhow!(
427            "Screenshot does not match baseline.\n\
428             Match: {:.2}% (threshold: {:.2}%)\n\
429             Actual: {}\n\
430             Baseline: {}\n\
431             \n\
432             Run with UPDATE_BASELINE=1 to update the baseline if this change is intentional.",
433            comparison.match_percentage * 100.0,
434            MATCH_THRESHOLD * 100.0,
435            actual_path.display(),
436            baseline_path.display()
437        ))
438    }
439}
440
441fn get_baseline_path(test_name: &str) -> PathBuf {
442    // Find the workspace root by looking for Cargo.toml
443    let mut path = std::env::current_dir().expect("Failed to get current directory");
444    while !path.join("Cargo.toml").exists() || !path.join("crates").exists() {
445        if !path.pop() {
446            panic!("Could not find workspace root");
447        }
448    }
449    path.join(BASELINE_DIR).join(format!("{}.png", test_name))
450}
451
452struct ImageComparison {
453    match_percentage: f64,
454    diff_image: Option<RgbaImage>,
455}
456
457fn compare_images(baseline: &RgbaImage, actual: &RgbaImage) -> ImageComparison {
458    // Check dimensions
459    if baseline.dimensions() != actual.dimensions() {
460        return ImageComparison {
461            match_percentage: 0.0,
462            diff_image: None,
463        };
464    }
465
466    let (width, height) = baseline.dimensions();
467    let total_pixels = width as u64 * height as u64;
468    let mut diff_count: u64 = 0;
469    let mut diff_image = RgbaImage::new(width, height);
470
471    for y in 0..height {
472        for x in 0..width {
473            let baseline_pixel = baseline.get_pixel(x, y);
474            let actual_pixel = actual.get_pixel(x, y);
475
476            if pixels_match(baseline_pixel, actual_pixel) {
477                // Matching pixel - show as dimmed version of actual
478                diff_image.put_pixel(
479                    x,
480                    y,
481                    image::Rgba([
482                        actual_pixel[0] / 3,
483                        actual_pixel[1] / 3,
484                        actual_pixel[2] / 3,
485                        255,
486                    ]),
487                );
488            } else {
489                diff_count += 1;
490                // Different pixel - highlight in red
491                diff_image.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
492            }
493        }
494    }
495
496    let match_percentage = if total_pixels > 0 {
497        (total_pixels - diff_count) as f64 / total_pixels as f64
498    } else {
499        1.0
500    };
501
502    ImageComparison {
503        match_percentage,
504        diff_image: Some(diff_image),
505    }
506}
507
508fn pixels_match(a: &image::Rgba<u8>, b: &image::Rgba<u8>) -> bool {
509    a == b
510}
511
512fn capture_screenshot(window: gpui::AnyWindowHandle, cx: &mut gpui::App) -> Result<RgbaImage> {
513    // Use direct texture capture - renders the scene to a texture and reads pixels back.
514    // This does not require the window to be visible on screen.
515    let screenshot = cx.update_window(window, |_view, window: &mut Window, _cx| {
516        window.render_to_image()
517    })??;
518
519    Ok(screenshot)
520}
521
522/// Create test files in a real filesystem directory
523fn create_test_files(project_path: &Path) {
524    let src_dir = project_path.join("src");
525    std::fs::create_dir_all(&src_dir).expect("Failed to create src directory");
526
527    std::fs::write(src_dir.join("main.rs"), MAIN_RS_CONTENT).expect("Failed to write main.rs");
528
529    std::fs::write(src_dir.join("lib.rs"), LIB_RS_CONTENT).expect("Failed to write lib.rs");
530
531    std::fs::write(src_dir.join("utils.rs"), UTILS_RS_CONTENT).expect("Failed to write utils.rs");
532
533    std::fs::write(project_path.join("Cargo.toml"), CARGO_TOML_CONTENT)
534        .expect("Failed to write Cargo.toml");
535
536    std::fs::write(project_path.join("README.md"), README_MD_CONTENT)
537        .expect("Failed to write README.md");
538}
539
540const MAIN_RS_CONTENT: &str = r#"fn main() {
541    println!("Hello, world!");
542
543    let message = greet("Zed");
544    println!("{}", message);
545}
546
547fn greet(name: &str) -> String {
548    format!("Welcome to {}, the editor of the future!", name)
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn test_greet() {
557        assert_eq!(greet("World"), "Welcome to World, the editor of the future!");
558    }
559}
560"#;
561
562const LIB_RS_CONTENT: &str = r#"//! A sample library for visual testing.
563
564pub mod utils;
565
566/// Adds two numbers together.
567pub fn add(a: i32, b: i32) -> i32 {
568    a + b
569}
570
571/// Subtracts the second number from the first.
572pub fn subtract(a: i32, b: i32) -> i32 {
573    a - b
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn test_add() {
582        assert_eq!(add(2, 3), 5);
583    }
584
585    #[test]
586    fn test_subtract() {
587        assert_eq!(subtract(5, 3), 2);
588    }
589}
590"#;
591
592const UTILS_RS_CONTENT: &str = r#"//! Utility functions for the sample project.
593
594/// Formats a greeting message.
595pub fn format_greeting(name: &str) -> String {
596    format!("Hello, {}!", name)
597}
598
599/// Formats a farewell message.
600pub fn format_farewell(name: &str) -> String {
601    format!("Goodbye, {}!", name)
602}
603"#;
604
605const CARGO_TOML_CONTENT: &str = r#"[package]
606name = "test-project"
607version = "0.1.0"
608edition = "2021"
609
610[dependencies]
611
612[dev-dependencies]
613"#;
614
615const README_MD_CONTENT: &str = r#"# Test Project
616
617This is a test project for visual testing of Zed.
618
619## Description
620
621A simple Rust project used to verify that Zed's visual testing
622infrastructure can capture screenshots of real workspaces.
623
624## Features
625
626- Sample Rust code with main.rs, lib.rs, and utils.rs
627- Standard Cargo.toml configuration
628- Example tests
629
630## Building
631
632```bash
633cargo build
634```
635
636## Testing
637
638```bash
639cargo test
640```
641"#;
642
643/// Initialize AppState with real filesystem for visual testing.
644fn init_app_state(cx: &mut gpui::App) -> Arc<AppState> {
645    use client::Client;
646    use clock::FakeSystemClock;
647    use fs::RealFs;
648    use language::LanguageRegistry;
649    use node_runtime::NodeRuntime;
650    use session::Session;
651
652    let fs = Arc::new(RealFs::new(None, cx.background_executor().clone()));
653    let languages = Arc::new(LanguageRegistry::new(cx.background_executor().clone()));
654    let clock = Arc::new(FakeSystemClock::new());
655    let http_client = http_client::FakeHttpClient::with_404_response();
656    let client = Client::new(clock, http_client, cx);
657    let session = cx.new(|cx| session::AppSession::new(Session::test(), cx));
658    let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx));
659    let workspace_store = cx.new(|cx| workspace::WorkspaceStore::new(client.clone(), cx));
660
661    Arc::new(AppState {
662        client,
663        fs,
664        languages,
665        user_store,
666        workspace_store,
667        node_runtime: NodeRuntime::unavailable(),
668        build_window_options: |_, _| Default::default(),
669        session,
670    })
671}
672
673/// A stub AgentServer for visual testing that returns a pre-programmed connection.
674#[derive(Clone)]
675struct StubAgentServer {
676    connection: StubAgentConnection,
677}
678
679impl StubAgentServer {
680    fn new(connection: StubAgentConnection) -> Self {
681        Self { connection }
682    }
683}
684
685impl AgentServer for StubAgentServer {
686    fn logo(&self) -> ui::IconName {
687        ui::IconName::ZedAssistant
688    }
689
690    fn name(&self) -> SharedString {
691        "Visual Test Agent".into()
692    }
693
694    fn connect(
695        &self,
696        _root_dir: Option<&Path>,
697        _delegate: AgentServerDelegate,
698        _cx: &mut App,
699    ) -> gpui::Task<gpui::Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
700        gpui::Task::ready(Ok((Rc::new(self.connection.clone()), None)))
701    }
702
703    fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
704        self
705    }
706}
707
708/// Runs the agent panel visual test with full UI chrome.
709/// This test actually runs the real ReadFileTool to capture image output.
710async fn run_agent_thread_view_test(
711    app_state: Arc<AppState>,
712    cx: &mut gpui::AsyncApp,
713    update_baseline: bool,
714) -> Result<TestResult> {
715    use agent::AgentTool;
716    use agent_ui::AgentPanel;
717
718    // Create a temporary directory with the test image using real filesystem
719    let temp_dir = tempfile::tempdir()?;
720    let project_path = temp_dir.path().join("project");
721    std::fs::create_dir_all(&project_path)?;
722    let image_path = project_path.join("test-image.png");
723    std::fs::write(&image_path, EMBEDDED_TEST_IMAGE)?;
724
725    // Create a project with the real filesystem containing the test image
726    let project = cx.update(|cx| {
727        project::Project::local(
728            app_state.client.clone(),
729            app_state.node_runtime.clone(),
730            app_state.user_store.clone(),
731            app_state.languages.clone(),
732            app_state.fs.clone(),
733            None,
734            false,
735            cx,
736        )
737    });
738
739    // Add the test directory as a worktree
740    let (worktree, _) = project
741        .update(cx, |project, cx| {
742            project.find_or_create_worktree(&project_path, true, cx)
743        })
744        .await?;
745
746    // Wait for worktree to scan and find the image file
747    let worktree_name = worktree.read_with(cx, |wt, _| wt.root_name_str().to_string());
748
749    cx.background_executor()
750        .timer(std::time::Duration::from_millis(100))
751        .await;
752
753    // Create the necessary entities for the ReadFileTool
754    let action_log = cx.new(|_| action_log::ActionLog::new(project.clone()));
755    let context_server_registry =
756        cx.new(|cx| agent::ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
757    let fake_model = Arc::new(language_model::fake_provider::FakeLanguageModel::default());
758    let project_context = cx.new(|_| prompt_store::ProjectContext::default());
759
760    // Create the agent Thread
761    let thread = cx.new(|cx| {
762        agent::Thread::new(
763            project.clone(),
764            project_context,
765            context_server_registry,
766            agent::Templates::new(),
767            Some(fake_model),
768            cx,
769        )
770    });
771
772    // Create the ReadFileTool
773    let tool = Arc::new(agent::ReadFileTool::new(
774        thread.downgrade(),
775        project.clone(),
776        action_log,
777    ));
778
779    // Create a test event stream to capture tool output
780    let (event_stream, mut event_receiver) = agent::ToolCallEventStream::test();
781
782    // Run the real ReadFileTool to get the actual image content
783    // The path is relative to the worktree root name
784    let input = agent::ReadFileToolInput {
785        path: format!("{}/test-image.png", worktree_name),
786        start_line: None,
787        end_line: None,
788    };
789    // The tool runs async - wait for it
790    cx.update(|cx| tool.clone().run(input, event_stream, cx))
791        .await?;
792
793    // Collect the events from the tool execution
794    let mut tool_content: Vec<acp::ToolCallContent> = Vec::new();
795    let mut tool_locations: Vec<acp::ToolCallLocation> = Vec::new();
796
797    while let Ok(Some(event)) = event_receiver.try_next() {
798        if let Ok(agent::ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(
799            update,
800        ))) = event
801        {
802            if let Some(content) = update.fields.content {
803                tool_content.extend(content);
804            }
805            if let Some(locations) = update.fields.locations {
806                tool_locations.extend(locations);
807            }
808        }
809    }
810
811    // Verify we got image content from the real tool
812    if tool_content.is_empty() {
813        return Err(anyhow::anyhow!(
814            "ReadFileTool did not produce any content - the tool is broken!"
815        ));
816    }
817
818    // Create stub connection with the REAL tool output
819    let connection = StubAgentConnection::new();
820    connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
821        acp::ToolCall::new(
822            "read_file",
823            format!("Read file `{}/test-image.png`", worktree_name),
824        )
825        .kind(acp::ToolKind::Read)
826        .status(acp::ToolCallStatus::Completed)
827        .locations(tool_locations)
828        .content(tool_content),
829    )]);
830
831    let stub_agent: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(connection.clone()));
832
833    // Create a workspace window
834    let bounds = Bounds {
835        origin: point(px(0.0), px(0.0)),
836        size: agent_panel_window_size(),
837    };
838
839    let workspace_window: WindowHandle<Workspace> = cx.update(|cx| {
840        cx.open_window(
841            WindowOptions {
842                window_bounds: Some(WindowBounds::Windowed(bounds)),
843                focus: false,
844                show: false,
845                ..Default::default()
846            },
847            |window, cx| {
848                cx.new(|cx| Workspace::new(None, project.clone(), app_state.clone(), window, cx))
849            },
850        )
851    })?;
852
853    cx.background_executor()
854        .timer(std::time::Duration::from_millis(100))
855        .await;
856
857    // Load the AgentPanel
858    let panel_task = workspace_window.update(cx, |_workspace, window, cx| {
859        let weak_workspace = cx.weak_entity();
860        let prompt_builder = prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx);
861        let async_window_cx = window.to_async(cx);
862        AgentPanel::load(weak_workspace, prompt_builder, async_window_cx)
863    })?;
864
865    let panel = panel_task.await?;
866
867    // Add the panel to the workspace
868    workspace_window.update(cx, |workspace, window, cx| {
869        workspace.add_panel(panel.clone(), window, cx);
870        workspace.open_panel::<AgentPanel>(window, cx);
871    })?;
872
873    cx.background_executor()
874        .timer(std::time::Duration::from_millis(100))
875        .await;
876
877    // Inject the stub server and open the stub thread
878    workspace_window.update(cx, |_workspace, window, cx| {
879        panel.update(cx, |panel: &mut AgentPanel, cx| {
880            panel.open_external_thread_with_server(stub_agent.clone(), window, cx);
881        });
882    })?;
883
884    cx.background_executor()
885        .timer(std::time::Duration::from_millis(100))
886        .await;
887
888    // Get the thread view and send a message
889    let thread_view = panel
890        .read_with(cx, |panel, _| panel.active_thread_view_for_tests().cloned())
891        .ok_or_else(|| anyhow::anyhow!("No active thread view"))?;
892
893    let thread = thread_view
894        .update(cx, |view: &mut agent_ui::acp::AcpThreadView, _cx| {
895            view.thread().cloned()
896        })
897        .ok_or_else(|| anyhow::anyhow!("Thread not available"))?;
898
899    // Send the message to trigger the image response
900    thread
901        .update(cx, |thread: &mut acp_thread::AcpThread, cx| {
902            thread.send_raw("Show me the Zed logo", cx)
903        })
904        .await?;
905
906    cx.background_executor()
907        .timer(std::time::Duration::from_millis(200))
908        .await;
909
910    // Get the tool call ID for expanding later
911    let tool_call_id = thread
912        .update(cx, |thread: &mut acp_thread::AcpThread, _cx| {
913            thread.entries().iter().find_map(|entry| {
914                if let acp_thread::AgentThreadEntry::ToolCall(tool_call) = entry {
915                    Some(tool_call.id.clone())
916                } else {
917                    None
918                }
919            })
920        })
921        .ok_or_else(|| anyhow::anyhow!("Expected a ToolCall entry in thread for visual test"))?;
922
923    // Refresh window for collapsed state
924    cx.update_window(
925        workspace_window.into(),
926        |_view, window: &mut Window, _cx| {
927            window.refresh();
928        },
929    )?;
930
931    cx.background_executor()
932        .timer(std::time::Duration::from_millis(100))
933        .await;
934
935    // First, capture the COLLAPSED state (image tool call not expanded)
936    let collapsed_result = run_visual_test(
937        "agent_thread_with_image_collapsed",
938        workspace_window.into(),
939        cx,
940        update_baseline,
941    )
942    .await?;
943
944    // Now expand the tool call so its content (the image) is visible
945    thread_view.update(cx, |view: &mut agent_ui::acp::AcpThreadView, cx| {
946        view.expand_tool_call(tool_call_id, cx);
947    });
948
949    cx.background_executor()
950        .timer(std::time::Duration::from_millis(100))
951        .await;
952
953    // Refresh window for expanded state
954    cx.update_window(
955        workspace_window.into(),
956        |_view, window: &mut Window, _cx| {
957            window.refresh();
958        },
959    )?;
960
961    cx.background_executor()
962        .timer(std::time::Duration::from_millis(100))
963        .await;
964
965    // Capture the EXPANDED state (image visible)
966    let expanded_result = run_visual_test(
967        "agent_thread_with_image_expanded",
968        workspace_window.into(),
969        cx,
970        update_baseline,
971    )
972    .await?;
973
974    // Return pass only if both tests passed
975    match (&collapsed_result, &expanded_result) {
976        (TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
977        (TestResult::BaselineUpdated(p1), TestResult::BaselineUpdated(_)) => {
978            Ok(TestResult::BaselineUpdated(p1.clone()))
979        }
980        (TestResult::Passed, TestResult::BaselineUpdated(p)) => {
981            Ok(TestResult::BaselineUpdated(p.clone()))
982        }
983        (TestResult::BaselineUpdated(p), TestResult::Passed) => {
984            Ok(TestResult::BaselineUpdated(p.clone()))
985        }
986    }
987}