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}