Skip to repository content5212 lines · 183.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:33:28.167Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
visual_test_runner.rs
1// Allow blocking process commands in this binary - it's a synchronous test runner
2#![allow(clippy::disallowed_methods)]
3
4//! Visual Test Runner
5//!
6//! This binary runs visual regression tests for Zed's UI. It captures screenshots
7//! of real Zed windows and compares them against baseline images.
8//!
9//! **Note: This tool is macOS-only** because it uses `VisualTestAppContext` which
10//! depends on the macOS Metal renderer for accurate screenshot capture.
11//!
12//! ## How It Works
13//!
14//! This tool uses `VisualTestAppContext` which combines:
15//! - Real Metal/compositor rendering for accurate screenshots
16//! - Deterministic task scheduling via TestDispatcher
17//! - Controllable time via `advance_clock` for testing time-based behaviors
18//!
19//! This approach:
20//! - Does NOT require Screen Recording permission
21//! - Does NOT require the window to be visible on screen
22//! - Captures raw GPUI output without system window chrome
23//! - Is fully deterministic - tooltips, animations, etc. work reliably
24//!
25//! ## Usage
26//!
27//! Run the visual tests:
28//! cargo run -p zed --bin zed_visual_test_runner --features visual-tests
29//!
30//! Update baseline images (when UI intentionally changes):
31//! UPDATE_BASELINE=1 cargo run -p zed --bin zed_visual_test_runner --features visual-tests
32//!
33//! ## Environment Variables
34//!
35//! UPDATE_BASELINE - Set to update baseline images instead of comparing
36//! VISUAL_TEST_OUTPUT_DIR - Directory to save test output (default: target/visual_tests)
37
38// Stub main for non-macOS platforms
39#[cfg(not(target_os = "macos"))]
40fn main() {
41 eprintln!("Visual test runner is only supported on macOS");
42 std::process::exit(1);
43}
44
45#[cfg(target_os = "macos")]
46fn main() {
47 // Set ZED_STATELESS early to prevent file system access to real config directories
48 // This must be done before any code accesses zed_env_vars::ZED_STATELESS
49 // SAFETY: We're at the start of main(), before any threads are spawned
50 unsafe {
51 std::env::set_var("ZED_STATELESS", "1");
52 }
53
54 // Redirect every derived data path into a temporary directory. A visual
55 // test that exercises real on-disk state — omega#81's harness pins and
56 // receipts do — would otherwise write into the developer's own Omega
57 // installation. Set before anything can read `data_dir`.
58 //
59 // `OMEGA_VISUAL_DATA_DIR` overrides the temporary directory so that two
60 // *processes* can share one data directory. That is what makes omega#77's
61 // restart captures a restart: the second process is a genuinely cold one —
62 // empty statics, nothing carried in memory — and the only thing it has of
63 // the first is what the first left on disk. It is still never the
64 // developer's own data directory; `script/omega-visual-proof` creates a
65 // temporary one and passes it in.
66 let data_dir = match std::env::var("OMEGA_VISUAL_DATA_DIR") {
67 Ok(path) if !path.is_empty() => std::path::PathBuf::from(path),
68 _ => tempfile::tempdir()
69 .expect("Failed to create data directory")
70 .keep(),
71 };
72 std::fs::create_dir_all(&data_dir).expect("Failed to create data directory");
73 paths::set_custom_data_dir(data_dir.to_str().expect("Data directory path is not UTF-8"));
74
75 env_logger::builder()
76 .filter_level(log::LevelFilter::Info)
77 .init();
78
79 let update_baseline = std::env::var("UPDATE_BASELINE").is_ok();
80
81 // Create a temporary directory for test files
82 // Canonicalize the path to resolve symlinks (on macOS, /var -> /private/var)
83 // which prevents "path does not exist" errors during worktree scanning
84 // Use keep() to prevent auto-cleanup - background worktree tasks may still be running
85 // when tests complete, so we let the OS clean up temp directories on process exit
86 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
87 let temp_path = temp_dir.keep();
88 let canonical_temp = temp_path
89 .canonicalize()
90 .expect("Failed to canonicalize temp directory");
91 let project_path = canonical_temp.join("project");
92 std::fs::create_dir_all(&project_path).expect("Failed to create project directory");
93
94 // Create test files in the real filesystem
95 create_test_files(&project_path);
96
97 let test_result = std::panic::catch_unwind(|| run_visual_tests(project_path, update_baseline));
98
99 // Note: We don't delete temp_path here because background worktree tasks may still
100 // be running. The directory will be cleaned up when the process exits or by the OS.
101
102 match test_result {
103 Ok(Ok(())) => {}
104 Ok(Err(e)) => {
105 // `{:#}` and not `{}`: every failure here is wrapped in the name of
106 // the suite that produced it, and `{}` prints only that wrapper.
107 // A run that failed because a restored record named the wrong run
108 // would report "omega_agent_surfaces" and nothing else, which is
109 // the same class of unreadable failure this file already carries a
110 // comment about.
111 eprintln!("Visual tests failed: {:#}", e);
112 std::process::exit(1);
113 }
114 Err(_) => {
115 eprintln!("Visual tests panicked");
116 std::process::exit(1);
117 }
118 }
119}
120
121// All macOS-specific imports grouped together
122#[cfg(target_os = "macos")]
123use {
124 acp_thread::{AgentConnection, StubAgentConnection},
125 agent_client_protocol::schema::v1 as acp,
126 agent_servers::{AgentServer, AgentServerDelegate},
127 agent_ui::Agent,
128 anyhow::{Context as _, Result},
129 assets::Assets,
130 editor::display_map::DisplayRow,
131 feature_flags::FeatureFlagAppExt as _,
132 git_ui::project_diff::ProjectDiff,
133 gpui::{
134 App, AppContext as _, Bounds, Entity, KeyBinding, Modifiers, VisualTestAppContext,
135 WindowBounds, WindowHandle, WindowOptions, point, px, size,
136 },
137 image::RgbaImage,
138 project::{AgentId, Project},
139 project_panel::ProjectPanel,
140 settings::{NotifyWhenAgentWaiting, PlaySoundWhenAgentDone, Settings as _},
141 settings_ui::SettingsWindow,
142 std::{
143 any::Any,
144 path::{Path, PathBuf},
145 rc::Rc,
146 sync::Arc,
147 time::Duration,
148 },
149 util::ResultExt as _,
150 workspace::{AppState, MultiWorkspace, Workspace},
151 zed_actions::OpenSettingsAt,
152};
153
154// All macOS-specific constants grouped together
155#[cfg(target_os = "macos")]
156mod constants {
157 use std::time::Duration;
158
159 /// Baseline images are stored relative to this file
160 pub const BASELINE_DIR: &str = "crates/zed/test_fixtures/visual_tests";
161
162 /// Embedded test image (Zed app icon) for visual tests.
163 pub const EMBEDDED_TEST_IMAGE: &[u8] = include_bytes!("../resources/app-icon.png");
164
165 /// Threshold for image comparison (0.0 to 1.0)
166 /// Images must match at least this percentage to pass
167 pub const MATCH_THRESHOLD: f64 = 0.99;
168
169 /// Tooltip show delay - must match TOOLTIP_SHOW_DELAY in gpui/src/elements/div.rs
170 pub const TOOLTIP_SHOW_DELAY: Duration = Duration::from_millis(500);
171}
172
173#[cfg(target_os = "macos")]
174use constants::*;
175
176#[cfg(target_os = "macos")]
177fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> {
178 // Create the visual test context with deterministic task scheduling
179 // Use real Assets so that SVG icons render properly
180 let mut cx = VisualTestAppContext::with_asset_source(
181 gpui_platform::current_platform(false),
182 Arc::new(Assets),
183 );
184
185 // Load embedded fonts (IBM Plex Sans, Lilex, etc.) so UI renders with correct fonts
186 cx.update(|cx| {
187 Assets.load_fonts(cx).unwrap();
188 });
189
190 // Initialize settings store with real default settings (not test settings)
191 // Test settings use Courier font, but we want the real Zed fonts for visual tests
192 cx.update(|cx| {
193 settings::init(cx);
194 });
195
196 // Create AppState using the test initialization
197 let app_state = cx.update(|cx| init_app_state(cx));
198
199 // Set the global app state so settings_ui and other subsystems can find it
200 cx.update(|cx| {
201 AppState::set_global(app_state.clone(), cx);
202 });
203
204 // Initialize all Zed subsystems
205 cx.update(|cx| {
206 gpui_tokio::init(cx);
207 theme_settings::init(theme::LoadThemes::JustBase, cx);
208 client::init(&app_state.client, cx);
209 audio::init(cx);
210 workspace::init(app_state.clone(), cx);
211 release_channel::init(semver::Version::new(0, 0, 0), cx);
212 command_palette::init(cx);
213 editor::init(cx);
214 call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
215 title_bar::init(cx);
216 project_panel::init(cx);
217 outline_panel::init(cx);
218 terminal_view::init(cx);
219 image_viewer::init(cx);
220 search::init(cx);
221 lsp_locations::init(cx);
222 cx.set_global(workspace::PaneSearchBarCallbacks {
223 setup_search_bar: |languages, toolbar, window, cx| {
224 let search_bar = cx.new(|cx| search::BufferSearchBar::new(languages, window, cx));
225 toolbar.update(cx, |toolbar, cx| {
226 toolbar.add_item(search_bar, window, cx);
227 });
228 },
229 wrap_div_with_search_actions: search::buffer_search::register_pane_search_actions,
230 });
231 prompt_store::init(cx);
232 let prompt_builder = prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx);
233 language_model::init(cx);
234 client::RefreshLlmTokenListener::register(
235 app_state.client.clone(),
236 app_state.user_store.clone(),
237 cx,
238 );
239 language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
240 git_ui::init(cx);
241 project::AgentRegistryStore::init_global(
242 cx,
243 app_state.fs.clone(),
244 app_state.client.http_client(),
245 );
246 agent_ui::init(
247 app_state.fs.clone(),
248 prompt_builder,
249 app_state.languages.clone(),
250 true,
251 false,
252 cx,
253 );
254 settings_ui::init(cx);
255
256 // Load default keymaps so tooltips can show keybindings like "f9" for ToggleBreakpoint
257 // We load a minimal set of editor keybindings needed for visual tests
258 cx.bind_keys([KeyBinding::new(
259 "f9",
260 editor::actions::ToggleBreakpoint,
261 Some("Editor"),
262 )]);
263
264 // Disable agent notifications during visual tests to avoid popup windows
265 agent_settings::AgentSettings::override_global(
266 agent_settings::AgentSettings {
267 notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
268 play_sound_when_agent_done: PlaySoundWhenAgentDone::Never,
269 ..agent_settings::AgentSettings::get_global(cx).clone()
270 },
271 cx,
272 );
273 });
274
275 // Run until all initialization tasks complete
276 cx.run_until_parked();
277
278 // Open workspace window
279 let window_size = size(px(1280.0), px(800.0));
280 let bounds = Bounds {
281 origin: point(px(0.0), px(0.0)),
282 size: window_size,
283 };
284
285 // Create a project for the workspace
286 let project = cx.update(|cx| {
287 project::Project::local(
288 app_state.client.clone(),
289 app_state.node_runtime.clone(),
290 app_state.user_store.clone(),
291 app_state.languages.clone(),
292 app_state.fs.clone(),
293 None,
294 project::LocalProjectFlags {
295 init_worktree_trust: false,
296 ..Default::default()
297 },
298 cx,
299 )
300 });
301
302 let workspace_window: WindowHandle<Workspace> = cx
303 .update(|cx| {
304 cx.open_window(
305 WindowOptions {
306 window_bounds: Some(WindowBounds::Windowed(bounds)),
307 focus: false,
308 show: false,
309 ..Default::default()
310 },
311 |window, cx| {
312 cx.new(|cx| {
313 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
314 })
315 },
316 )
317 })
318 .context("Failed to open workspace window")?;
319
320 cx.run_until_parked();
321
322 // Add the test project as a worktree
323 let add_worktree_task = workspace_window
324 .update(&mut cx, |workspace, _window, cx| {
325 let project = workspace.project().clone();
326 project.update(cx, |project, cx| {
327 project.find_or_create_worktree(&project_path, true, cx)
328 })
329 })
330 .context("Failed to start adding worktree")?;
331
332 // Use block_test to wait for the worktree task
333 // block_test runs both foreground and background tasks, which is needed because
334 // worktree creation spawns foreground tasks via cx.spawn
335 // Allow parking since filesystem operations happen outside the test dispatcher
336 cx.background_executor.allow_parking();
337 let worktree_result = cx.foreground_executor.block_test(add_worktree_task);
338 cx.background_executor.forbid_parking();
339 worktree_result.context("Failed to add worktree")?;
340
341 cx.run_until_parked();
342
343 // Create and add the project panel
344 let (weak_workspace, async_window_cx) = workspace_window
345 .update(&mut cx, |workspace, window, cx| {
346 (workspace.weak_handle(), window.to_async(cx))
347 })
348 .context("Failed to get workspace handle")?;
349
350 cx.background_executor.allow_parking();
351 let panel = cx
352 .foreground_executor
353 .block_test(ProjectPanel::load(weak_workspace, async_window_cx))
354 .context("Failed to load project panel")?;
355 cx.background_executor.forbid_parking();
356
357 workspace_window
358 .update(&mut cx, |workspace, window, cx| {
359 workspace.add_panel(panel, window, cx);
360 })
361 .log_err();
362
363 cx.run_until_parked();
364
365 // Open the project panel
366 workspace_window
367 .update(&mut cx, |workspace, window, cx| {
368 workspace.open_panel::<ProjectPanel>(window, cx);
369 })
370 .log_err();
371
372 cx.run_until_parked();
373
374 // Open main.rs in the editor
375 let open_file_task = workspace_window
376 .update(&mut cx, |workspace, window, cx| {
377 let worktree = workspace.project().read(cx).worktrees(cx).next();
378 if let Some(worktree) = worktree {
379 let worktree_id = worktree.read(cx).id();
380 let rel_path: std::sync::Arc<util::rel_path::RelPath> =
381 util::rel_path::rel_path("src/main.rs").into();
382 let project_path: project::ProjectPath = (worktree_id, rel_path).into();
383 Some(workspace.open_path(project_path, None, true, window, cx))
384 } else {
385 None
386 }
387 })
388 .log_err()
389 .flatten();
390
391 if let Some(task) = open_file_task {
392 cx.background_executor.allow_parking();
393 let block_result = cx.foreground_executor.block_test(task);
394 cx.background_executor.forbid_parking();
395 if let Ok(item) = block_result {
396 workspace_window
397 .update(&mut cx, |workspace, window, cx| {
398 let pane = workspace.active_pane().clone();
399 pane.update(cx, |pane, cx| {
400 if let Some(index) = pane.index_for_item(item.as_ref()) {
401 pane.activate_item(index, true, true, window, cx);
402 }
403 });
404 })
405 .log_err();
406 }
407 }
408
409 cx.run_until_parked();
410
411 // Request a window refresh
412 cx.update_window(workspace_window.into(), |_, window, _cx| {
413 window.refresh();
414 })
415 .log_err();
416
417 cx.run_until_parked();
418
419 // Track test results
420 let mut passed = 0;
421 let mut failed = 0;
422 let mut updated = 0;
423
424 // `OMEGA_EXO_VISUAL_ONLY=1` runs the real Exo conversation workspace
425 // proof and nothing else. The lane file must name an isolated Exo install.
426 // This path connects over the shipped ACP stdio transport and sends a real
427 // turn before it captures the wide and narrow layouts.
428 #[cfg(feature = "visual-tests")]
429 if std::env::var("OMEGA_EXO_VISUAL_ONLY").is_ok() {
430 println!("\n--- Omega: real Exo conversation workspace ---");
431 let outcome = run_omega_exo_visual_tests(app_state.clone(), &mut cx, update_baseline);
432 teardown_shared_window(workspace_window, &mut cx);
433 return match outcome {
434 Ok(TestResult::Passed) => {
435 println!("\u{2713} omega_exo_workspace: PASSED");
436 Ok(())
437 }
438 Ok(TestResult::BaselineUpdated(_)) => {
439 println!("\u{2713} omega_exo_workspace: Baselines updated");
440 Ok(())
441 }
442 Err(error) => Err(error.context("omega_exo_workspace")),
443 };
444 }
445
446 // `OMEGA_VISUAL_ONLY=1` runs Omega's own suite and nothing else.
447 //
448 // This runner has no committed baselines for any of the inherited Zed
449 // tests, so a plain run reports "Baseline not found" for every one of them
450 // and Omega's result is buried in the noise. Generating baselines for all
451 // of them would be committing a large set of images nobody has looked at,
452 // which is the opposite of the point. So the Omega suite gets its own
453 // invocation, `script/omega-visual-proof`, and the inherited tests keep the
454 // status they already had: present, and not standing up.
455 #[cfg(feature = "visual-tests")]
456 if std::env::var("OMEGA_VISUAL_ONLY").is_ok() {
457 // `OMEGA_VISUAL_PHASE=restart` is the second process of omega#77's
458 // restart proof. It captures nothing the first process captured: it
459 // reopens two threads the first process left on disk and photographs
460 // the executor lines a cold process derives for them.
461 let restart_phase = std::env::var("OMEGA_VISUAL_PHASE").as_deref() == Ok("restart");
462 let outcome = if restart_phase {
463 println!("\n--- Omega: executor disclosure after a restart ---");
464 run_omega_restart_visual_tests(app_state.clone(), &mut cx, update_baseline)
465 } else {
466 println!("\n--- Omega: front door, executor disclosure, route pin ---");
467 run_omega_agent_visual_tests(app_state.clone(), &mut cx, update_baseline)
468 };
469 // The shared window this function opened above is torn down here as
470 // well as at the end of the full run. Returning early without it left
471 // the sample project's buffers alive, GPUI's leaked-handle check
472 // panicked at drop, and a green suite exited 101 — a script reporting
473 // failure on a run where every capture matched.
474 teardown_shared_window(workspace_window, &mut cx);
475 return match outcome {
476 Ok(TestResult::Passed) => {
477 println!("\u{2713} omega_agent_surfaces: PASSED");
478 Ok(())
479 }
480 Ok(TestResult::BaselineUpdated(_)) => {
481 println!("\u{2713} omega_agent_surfaces: Baselines updated");
482 Ok(())
483 }
484 Err(error) => Err(error.context("omega_agent_surfaces")),
485 };
486 }
487
488 // Run Test 1: Project Panel (with project panel visible)
489 println!("\n--- Test 1: project_panel ---");
490 match run_visual_test(
491 "project_panel",
492 workspace_window.into(),
493 &mut cx,
494 update_baseline,
495 ) {
496 Ok(TestResult::Passed) => {
497 println!("✓ project_panel: PASSED");
498 passed += 1;
499 }
500 Ok(TestResult::BaselineUpdated(_)) => {
501 println!("✓ project_panel: Baseline updated");
502 updated += 1;
503 }
504 Err(e) => {
505 eprintln!("✗ project_panel: FAILED - {}", e);
506 failed += 1;
507 }
508 }
509
510 // Run Test 2: Workspace with Editor
511 println!("\n--- Test 2: workspace_with_editor ---");
512
513 // Close project panel for this test
514 workspace_window
515 .update(&mut cx, |workspace, window, cx| {
516 workspace.close_panel::<ProjectPanel>(window, cx);
517 })
518 .log_err();
519
520 cx.run_until_parked();
521
522 match run_visual_test(
523 "workspace_with_editor",
524 workspace_window.into(),
525 &mut cx,
526 update_baseline,
527 ) {
528 Ok(TestResult::Passed) => {
529 println!("✓ workspace_with_editor: PASSED");
530 passed += 1;
531 }
532 Ok(TestResult::BaselineUpdated(_)) => {
533 println!("✓ workspace_with_editor: Baseline updated");
534 updated += 1;
535 }
536 Err(e) => {
537 eprintln!("✗ workspace_with_editor: FAILED - {}", e);
538 failed += 1;
539 }
540 }
541
542 // Run Test: ThreadItem branch names visual test
543 println!("\n--- Test: thread_item_branch_names ---");
544 match run_thread_item_branch_name_visual_tests(app_state.clone(), &mut cx, update_baseline) {
545 Ok(TestResult::Passed) => {
546 println!("✓ thread_item_branch_names: PASSED");
547 passed += 1;
548 }
549 Ok(TestResult::BaselineUpdated(_)) => {
550 println!("✓ thread_item_branch_names: Baseline updated");
551 updated += 1;
552 }
553 Err(e) => {
554 eprintln!("✗ thread_item_branch_names: FAILED - {}", e);
555 failed += 1;
556 }
557 }
558
559 // Run Test 3: Multi-workspace sidebar visual tests
560 println!("\n--- Test 3: multi_workspace_sidebar ---");
561 match run_multi_workspace_sidebar_visual_tests(app_state.clone(), &mut cx, update_baseline) {
562 Ok(TestResult::Passed) => {
563 println!("✓ multi_workspace_sidebar: PASSED");
564 passed += 1;
565 }
566 Ok(TestResult::BaselineUpdated(_)) => {
567 println!("✓ multi_workspace_sidebar: Baselines updated");
568 updated += 1;
569 }
570 Err(e) => {
571 eprintln!("✗ multi_workspace_sidebar: FAILED - {}", e);
572 failed += 1;
573 }
574 }
575
576 // Run Test 4: Error wrapping visual tests
577 println!("\n--- Test 4: error_message_wrapping ---");
578 match run_error_wrapping_visual_tests(app_state.clone(), &mut cx, update_baseline) {
579 Ok(TestResult::Passed) => {
580 println!("✓ error_message_wrapping: PASSED");
581 passed += 1;
582 }
583 Ok(TestResult::BaselineUpdated(_)) => {
584 println!("✓ error_message_wrapping: Baselines updated");
585 updated += 1;
586 }
587 Err(e) => {
588 eprintln!("✗ error_message_wrapping: FAILED - {}", e);
589 failed += 1;
590 }
591 }
592
593 // Omega's own rendered proofs: the front door with no project, typing
594 // starting a thread, the executor line on three thread kinds, and a pin
595 // that could not be honoured. omega#76, #77, #78.
596 #[cfg(feature = "visual-tests")]
597 {
598 println!("\n--- Omega: front door, executor disclosure, route pin ---");
599 match run_omega_agent_visual_tests(app_state.clone(), &mut cx, update_baseline) {
600 Ok(TestResult::Passed) => {
601 println!("\u{2713} omega_agent_surfaces: PASSED");
602 passed += 1;
603 }
604 Ok(TestResult::BaselineUpdated(_)) => {
605 println!("\u{2713} omega_agent_surfaces: Baselines updated");
606 updated += 1;
607 }
608 Err(e) => {
609 eprintln!("\u{2717} omega_agent_surfaces: FAILED - {}", e);
610 failed += 1;
611 }
612 }
613 }
614
615 // Run Test 5: Agent Thread View tests
616 #[cfg(feature = "visual-tests")]
617 {
618 println!("\n--- Test 5: agent_thread_with_image (collapsed + expanded) ---");
619 match run_agent_thread_view_test(app_state.clone(), &mut cx, update_baseline) {
620 Ok(TestResult::Passed) => {
621 println!("✓ agent_thread_with_image (collapsed + expanded): PASSED");
622 passed += 1;
623 }
624 Ok(TestResult::BaselineUpdated(_)) => {
625 println!("✓ agent_thread_with_image: Baselines updated (collapsed + expanded)");
626 updated += 1;
627 }
628 Err(e) => {
629 eprintln!("✗ agent_thread_with_image: FAILED - {}", e);
630 failed += 1;
631 }
632 }
633 }
634
635 // Run Test 6: Breakpoint Hover visual tests
636 println!("\n--- Test 6: breakpoint_hover (3 variants) ---");
637 match run_breakpoint_hover_visual_tests(app_state.clone(), &mut cx, update_baseline) {
638 Ok(TestResult::Passed) => {
639 println!("✓ breakpoint_hover: PASSED");
640 passed += 1;
641 }
642 Ok(TestResult::BaselineUpdated(_)) => {
643 println!("✓ breakpoint_hover: Baselines updated");
644 updated += 1;
645 }
646 Err(e) => {
647 eprintln!("✗ breakpoint_hover: FAILED - {}", e);
648 failed += 1;
649 }
650 }
651
652 // Run Test 7: Diff Review Button visual tests
653 println!("\n--- Test 7: diff_review_button (3 variants) ---");
654 match run_diff_review_visual_tests(app_state.clone(), &mut cx, update_baseline) {
655 Ok(TestResult::Passed) => {
656 println!("✓ diff_review_button: PASSED");
657 passed += 1;
658 }
659 Ok(TestResult::BaselineUpdated(_)) => {
660 println!("✓ diff_review_button: Baselines updated");
661 updated += 1;
662 }
663 Err(e) => {
664 eprintln!("✗ diff_review_button: FAILED - {}", e);
665 failed += 1;
666 }
667 }
668
669 // Run Test 8: ThreadItem icon decorations visual tests
670 println!("\n--- Test 8: thread_item_icon_decorations ---");
671 match run_thread_item_icon_decorations_visual_tests(app_state.clone(), &mut cx, update_baseline)
672 {
673 Ok(TestResult::Passed) => {
674 println!("✓ thread_item_icon_decorations: PASSED");
675 passed += 1;
676 }
677 Ok(TestResult::BaselineUpdated(_)) => {
678 println!("✓ thread_item_icon_decorations: Baseline updated");
679 updated += 1;
680 }
681 Err(e) => {
682 eprintln!("✗ thread_item_icon_decorations: FAILED - {}", e);
683 failed += 1;
684 }
685 }
686
687 // Run Test: Sidebar with duplicate project names
688 println!("\n--- Test: sidebar_duplicate_names ---");
689 match run_sidebar_duplicate_project_names_visual_tests(
690 app_state.clone(),
691 &mut cx,
692 update_baseline,
693 ) {
694 Ok(TestResult::Passed) => {
695 println!("✓ sidebar_duplicate_names: PASSED");
696 passed += 1;
697 }
698 Ok(TestResult::BaselineUpdated(_)) => {
699 println!("✓ sidebar_duplicate_names: Baselines updated");
700 updated += 1;
701 }
702 Err(e) => {
703 eprintln!("✗ sidebar_duplicate_names: FAILED - {}", e);
704 failed += 1;
705 }
706 }
707
708 // Run Test 9: Tool Permissions Settings UI visual test
709 println!("\n--- Test 9: tool_permissions_settings ---");
710 match run_tool_permissions_visual_tests(app_state.clone(), &mut cx, update_baseline) {
711 Ok(TestResult::Passed) => {
712 println!("✓ tool_permissions_settings: PASSED");
713 passed += 1;
714 }
715 Ok(TestResult::BaselineUpdated(_)) => {
716 println!("✓ tool_permissions_settings: Baselines updated");
717 updated += 1;
718 }
719 Err(e) => {
720 eprintln!("✗ tool_permissions_settings: FAILED - {}", e);
721 failed += 1;
722 }
723 }
724
725 // Run Test 10: Settings UI sub-page auto-open visual tests
726 println!("\n--- Test 10: settings_ui_subpage_auto_open (2 variants) ---");
727 match run_settings_ui_subpage_visual_tests(app_state.clone(), &mut cx, update_baseline) {
728 Ok(TestResult::Passed) => {
729 println!("✓ settings_ui_subpage_auto_open: PASSED");
730 passed += 1;
731 }
732 Ok(TestResult::BaselineUpdated(_)) => {
733 println!("✓ settings_ui_subpage_auto_open: Baselines updated");
734 updated += 1;
735 }
736 Err(e) => {
737 eprintln!("✗ settings_ui_subpage_auto_open: FAILED - {}", e);
738 failed += 1;
739 }
740 }
741
742 // Run Test 11: External agent harness maintenance (omega#81)
743 println!("\n--- Test 11: external_agent_harness_maintenance ---");
744 match run_external_agent_maintenance_visual_tests(app_state.clone(), &mut cx, update_baseline) {
745 Ok(TestResult::Passed) => {
746 println!("\u{2713} external_agent_harness_maintenance: PASSED");
747 passed += 1;
748 }
749 Ok(TestResult::BaselineUpdated(_)) => {
750 println!("\u{2713} external_agent_harness_maintenance: Baselines updated");
751 updated += 1;
752 }
753 Err(e) => {
754 eprintln!(
755 "\u{2717} external_agent_harness_maintenance: FAILED - {}",
756 e
757 );
758 failed += 1;
759 }
760 }
761
762 // Clean up the main workspace's worktree to stop background scanning tasks
763 // This prevents "root path could not be canonicalized" errors when main() drops temp_dir
764 teardown_shared_window(workspace_window, &mut cx);
765
766 // Print summary
767 println!("\n=== Test Summary ===");
768 println!("Passed: {}", passed);
769 println!("Failed: {}", failed);
770 if updated > 0 {
771 println!("Baselines Updated: {}", updated);
772 }
773
774 if failed > 0 {
775 eprintln!("\n=== Visual Tests FAILED ===");
776 Err(anyhow::anyhow!("{} tests failed", failed))
777 } else {
778 println!("\n=== All Visual Tests PASSED ===");
779 Ok(())
780 }
781}
782
783#[cfg(target_os = "macos")]
784enum TestResult {
785 Passed,
786 BaselineUpdated(PathBuf),
787}
788
789#[cfg(target_os = "macos")]
790fn run_visual_test(
791 test_name: &str,
792 window: gpui::AnyWindowHandle,
793 cx: &mut VisualTestAppContext,
794 update_baseline: bool,
795) -> Result<TestResult> {
796 // Ensure all pending work is done
797 cx.run_until_parked();
798
799 // Refresh the window to ensure it's fully rendered
800 cx.update_window(window, |_, window, _cx| {
801 window.refresh();
802 })?;
803
804 cx.run_until_parked();
805
806 // Capture the screenshot using direct texture capture
807 let screenshot = cx.capture_screenshot(window)?;
808
809 // Get paths
810 let baseline_path = get_baseline_path(test_name);
811 let output_dir = std::env::var("VISUAL_TEST_OUTPUT_DIR")
812 .unwrap_or_else(|_| "target/visual_tests".to_string());
813 let output_path = PathBuf::from(&output_dir).join(format!("{}.png", test_name));
814
815 // Ensure output directory exists
816 std::fs::create_dir_all(&output_dir)?;
817
818 // Always save the current screenshot
819 screenshot.save(&output_path)?;
820 println!(" Screenshot saved to: {}", output_path.display());
821
822 if update_baseline {
823 // Update the baseline
824 if let Some(parent) = baseline_path.parent() {
825 std::fs::create_dir_all(parent)?;
826 }
827 screenshot.save(&baseline_path)?;
828 println!(" Baseline updated: {}", baseline_path.display());
829 return Ok(TestResult::BaselineUpdated(baseline_path));
830 }
831
832 // Compare with baseline
833 if !baseline_path.exists() {
834 return Err(anyhow::anyhow!(
835 "Baseline not found: {}. Run with UPDATE_BASELINE=1 to create it.",
836 baseline_path.display()
837 ));
838 }
839
840 let baseline = image::open(&baseline_path)?.to_rgba8();
841 let comparison = compare_images(&screenshot, &baseline);
842
843 println!(
844 " Match: {:.2}% ({} different pixels)",
845 comparison.match_percentage * 100.0,
846 comparison.diff_pixel_count
847 );
848
849 if comparison.match_percentage >= MATCH_THRESHOLD {
850 Ok(TestResult::Passed)
851 } else {
852 // Save diff image
853 let diff_path = PathBuf::from(&output_dir).join(format!("{}_diff.png", test_name));
854 comparison.diff_image.save(&diff_path)?;
855 println!(" Diff image saved to: {}", diff_path.display());
856
857 Err(anyhow::anyhow!(
858 "Image mismatch: {:.2}% match (threshold: {:.2}%)",
859 comparison.match_percentage * 100.0,
860 MATCH_THRESHOLD * 100.0
861 ))
862 }
863}
864
865/// Tear down the shared workspace window this runner opens for the inherited
866/// tests.
867///
868/// Extracted so the `OMEGA_VISUAL_ONLY` early return runs it too. GPUI checks
869/// for leaked entity handles when the app drops, and skipping this left the
870/// sample project's buffers alive — a green suite that exited 101.
871#[cfg(target_os = "macos")]
872fn teardown_shared_window(
873 workspace_window: WindowHandle<Workspace>,
874 cx: &mut VisualTestAppContext,
875) {
876 workspace_window
877 .update(cx, |workspace, _window, cx| {
878 let project = workspace.project().clone();
879 project.update(cx, |project, cx| {
880 let worktree_ids: Vec<_> =
881 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
882 for id in worktree_ids {
883 project.remove_worktree(id, cx);
884 }
885 });
886 })
887 .log_err();
888
889 cx.run_until_parked();
890
891 cx.update_window(workspace_window.into(), |_, window, _cx| {
892 window.remove_window();
893 })
894 .log_err();
895
896 cx.run_until_parked();
897
898 // Background tasks, including scrollbar hide timers (1 second).
899 for _ in 0..15 {
900 cx.advance_clock(Duration::from_millis(100));
901 cx.run_until_parked();
902 }
903}
904
905#[cfg(target_os = "macos")]
906fn get_baseline_path(test_name: &str) -> PathBuf {
907 // Get the workspace root (where Cargo.toml is)
908 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
909 let workspace_root = PathBuf::from(manifest_dir)
910 .parent()
911 .and_then(|p| p.parent())
912 .map(|p| p.to_path_buf())
913 .unwrap_or_else(|| PathBuf::from("."));
914
915 workspace_root
916 .join(BASELINE_DIR)
917 .join(format!("{}.png", test_name))
918}
919
920#[cfg(target_os = "macos")]
921struct ImageComparison {
922 match_percentage: f64,
923 diff_image: RgbaImage,
924 diff_pixel_count: u32,
925 #[allow(dead_code)]
926 total_pixels: u32,
927}
928
929#[cfg(target_os = "macos")]
930fn compare_images(actual: &RgbaImage, expected: &RgbaImage) -> ImageComparison {
931 let width = actual.width().max(expected.width());
932 let height = actual.height().max(expected.height());
933 let total_pixels = width * height;
934
935 let mut diff_image = RgbaImage::new(width, height);
936 let mut matching_pixels = 0u32;
937
938 for y in 0..height {
939 for x in 0..width {
940 let actual_pixel = if x < actual.width() && y < actual.height() {
941 *actual.get_pixel(x, y)
942 } else {
943 image::Rgba([0, 0, 0, 0])
944 };
945
946 let expected_pixel = if x < expected.width() && y < expected.height() {
947 *expected.get_pixel(x, y)
948 } else {
949 image::Rgba([0, 0, 0, 0])
950 };
951
952 if pixels_are_similar(&actual_pixel, &expected_pixel) {
953 matching_pixels += 1;
954 // Semi-transparent green for matching pixels
955 diff_image.put_pixel(x, y, image::Rgba([0, 255, 0, 64]));
956 } else {
957 // Bright red for differing pixels
958 diff_image.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
959 }
960 }
961 }
962
963 let match_percentage = matching_pixels as f64 / total_pixels as f64;
964 let diff_pixel_count = total_pixels - matching_pixels;
965
966 ImageComparison {
967 match_percentage,
968 diff_image,
969 diff_pixel_count,
970 total_pixels,
971 }
972}
973
974#[cfg(target_os = "macos")]
975fn pixels_are_similar(a: &image::Rgba<u8>, b: &image::Rgba<u8>) -> bool {
976 const TOLERANCE: i16 = 2;
977 (a.0[0] as i16 - b.0[0] as i16).abs() <= TOLERANCE
978 && (a.0[1] as i16 - b.0[1] as i16).abs() <= TOLERANCE
979 && (a.0[2] as i16 - b.0[2] as i16).abs() <= TOLERANCE
980 && (a.0[3] as i16 - b.0[3] as i16).abs() <= TOLERANCE
981}
982
983#[cfg(target_os = "macos")]
984fn create_test_files(project_path: &Path) {
985 // Create src directory
986 let src_dir = project_path.join("src");
987 std::fs::create_dir_all(&src_dir).expect("Failed to create src directory");
988
989 // Create main.rs
990 let main_rs = r#"fn main() {
991 println!("Hello, world!");
992
993 let x = 42;
994 let y = x * 2;
995
996 if y > 50 {
997 println!("y is greater than 50");
998 } else {
999 println!("y is not greater than 50");
1000 }
1001
1002 for i in 0..10 {
1003 println!("i = {}", i);
1004 }
1005}
1006
1007fn helper_function(a: i32, b: i32) -> i32 {
1008 a + b
1009}
1010
1011struct MyStruct {
1012 field1: String,
1013 field2: i32,
1014}
1015
1016impl MyStruct {
1017 fn new(name: &str, value: i32) -> Self {
1018 Self {
1019 field1: name.to_string(),
1020 field2: value,
1021 }
1022 }
1023
1024 fn get_value(&self) -> i32 {
1025 self.field2
1026 }
1027}
1028"#;
1029 std::fs::write(src_dir.join("main.rs"), main_rs).expect("Failed to write main.rs");
1030
1031 // Create lib.rs
1032 let lib_rs = r#"//! A sample library for visual testing
1033
1034pub mod utils;
1035
1036/// A public function in the library
1037pub fn library_function() -> String {
1038 "Hello from lib".to_string()
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043 use super::*;
1044
1045 #[test]
1046 fn it_works() {
1047 assert_eq!(library_function(), "Hello from lib");
1048 }
1049}
1050"#;
1051 std::fs::write(src_dir.join("lib.rs"), lib_rs).expect("Failed to write lib.rs");
1052
1053 // Create utils.rs
1054 let utils_rs = r#"//! Utility functions
1055
1056/// Format a number with commas
1057pub fn format_number(n: u64) -> String {
1058 let s = n.to_string();
1059 let mut result = String::new();
1060 for (i, c) in s.chars().rev().enumerate() {
1061 if i > 0 && i % 3 == 0 {
1062 result.push(',');
1063 }
1064 result.push(c);
1065 }
1066 result.chars().rev().collect()
1067}
1068
1069/// Calculate fibonacci number
1070pub fn fibonacci(n: u32) -> u64 {
1071 match n {
1072 0 => 0,
1073 1 => 1,
1074 _ => fibonacci(n - 1) + fibonacci(n - 2),
1075 }
1076}
1077"#;
1078 std::fs::write(src_dir.join("utils.rs"), utils_rs).expect("Failed to write utils.rs");
1079
1080 // Create Cargo.toml
1081 let cargo_toml = r#"[package]
1082name = "test_project"
1083version = "0.1.0"
1084edition = "2021"
1085
1086[dependencies]
1087"#;
1088 std::fs::write(project_path.join("Cargo.toml"), cargo_toml)
1089 .expect("Failed to write Cargo.toml");
1090
1091 // Create README.md
1092 let readme = r#"# Test Project
1093
1094This is a test project for visual testing of Omega.
1095
1096## Features
1097
1098- Feature 1
1099- Feature 2
1100- Feature 3
1101
1102## Usage
1103
1104```bash
1105cargo run
1106```
1107"#;
1108 std::fs::write(project_path.join("README.md"), readme).expect("Failed to write README.md");
1109}
1110
1111#[cfg(target_os = "macos")]
1112fn init_app_state(cx: &mut App) -> Arc<AppState> {
1113 use fs::Fs;
1114 use node_runtime::NodeRuntime;
1115 use session::Session;
1116 use settings::SettingsStore;
1117
1118 if !cx.has_global::<SettingsStore>() {
1119 let settings_store = SettingsStore::test(cx);
1120 cx.set_global(settings_store);
1121 }
1122
1123 // Use the real filesystem instead of FakeFs so we can access actual files on disk
1124 let fs: Arc<dyn Fs> = Arc::new(fs::RealFs::new(None, cx.background_executor().clone()));
1125 <dyn Fs>::set_global(fs.clone(), cx);
1126
1127 let languages = Arc::new(language::LanguageRegistry::test(
1128 cx.background_executor().clone(),
1129 ));
1130 let clock = Arc::new(clock::FakeSystemClock::new());
1131 let http_client = http_client::FakeHttpClient::with_404_response();
1132 let client = client::Client::new(clock, http_client, cx);
1133 let session = cx.new(|cx| session::AppSession::new(Session::test(), cx));
1134 let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx));
1135 let workspace_store = cx.new(|cx| workspace::WorkspaceStore::new(client.clone(), cx));
1136
1137 theme_settings::init(theme::LoadThemes::JustBase, cx);
1138 client::init(&client, cx);
1139
1140 let app_state = Arc::new(AppState {
1141 client,
1142 fs,
1143 languages,
1144 user_store,
1145 workspace_store,
1146 node_runtime: NodeRuntime::unavailable(),
1147 build_window_options: |_, _| Default::default(),
1148 session,
1149 });
1150 AppState::set_global(app_state.clone(), cx);
1151 app_state
1152}
1153
1154/// Runs visual tests for breakpoint hover states in the editor gutter.
1155///
1156/// This test captures three states:
1157/// 1. Gutter with line numbers, no breakpoint hover (baseline)
1158/// 2. Gutter with breakpoint hover indicator (gray circle)
1159/// 3. Gutter with breakpoint hover AND tooltip
1160#[cfg(target_os = "macos")]
1161fn run_breakpoint_hover_visual_tests(
1162 app_state: Arc<AppState>,
1163 cx: &mut VisualTestAppContext,
1164 update_baseline: bool,
1165) -> Result<TestResult> {
1166 // Create a temporary directory with a simple test file
1167 let temp_dir = tempfile::tempdir()?;
1168 let temp_path = temp_dir.keep();
1169 let canonical_temp = temp_path.canonicalize()?;
1170 let project_path = canonical_temp.join("project");
1171 std::fs::create_dir_all(&project_path)?;
1172
1173 // Create a simple file with a few lines
1174 let src_dir = project_path.join("src");
1175 std::fs::create_dir_all(&src_dir)?;
1176
1177 let test_content = r#"fn main() {
1178 println!("Hello");
1179 let x = 42;
1180}
1181"#;
1182 std::fs::write(src_dir.join("test.rs"), test_content)?;
1183
1184 // Create a small window - just big enough to show gutter and a few lines
1185 let window_size = size(px(300.0), px(200.0));
1186 let bounds = Bounds {
1187 origin: point(px(0.0), px(0.0)),
1188 size: window_size,
1189 };
1190
1191 // Create project
1192 let project = cx.update(|cx| {
1193 project::Project::local(
1194 app_state.client.clone(),
1195 app_state.node_runtime.clone(),
1196 app_state.user_store.clone(),
1197 app_state.languages.clone(),
1198 app_state.fs.clone(),
1199 None,
1200 project::LocalProjectFlags {
1201 init_worktree_trust: false,
1202 ..Default::default()
1203 },
1204 cx,
1205 )
1206 });
1207
1208 // Open workspace window
1209 let workspace_window: WindowHandle<Workspace> = cx
1210 .update(|cx| {
1211 cx.open_window(
1212 WindowOptions {
1213 window_bounds: Some(WindowBounds::Windowed(bounds)),
1214 focus: false,
1215 show: false,
1216 ..Default::default()
1217 },
1218 |window, cx| {
1219 cx.new(|cx| {
1220 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
1221 })
1222 },
1223 )
1224 })
1225 .context("Failed to open breakpoint test window")?;
1226
1227 cx.run_until_parked();
1228
1229 // Add the project as a worktree
1230 let add_worktree_task = workspace_window
1231 .update(cx, |workspace, _window, cx| {
1232 let project = workspace.project().clone();
1233 project.update(cx, |project, cx| {
1234 project.find_or_create_worktree(&project_path, true, cx)
1235 })
1236 })
1237 .context("Failed to start adding worktree")?;
1238
1239 cx.background_executor.allow_parking();
1240 let worktree_result = cx.foreground_executor.block_test(add_worktree_task);
1241 cx.background_executor.forbid_parking();
1242 worktree_result.context("Failed to add worktree")?;
1243
1244 cx.run_until_parked();
1245
1246 // Open the test file
1247 let open_file_task = workspace_window
1248 .update(cx, |workspace, window, cx| {
1249 let worktree = workspace.project().read(cx).worktrees(cx).next();
1250 if let Some(worktree) = worktree {
1251 let worktree_id = worktree.read(cx).id();
1252 let rel_path: std::sync::Arc<util::rel_path::RelPath> =
1253 util::rel_path::rel_path("src/test.rs").into();
1254 let project_path: project::ProjectPath = (worktree_id, rel_path).into();
1255 Some(workspace.open_path(project_path, None, true, window, cx))
1256 } else {
1257 None
1258 }
1259 })
1260 .log_err()
1261 .flatten();
1262
1263 if let Some(task) = open_file_task {
1264 cx.background_executor.allow_parking();
1265 cx.foreground_executor.block_test(task).log_err();
1266 cx.background_executor.forbid_parking();
1267 }
1268
1269 cx.run_until_parked();
1270
1271 // Wait for the editor to fully load
1272 for _ in 0..10 {
1273 cx.advance_clock(Duration::from_millis(100));
1274 cx.run_until_parked();
1275 }
1276
1277 // Refresh window
1278 cx.update_window(workspace_window.into(), |_, window, _cx| {
1279 window.refresh();
1280 })?;
1281
1282 cx.run_until_parked();
1283
1284 // Test 1: Gutter visible with line numbers, no breakpoint hover
1285 let test1_result = run_visual_test(
1286 "breakpoint_hover_none",
1287 workspace_window.into(),
1288 cx,
1289 update_baseline,
1290 )?;
1291
1292 // Test 2: Breakpoint hover indicator (circle) visible
1293 // The gutter is on the left side. We need to position the mouse over the gutter area
1294 // for line 1. The breakpoint indicator appears in the leftmost part of the gutter.
1295 //
1296 // The breakpoint hover requires multiple steps:
1297 // 1. Draw to register mouse listeners
1298 // 2. Mouse move to trigger gutter_hovered and create GutterHoverButton
1299 // 3. Wait 200ms for is_active to become true
1300 // 4. Draw again to render the indicator
1301 //
1302 // The gutter_position should be in the gutter area to trigger the gutter hover button.
1303 // The button_position should be directly over the breakpoint icon button for tooltip hover.
1304 // Based on debug output: button is at origin=(3.12, 66.5) with size=(14, 16)
1305 let gutter_position = point(px(30.0), px(85.0));
1306 let button_position = point(px(10.0), px(75.0)); // Center of the breakpoint button
1307
1308 // Step 1: Initial draw to register mouse listeners
1309 cx.update_window(workspace_window.into(), |_, window, cx| {
1310 window.draw(cx).clear(cx);
1311 })?;
1312 cx.run_until_parked();
1313
1314 // Step 2: Simulate mouse move into gutter area
1315 cx.simulate_mouse_move(
1316 workspace_window.into(),
1317 gutter_position,
1318 None,
1319 Modifiers::default(),
1320 );
1321
1322 // Step 3: Advance clock past 200ms debounce
1323 cx.advance_clock(Duration::from_millis(300));
1324 cx.run_until_parked();
1325
1326 // Step 4: Draw again to pick up the indicator state change
1327 cx.update_window(workspace_window.into(), |_, window, cx| {
1328 window.draw(cx).clear(cx);
1329 })?;
1330 cx.run_until_parked();
1331
1332 // Step 5: Another mouse move to keep hover state active
1333 cx.simulate_mouse_move(
1334 workspace_window.into(),
1335 gutter_position,
1336 None,
1337 Modifiers::default(),
1338 );
1339
1340 // Step 6: Final draw
1341 cx.update_window(workspace_window.into(), |_, window, cx| {
1342 window.draw(cx).clear(cx);
1343 })?;
1344 cx.run_until_parked();
1345
1346 let test2_result = run_visual_test(
1347 "breakpoint_hover_circle",
1348 workspace_window.into(),
1349 cx,
1350 update_baseline,
1351 )?;
1352
1353 // Test 3: Breakpoint hover with tooltip visible
1354 // The tooltip delay is 500ms (TOOLTIP_SHOW_DELAY constant)
1355 // We need to position the mouse directly over the breakpoint button for the tooltip to show.
1356 // The button hitbox is approximately at (3.12, 66.5) with size (14, 16).
1357
1358 // Move mouse directly over the button to trigger tooltip hover
1359 cx.simulate_mouse_move(
1360 workspace_window.into(),
1361 button_position,
1362 None,
1363 Modifiers::default(),
1364 );
1365
1366 // Draw to register the button's tooltip hover listener
1367 cx.update_window(workspace_window.into(), |_, window, cx| {
1368 window.draw(cx).clear(cx);
1369 })?;
1370 cx.run_until_parked();
1371
1372 // Move mouse over button again to trigger tooltip scheduling
1373 cx.simulate_mouse_move(
1374 workspace_window.into(),
1375 button_position,
1376 None,
1377 Modifiers::default(),
1378 );
1379
1380 // Advance clock past TOOLTIP_SHOW_DELAY (500ms)
1381 cx.advance_clock(TOOLTIP_SHOW_DELAY + Duration::from_millis(100));
1382 cx.run_until_parked();
1383
1384 // Draw to render the tooltip
1385 cx.update_window(workspace_window.into(), |_, window, cx| {
1386 window.draw(cx).clear(cx);
1387 })?;
1388 cx.run_until_parked();
1389
1390 // Refresh window
1391 cx.update_window(workspace_window.into(), |_, window, _cx| {
1392 window.refresh();
1393 })?;
1394
1395 cx.run_until_parked();
1396
1397 let test3_result = run_visual_test(
1398 "breakpoint_hover_tooltip",
1399 workspace_window.into(),
1400 cx,
1401 update_baseline,
1402 )?;
1403
1404 // Clean up: remove worktrees to stop background scanning
1405 workspace_window
1406 .update(cx, |workspace, _window, cx| {
1407 let project = workspace.project().clone();
1408 project.update(cx, |project, cx| {
1409 let worktree_ids: Vec<_> =
1410 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
1411 for id in worktree_ids {
1412 project.remove_worktree(id, cx);
1413 }
1414 });
1415 })
1416 .log_err();
1417
1418 cx.run_until_parked();
1419
1420 // Close the window
1421 cx.update_window(workspace_window.into(), |_, window, _cx| {
1422 window.remove_window();
1423 })
1424 .log_err();
1425
1426 cx.run_until_parked();
1427
1428 // Give background tasks time to finish
1429 for _ in 0..15 {
1430 cx.advance_clock(Duration::from_millis(100));
1431 cx.run_until_parked();
1432 }
1433
1434 // Return combined result
1435 match (&test1_result, &test2_result, &test3_result) {
1436 (TestResult::Passed, TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
1437 (TestResult::BaselineUpdated(p), _, _)
1438 | (_, TestResult::BaselineUpdated(p), _)
1439 | (_, _, TestResult::BaselineUpdated(p)) => Ok(TestResult::BaselineUpdated(p.clone())),
1440 }
1441}
1442
1443/// Runs visual tests for the settings UI sub-page auto-open feature.
1444///
1445/// This test verifies that when opening settings via OpenSettingsAt with a path
1446/// that maps to a single SubPageLink, the sub-page is automatically opened.
1447///
1448/// This test captures two states:
1449/// 1. Settings opened with a path that maps to multiple items (no auto-open)
1450/// 2. Settings opened with a path that maps to a single SubPageLink (auto-opens sub-page)
1451#[cfg(target_os = "macos")]
1452fn run_settings_ui_subpage_visual_tests(
1453 app_state: Arc<AppState>,
1454 cx: &mut VisualTestAppContext,
1455 update_baseline: bool,
1456) -> Result<TestResult> {
1457 // Create a workspace window for dispatching actions
1458 let window_size = size(px(1280.0), px(800.0));
1459 let bounds = Bounds {
1460 origin: point(px(0.0), px(0.0)),
1461 size: window_size,
1462 };
1463
1464 let project = cx.update(|cx| {
1465 project::Project::local(
1466 app_state.client.clone(),
1467 app_state.node_runtime.clone(),
1468 app_state.user_store.clone(),
1469 app_state.languages.clone(),
1470 app_state.fs.clone(),
1471 None,
1472 project::LocalProjectFlags {
1473 init_worktree_trust: false,
1474 ..Default::default()
1475 },
1476 cx,
1477 )
1478 });
1479
1480 let workspace_window: WindowHandle<MultiWorkspace> = cx
1481 .update(|cx| {
1482 cx.open_window(
1483 WindowOptions {
1484 window_bounds: Some(WindowBounds::Windowed(bounds)),
1485 focus: false,
1486 show: false,
1487 ..Default::default()
1488 },
1489 |window, cx| {
1490 let workspace = cx.new(|cx| {
1491 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
1492 });
1493 cx.new(|cx| MultiWorkspace::new(workspace, window, cx))
1494 },
1495 )
1496 })
1497 .context("Failed to open workspace window")?;
1498
1499 cx.run_until_parked();
1500
1501 // Test 1: Open settings with a path that maps to multiple items (e.g., "agent")
1502 // This should NOT auto-open a sub-page since multiple items match
1503 workspace_window
1504 .update(cx, |_workspace, window, cx| {
1505 window.dispatch_action(
1506 Box::new(OpenSettingsAt {
1507 path: "agent".to_string(),
1508 target: None,
1509 }),
1510 cx,
1511 );
1512 })
1513 .context("Failed to dispatch OpenSettingsAt for multiple items")?;
1514
1515 cx.run_until_parked();
1516
1517 // Find the settings window
1518 let settings_window_1 = cx
1519 .update(|cx| {
1520 cx.windows()
1521 .into_iter()
1522 .find_map(|window| window.downcast::<SettingsWindow>())
1523 })
1524 .context("Settings window not found")?;
1525
1526 // Refresh and capture screenshot
1527 cx.update_window(settings_window_1.into(), |_, window, _cx| {
1528 window.refresh();
1529 })?;
1530 cx.run_until_parked();
1531
1532 let test1_result = run_visual_test(
1533 "settings_ui_no_auto_open",
1534 settings_window_1.into(),
1535 cx,
1536 update_baseline,
1537 )?;
1538
1539 // Close the settings window
1540 cx.update_window(settings_window_1.into(), |_, window, _cx| {
1541 window.remove_window();
1542 })
1543 .log_err();
1544 cx.run_until_parked();
1545
1546 // Test 2: Open settings with a path that maps to a single SubPageLink
1547 // "edit_predictions.providers" maps to the "Configure Providers" SubPageLink
1548 // This should auto-open the sub-page
1549 workspace_window
1550 .update(cx, |_workspace, window, cx| {
1551 window.dispatch_action(
1552 Box::new(OpenSettingsAt {
1553 path: "edit_predictions.providers".to_string(),
1554 target: None,
1555 }),
1556 cx,
1557 );
1558 })
1559 .context("Failed to dispatch OpenSettingsAt for single SubPageLink")?;
1560
1561 cx.run_until_parked();
1562
1563 // Find the new settings window
1564 let settings_window_2 = cx
1565 .update(|cx| {
1566 cx.windows()
1567 .into_iter()
1568 .find_map(|window| window.downcast::<SettingsWindow>())
1569 })
1570 .context("Settings window not found for sub-page test")?;
1571
1572 // Refresh and capture screenshot
1573 cx.update_window(settings_window_2.into(), |_, window, _cx| {
1574 window.refresh();
1575 })?;
1576 cx.run_until_parked();
1577
1578 let test2_result = run_visual_test(
1579 "settings_ui_subpage_auto_open",
1580 settings_window_2.into(),
1581 cx,
1582 update_baseline,
1583 )?;
1584
1585 // Clean up: close the settings window
1586 cx.update_window(settings_window_2.into(), |_, window, _cx| {
1587 window.remove_window();
1588 })
1589 .log_err();
1590 cx.run_until_parked();
1591
1592 // Clean up: close the workspace window
1593 cx.update_window(workspace_window.into(), |_, window, _cx| {
1594 window.remove_window();
1595 })
1596 .log_err();
1597 cx.run_until_parked();
1598
1599 // Give background tasks time to finish
1600 for _ in 0..5 {
1601 cx.advance_clock(Duration::from_millis(100));
1602 cx.run_until_parked();
1603 }
1604
1605 // Return combined result
1606 match (&test1_result, &test2_result) {
1607 (TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
1608 (TestResult::BaselineUpdated(p), _) | (_, TestResult::BaselineUpdated(p)) => {
1609 Ok(TestResult::BaselineUpdated(p.clone()))
1610 }
1611 }
1612}
1613
1614/// Runs visual tests for the diff review button in git diff views.
1615///
1616/// This test captures three states:
1617/// 1. Diff view with feature flag enabled (button visible)
1618/// 2. Diff view with feature flag disabled (no button)
1619/// 3. Regular editor with feature flag enabled (no button - only shows in diff views)
1620#[cfg(target_os = "macos")]
1621fn run_diff_review_visual_tests(
1622 app_state: Arc<AppState>,
1623 cx: &mut VisualTestAppContext,
1624 update_baseline: bool,
1625) -> Result<TestResult> {
1626 // Create a temporary directory with test files and a real git repo
1627 let temp_dir = tempfile::tempdir()?;
1628 let temp_path = temp_dir.keep();
1629 let canonical_temp = temp_path.canonicalize()?;
1630 let project_path = canonical_temp.join("project");
1631 std::fs::create_dir_all(&project_path)?;
1632
1633 // Initialize a real git repository
1634 std::process::Command::new("git")
1635 .args(["init"])
1636 .current_dir(&project_path)
1637 .output()?;
1638
1639 // Configure git user for commits
1640 std::process::Command::new("git")
1641 .args(["config", "user.email", "test@test.com"])
1642 .current_dir(&project_path)
1643 .output()?;
1644 std::process::Command::new("git")
1645 .args(["config", "user.name", "Test User"])
1646 .current_dir(&project_path)
1647 .output()?;
1648
1649 // Create a test file with original content
1650 let original_content = "// Original content\n";
1651 std::fs::write(project_path.join("thread-view.tsx"), original_content)?;
1652
1653 // Commit the original file
1654 std::process::Command::new("git")
1655 .args(["add", "thread-view.tsx"])
1656 .current_dir(&project_path)
1657 .output()?;
1658 std::process::Command::new("git")
1659 .args(["commit", "-m", "Initial commit"])
1660 .current_dir(&project_path)
1661 .output()?;
1662
1663 // Modify the file to create a diff
1664 let modified_content = r#"import { ScrollArea } from 'components';
1665import { ButtonAlt, Tooltip } from 'ui';
1666import { Message, FileEdit } from 'types';
1667import { AiPaneTabContext } from 'context';
1668"#;
1669 std::fs::write(project_path.join("thread-view.tsx"), modified_content)?;
1670
1671 // Create window for the diff view - sized to show just the editor
1672 let window_size = size(px(600.0), px(400.0));
1673 let bounds = Bounds {
1674 origin: point(px(0.0), px(0.0)),
1675 size: window_size,
1676 };
1677
1678 // Create project
1679 let project = cx.update(|cx| {
1680 project::Project::local(
1681 app_state.client.clone(),
1682 app_state.node_runtime.clone(),
1683 app_state.user_store.clone(),
1684 app_state.languages.clone(),
1685 app_state.fs.clone(),
1686 None,
1687 project::LocalProjectFlags {
1688 init_worktree_trust: false,
1689 ..Default::default()
1690 },
1691 cx,
1692 )
1693 });
1694
1695 // Add the test directory as a worktree
1696 let add_worktree_task = project.update(cx, |project, cx| {
1697 project.find_or_create_worktree(&project_path, true, cx)
1698 });
1699
1700 cx.background_executor.allow_parking();
1701 cx.foreground_executor
1702 .block_test(add_worktree_task)
1703 .log_err();
1704 cx.background_executor.forbid_parking();
1705
1706 cx.run_until_parked();
1707
1708 // Wait for worktree to be fully scanned and git status to be detected
1709 for _ in 0..5 {
1710 cx.advance_clock(Duration::from_millis(100));
1711 cx.run_until_parked();
1712 }
1713
1714 // Test 1: Diff view with feature flag enabled
1715 // Enable the feature flag
1716 cx.update(|cx| {
1717 cx.update_flags(true, vec!["diff-review".to_string()]);
1718 });
1719
1720 let workspace_window: WindowHandle<Workspace> = cx
1721 .update(|cx| {
1722 cx.open_window(
1723 WindowOptions {
1724 window_bounds: Some(WindowBounds::Windowed(bounds)),
1725 focus: false,
1726 show: false,
1727 ..Default::default()
1728 },
1729 |window, cx| {
1730 cx.new(|cx| {
1731 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
1732 })
1733 },
1734 )
1735 })
1736 .context("Failed to open diff review test window")?;
1737
1738 cx.run_until_parked();
1739
1740 // Create and add the ProjectDiff using the public deploy_at method
1741 workspace_window
1742 .update(cx, |workspace, window, cx| {
1743 ProjectDiff::deploy_at(workspace, None, window, cx);
1744 })
1745 .log_err();
1746
1747 // Wait for diff to render
1748 for _ in 0..5 {
1749 cx.advance_clock(Duration::from_millis(100));
1750 cx.run_until_parked();
1751 }
1752
1753 // Refresh window
1754 cx.update_window(workspace_window.into(), |_, window, _cx| {
1755 window.refresh();
1756 })?;
1757
1758 cx.run_until_parked();
1759
1760 // Capture Test 1: Diff with flag enabled
1761 let test1_result = run_visual_test(
1762 "diff_review_button_enabled",
1763 workspace_window.into(),
1764 cx,
1765 update_baseline,
1766 )?;
1767
1768 // Test 2: Diff view with feature flag disabled
1769 // Disable the feature flag
1770 cx.update(|cx| {
1771 cx.update_flags(false, vec![]);
1772 });
1773
1774 // Refresh window
1775 cx.update_window(workspace_window.into(), |_, window, _cx| {
1776 window.refresh();
1777 })?;
1778
1779 for _ in 0..3 {
1780 cx.advance_clock(Duration::from_millis(100));
1781 cx.run_until_parked();
1782 }
1783
1784 // Capture Test 2: Diff with flag disabled
1785 let test2_result = run_visual_test(
1786 "diff_review_button_disabled",
1787 workspace_window.into(),
1788 cx,
1789 update_baseline,
1790 )?;
1791
1792 // Test 3: Regular editor with flag enabled (should NOT show button)
1793 // Re-enable the feature flag
1794 cx.update(|cx| {
1795 cx.update_flags(true, vec!["diff-review".to_string()]);
1796 });
1797
1798 // Create a new window with just a regular editor
1799 let regular_window: WindowHandle<Workspace> = cx
1800 .update(|cx| {
1801 cx.open_window(
1802 WindowOptions {
1803 window_bounds: Some(WindowBounds::Windowed(bounds)),
1804 focus: false,
1805 show: false,
1806 ..Default::default()
1807 },
1808 |window, cx| {
1809 cx.new(|cx| {
1810 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
1811 })
1812 },
1813 )
1814 })
1815 .context("Failed to open regular editor window")?;
1816
1817 cx.run_until_parked();
1818
1819 // Open a regular file (not a diff view)
1820 let open_file_task = regular_window
1821 .update(cx, |workspace, window, cx| {
1822 let worktree = workspace.project().read(cx).worktrees(cx).next();
1823 if let Some(worktree) = worktree {
1824 let worktree_id = worktree.read(cx).id();
1825 let rel_path: std::sync::Arc<util::rel_path::RelPath> =
1826 util::rel_path::rel_path("thread-view.tsx").into();
1827 let project_path: project::ProjectPath = (worktree_id, rel_path).into();
1828 Some(workspace.open_path(project_path, None, true, window, cx))
1829 } else {
1830 None
1831 }
1832 })
1833 .log_err()
1834 .flatten();
1835
1836 if let Some(task) = open_file_task {
1837 cx.background_executor.allow_parking();
1838 cx.foreground_executor.block_test(task).log_err();
1839 cx.background_executor.forbid_parking();
1840 }
1841
1842 // Wait for file to open
1843 for _ in 0..3 {
1844 cx.advance_clock(Duration::from_millis(100));
1845 cx.run_until_parked();
1846 }
1847
1848 // Refresh window
1849 cx.update_window(regular_window.into(), |_, window, _cx| {
1850 window.refresh();
1851 })?;
1852
1853 cx.run_until_parked();
1854
1855 // Capture Test 3: Regular editor with flag enabled (no button)
1856 let test3_result = run_visual_test(
1857 "diff_review_button_regular_editor",
1858 regular_window.into(),
1859 cx,
1860 update_baseline,
1861 )?;
1862
1863 // Test 4: Show the diff review overlay on the regular editor
1864 regular_window
1865 .update(cx, |workspace, window, cx| {
1866 // Get the first editor from the workspace
1867 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1868 if let Some(editor) = editors.into_iter().next() {
1869 editor.update(cx, |editor, cx| {
1870 editor.show_diff_review_overlay(DisplayRow(1)..DisplayRow(1), window, cx);
1871 });
1872 }
1873 })
1874 .log_err();
1875
1876 // Wait for overlay to render
1877 for _ in 0..3 {
1878 cx.advance_clock(Duration::from_millis(100));
1879 cx.run_until_parked();
1880 }
1881
1882 // Refresh window
1883 cx.update_window(regular_window.into(), |_, window, _cx| {
1884 window.refresh();
1885 })?;
1886
1887 cx.run_until_parked();
1888
1889 // Capture Test 4: Regular editor with overlay shown
1890 let test4_result = run_visual_test(
1891 "diff_review_overlay_shown",
1892 regular_window.into(),
1893 cx,
1894 update_baseline,
1895 )?;
1896
1897 // Test 5: Type text into the diff review prompt and submit it
1898 // First, get the prompt editor from the overlay and type some text
1899 regular_window
1900 .update(cx, |workspace, window, cx| {
1901 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1902 if let Some(editor) = editors.into_iter().next() {
1903 editor.update(cx, |editor, cx| {
1904 // Get the prompt editor from the overlay and insert text
1905 if let Some(prompt_editor) = editor.diff_review_prompt_editor().cloned() {
1906 prompt_editor.update(cx, |prompt_editor: &mut editor::Editor, cx| {
1907 prompt_editor.insert(
1908 "This change needs better error handling",
1909 window,
1910 cx,
1911 );
1912 });
1913 }
1914 });
1915 }
1916 })
1917 .log_err();
1918
1919 // Wait for text to be inserted
1920 for _ in 0..3 {
1921 cx.advance_clock(Duration::from_millis(100));
1922 cx.run_until_parked();
1923 }
1924
1925 // Refresh window
1926 cx.update_window(regular_window.into(), |_, window, _cx| {
1927 window.refresh();
1928 })?;
1929
1930 cx.run_until_parked();
1931
1932 // Capture Test 5: Diff review overlay with typed text
1933 let test5_result = run_visual_test(
1934 "diff_review_overlay_with_text",
1935 regular_window.into(),
1936 cx,
1937 update_baseline,
1938 )?;
1939
1940 // Test 6: Submit a comment to store it locally
1941 regular_window
1942 .update(cx, |workspace, window, cx| {
1943 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1944 if let Some(editor) = editors.into_iter().next() {
1945 editor.update(cx, |editor, cx| {
1946 // Submit the comment that was typed in test 5
1947 editor.submit_diff_review_comment(window, cx);
1948 });
1949 }
1950 })
1951 .log_err();
1952
1953 // Wait for comment to be stored
1954 for _ in 0..3 {
1955 cx.advance_clock(Duration::from_millis(100));
1956 cx.run_until_parked();
1957 }
1958
1959 // Refresh window
1960 cx.update_window(regular_window.into(), |_, window, _cx| {
1961 window.refresh();
1962 })?;
1963
1964 cx.run_until_parked();
1965
1966 // Capture Test 6: Overlay with one stored comment
1967 let test6_result = run_visual_test(
1968 "diff_review_one_comment",
1969 regular_window.into(),
1970 cx,
1971 update_baseline,
1972 )?;
1973
1974 // Test 7: Add more comments to show multiple comments expanded
1975 regular_window
1976 .update(cx, |workspace, window, cx| {
1977 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
1978 if let Some(editor) = editors.into_iter().next() {
1979 editor.update(cx, |editor, cx| {
1980 // Add second comment
1981 if let Some(prompt_editor) = editor.diff_review_prompt_editor().cloned() {
1982 prompt_editor.update(cx, |pe, cx| {
1983 pe.insert("Second comment about imports", window, cx);
1984 });
1985 }
1986 editor.submit_diff_review_comment(window, cx);
1987
1988 // Add third comment
1989 if let Some(prompt_editor) = editor.diff_review_prompt_editor().cloned() {
1990 prompt_editor.update(cx, |pe, cx| {
1991 pe.insert("Third comment about naming conventions", window, cx);
1992 });
1993 }
1994 editor.submit_diff_review_comment(window, cx);
1995 });
1996 }
1997 })
1998 .log_err();
1999
2000 // Wait for comments to be stored
2001 for _ in 0..3 {
2002 cx.advance_clock(Duration::from_millis(100));
2003 cx.run_until_parked();
2004 }
2005
2006 // Refresh window
2007 cx.update_window(regular_window.into(), |_, window, _cx| {
2008 window.refresh();
2009 })?;
2010
2011 cx.run_until_parked();
2012
2013 // Capture Test 7: Overlay with multiple comments expanded
2014 let test7_result = run_visual_test(
2015 "diff_review_multiple_comments_expanded",
2016 regular_window.into(),
2017 cx,
2018 update_baseline,
2019 )?;
2020
2021 // Test 8: Collapse the comments section
2022 regular_window
2023 .update(cx, |workspace, _window, cx| {
2024 let editors: Vec<_> = workspace.items_of_type::<editor::Editor>(cx).collect();
2025 if let Some(editor) = editors.into_iter().next() {
2026 editor.update(cx, |editor, cx| {
2027 // Toggle collapse using the public method
2028 editor.set_diff_review_comments_expanded(false, cx);
2029 });
2030 }
2031 })
2032 .log_err();
2033
2034 // Wait for UI to update
2035 for _ in 0..3 {
2036 cx.advance_clock(Duration::from_millis(100));
2037 cx.run_until_parked();
2038 }
2039
2040 // Refresh window
2041 cx.update_window(regular_window.into(), |_, window, _cx| {
2042 window.refresh();
2043 })?;
2044
2045 cx.run_until_parked();
2046
2047 // Capture Test 8: Comments collapsed
2048 let test8_result = run_visual_test(
2049 "diff_review_comments_collapsed",
2050 regular_window.into(),
2051 cx,
2052 update_baseline,
2053 )?;
2054
2055 // Clean up: remove worktrees to stop background scanning
2056 workspace_window
2057 .update(cx, |workspace, _window, cx| {
2058 let project = workspace.project().clone();
2059 project.update(cx, |project, cx| {
2060 let worktree_ids: Vec<_> =
2061 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
2062 for id in worktree_ids {
2063 project.remove_worktree(id, cx);
2064 }
2065 });
2066 })
2067 .log_err();
2068
2069 cx.run_until_parked();
2070
2071 // Close windows
2072 cx.update_window(workspace_window.into(), |_, window, _cx| {
2073 window.remove_window();
2074 })
2075 .log_err();
2076 cx.update_window(regular_window.into(), |_, window, _cx| {
2077 window.remove_window();
2078 })
2079 .log_err();
2080
2081 cx.run_until_parked();
2082
2083 // Give background tasks time to finish
2084 for _ in 0..15 {
2085 cx.advance_clock(Duration::from_millis(100));
2086 cx.run_until_parked();
2087 }
2088
2089 // Return combined result
2090 let all_results = [
2091 &test1_result,
2092 &test2_result,
2093 &test3_result,
2094 &test4_result,
2095 &test5_result,
2096 &test6_result,
2097 &test7_result,
2098 &test8_result,
2099 ];
2100
2101 // Combine results: if any test updated a baseline, return BaselineUpdated;
2102 // otherwise return Passed. The exhaustive match ensures the compiler
2103 // verifies we handle all TestResult variants.
2104 let result = all_results
2105 .iter()
2106 .fold(TestResult::Passed, |acc, r| match r {
2107 TestResult::Passed => acc,
2108 TestResult::BaselineUpdated(p) => TestResult::BaselineUpdated(p.clone()),
2109 });
2110 Ok(result)
2111}
2112
2113/// Omega's own rendered proofs. `OMEGA-DELTA-0034`, `OMEGA-DELTA-0035`,
2114/// omega#76, omega#77 and omega#78.
2115///
2116/// # Why this exists at all
2117///
2118/// omega#76, #77 and #78 each landed with a source-level suite and no rendered
2119/// evidence, and each said so plainly. Three lanes in a row reported "no check
2120/// here looks at a rendered pixel". That gap is what this function closes: it
2121/// draws the real widget tree through Metal and writes the frame to a PNG, so
2122/// a claim about what the user sees is answered by a picture rather than by a
2123/// `contains` over a source file.
2124///
2125/// # Why it is in-process
2126///
2127/// The obvious alternative — drive the packaged app and screenshot the window
2128/// — has a hazard that has already bitten this workspace: macOS routes
2129/// synthesized keystrokes to the **frontmost application**, not to the process
2130/// named on the command line, so a harness can type into somebody else's
2131/// window and record the reply as the app's. `VisualTestAppContext` dispatches
2132/// keystrokes into this process's own GPUI window and captures that window's
2133/// texture directly, so there is no frontmost app to get wrong and no other
2134/// window that can answer.
2135///
2136/// # The threshold, stated
2137///
2138/// Comparison is `MATCH_THRESHOLD` (0.99): at least 99% of pixels must match
2139/// the committed baseline. Exact equality is not usable even here — font
2140/// rasterisation and theme colour rounding differ by a pixel or two between
2141/// machines — and a threshold nobody states is worse than a loose one.
2142/// Baselines were generated on Apple Silicon with the Metal renderer; this is
2143/// a local gate, and a materially different GPU may need `UPDATE_BASELINE=1`
2144/// and a human looking at the result before trusting it.
2145#[cfg(all(target_os = "macos", feature = "visual-tests"))]
2146fn run_omega_agent_visual_tests(
2147 app_state: Arc<AppState>,
2148 cx: &mut VisualTestAppContext,
2149 update_baseline: bool,
2150) -> Result<TestResult> {
2151 // The window is torn down whatever happens. Without this, an early `Err`
2152 // leaves the panel's editor buffer alive, GPUI's leaked-handle check panics
2153 // at drop, and the panic *replaces* the error that actually explains the
2154 // failure — which is exactly how the first run of this test reported
2155 // "Visual tests panicked" and nothing else.
2156 let outcome = run_omega_agent_visual_tests_inner(app_state, cx, update_baseline);
2157 cx.run_until_parked();
2158 outcome
2159}
2160
2161#[cfg(all(target_os = "macos", feature = "visual-tests"))]
2162fn run_omega_agent_visual_tests_inner(
2163 app_state: Arc<AppState>,
2164 cx: &mut VisualTestAppContext,
2165 update_baseline: bool,
2166) -> Result<TestResult> {
2167 use agent_ui::AgentPanel;
2168
2169 // A project with **no worktree**. This is the whole point of the first two
2170 // captures: a window with nothing to restore is by definition a window with
2171 // no project, so this is the state a fresh install actually reaches.
2172 let project = cx.update(|cx| {
2173 project::Project::local(
2174 app_state.client.clone(),
2175 app_state.node_runtime.clone(),
2176 app_state.user_store.clone(),
2177 app_state.languages.clone(),
2178 app_state.fs.clone(),
2179 None,
2180 project::LocalProjectFlags {
2181 init_worktree_trust: false,
2182 ..Default::default()
2183 },
2184 cx,
2185 )
2186 });
2187
2188 let window_size = size(px(900.0), px(720.0));
2189 let bounds = Bounds {
2190 origin: point(px(0.0), px(0.0)),
2191 size: window_size,
2192 };
2193 let workspace_window: WindowHandle<Workspace> = cx
2194 .update(|cx| {
2195 cx.open_window(
2196 WindowOptions {
2197 window_bounds: Some(WindowBounds::Windowed(bounds)),
2198 focus: false,
2199 show: false,
2200 ..Default::default()
2201 },
2202 |window, cx| {
2203 cx.new(|cx| {
2204 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
2205 })
2206 },
2207 )
2208 })
2209 .context("Failed to open the Omega front door window")?;
2210
2211 cx.run_until_parked();
2212
2213 // The window really has no project. Asserted rather than assumed, because
2214 // every claim these captures make rests on it — a capture taken with a
2215 // worktree quietly present would prove the opposite of what it says.
2216 let visible_worktrees = workspace_window
2217 .update(cx, |workspace, _window, cx| {
2218 workspace.project().read(cx).visible_worktrees(cx).count()
2219 })
2220 .context("Failed to read the project's worktrees")?;
2221 anyhow::ensure!(
2222 visible_worktrees == 0,
2223 "the front door capture needs a projectless window; found {visible_worktrees} worktree(s)"
2224 );
2225
2226 let (weak_workspace, async_window_cx) = workspace_window
2227 .update(cx, |workspace, window, cx| {
2228 (workspace.weak_handle(), window.to_async(cx))
2229 })
2230 .context("Failed to get workspace handle")?;
2231
2232 cx.background_executor.allow_parking();
2233 let panel = cx
2234 .foreground_executor
2235 .block_test(AgentPanel::load(weak_workspace, async_window_cx))
2236 .context("Failed to load AgentPanel")?;
2237 cx.background_executor.forbid_parking();
2238
2239 workspace_window
2240 .update(cx, |workspace, window, cx| {
2241 workspace.add_panel(panel.clone(), window, cx);
2242 workspace.open_panel::<AgentPanel>(window, cx);
2243 })
2244 .context("Failed to add the agent panel")?;
2245
2246 cx.run_until_parked();
2247
2248 // The shipped front door, called the way `crates/zed/src/main.rs` calls it
2249 // on a window with nothing to restore. Not a hand-rolled approximation of
2250 // it: `open_front_door` is the entry `OMEGA-DELTA-0019` added, and driving
2251 // anything else here would photograph a path no user takes.
2252 workspace_window
2253 .update(cx, |_workspace, window, cx| {
2254 AgentPanel::open_front_door(window, cx);
2255 })
2256 .context("Failed to open the front door")?;
2257
2258 cx.run_until_parked();
2259
2260 // The panel must actually be the focused, visible dock surface. A capture
2261 // taken with the panel absent shows the launchpad and would read as a
2262 // perfectly plausible screenshot of something else entirely — which is what
2263 // the first run of this test produced, and how it was caught.
2264 let panel_is_open = workspace_window
2265 .update(cx, |workspace, _window, cx| {
2266 workspace
2267 .panel::<AgentPanel>(cx)
2268 .is_some_and(|panel| panel.read(cx).active_thread_id(cx).is_some())
2269 })
2270 .unwrap_or(false);
2271 anyhow::ensure!(
2272 panel_is_open,
2273 "the agent panel is not the open surface; the capture would show the \
2274 launchpad rather than the front door"
2275 );
2276
2277 // omega#76's exit, first half. Before this delta the panel answered a
2278 // projectless window with "Open Project / Clone Repository" and there was
2279 // nothing to type into.
2280 let thread_id = cx
2281 .read(|cx| panel.read(cx).active_thread_id(cx))
2282 .ok_or_else(|| {
2283 anyhow::anyhow!(
2284 "the front door produced no thread on a projectless window — \
2285 this is the omega#76 defect, and the capture below would show \
2286 the empty-project state"
2287 )
2288 })?;
2289
2290 let front_door = run_visual_test(
2291 "omega_front_door_no_project",
2292 workspace_window.into(),
2293 cx,
2294 update_baseline,
2295 )?;
2296
2297 // omega#76's exit, second half: **typing starts a real thread**. The
2298 // keystrokes go into this window through GPUI's own dispatch, so nothing
2299 // depends on which application macOS thinks is frontmost.
2300 cx.simulate_input(workspace_window.into(), "route this thread on purpose");
2301 cx.run_until_parked();
2302
2303 let typed = cx
2304 .read(|cx| {
2305 panel
2306 .read(cx)
2307 .active_thread_view_for_tests()
2308 .and_then(|conversation| conversation.read(cx).root_thread_view())
2309 .map(|view| view.read(cx).message_editor.read(cx).text(cx))
2310 })
2311 .unwrap_or_default();
2312 anyhow::ensure!(
2313 typed.contains("route this thread on purpose"),
2314 "typing on the front door did not reach the thread's composer; the \
2315 editor holds {typed:?}"
2316 );
2317
2318 let typing = run_visual_test(
2319 "omega_front_door_typing",
2320 workspace_window.into(),
2321 cx,
2322 update_baseline,
2323 )?;
2324
2325 // Zoom the panel for the disclosure captures. The executor line is long by
2326 // design — class, agent, model, run, and route reason — and a dock-width
2327 // capture truncates it with an ellipsis, which would make a picture of a
2328 // *truncated* line the evidence that the line renders.
2329 cx.update_window(workspace_window.into(), |_, window, cx| {
2330 panel.update(cx, |panel, cx| {
2331 use workspace::dock::Panel as _;
2332 panel.set_zoomed(true, window, cx);
2333 });
2334 })
2335 .log_err();
2336 cx.run_until_parked();
2337
2338 // omega#77: the executor line, on the native thread the front door just
2339 // built. This thread was routed by `OmegaAgentConnection`, so its line also
2340 // carries omega#78's route reason.
2341 let native_line = cx
2342 .read(|cx| omega_executor_line(&panel, cx))
2343 .ok_or_else(|| anyhow::anyhow!("the native thread has no executor disclosure"))?;
2344 anyhow::ensure!(
2345 native_line.starts_with("native_loop"),
2346 "the front door's own thread must disclose the native loop, not {native_line:?}"
2347 );
2348 anyhow::ensure!(
2349 native_line.contains("routed:"),
2350 "omega#78's wiring is not reaching the rendered line: {native_line:?}"
2351 );
2352 println!(" native executor line: {native_line}");
2353
2354 let native_disclosure = run_visual_test(
2355 "omega_executor_disclosure_native",
2356 workspace_window.into(),
2357 cx,
2358 update_baseline,
2359 )?;
2360
2361 // omega#78, first exit clause: **a pinned executor is always honoured.**
2362 // The native loop is the one executor this build has registered on the
2363 // router, so it is the one honourable pin — and pinning it is not a no-op:
2364 // the line changes from `routed: unpinned` to `routed: pinned`, which is
2365 // the difference between "nobody chose this" and "a person did".
2366 let session_for_pin = cx
2367 .read(|cx| {
2368 panel
2369 .read(cx)
2370 .active_thread_view_for_tests()
2371 .and_then(|conversation| conversation.read(cx).root_thread_view())
2372 .map(|view| view.read(cx).thread.read(cx).session_id().clone())
2373 })
2374 .ok_or_else(|| anyhow::anyhow!("no session to pin"))?;
2375 let router_for_pin = agent_ui::omega_router::active_router().ok_or_else(|| {
2376 anyhow::anyhow!(
2377 "no router was built for the native agent entry — omega#78's \
2378 wiring is the thing under test and it is absent"
2379 )
2380 })?;
2381 let honoured = router_for_pin.pin_session(
2382 &session_for_pin,
2383 omega_front_door::ExecutorPin::new(omega_front_door::ExecutorClass::NativeLoop),
2384 omega_front_door::PinGesture::ExecutorPinMenuItem,
2385 );
2386 anyhow::ensure!(
2387 honoured.reason == omega_front_door::RouteReason::PinHonored,
2388 "a pin to the fail-closed target must be honoured, not {:?}",
2389 honoured.reason
2390 );
2391 cx.update_window(workspace_window.into(), |_, window, _cx| {
2392 window.refresh();
2393 })?;
2394 cx.run_until_parked();
2395
2396 let honoured_line = cx
2397 .read(|cx| omega_executor_line(&panel, cx))
2398 .ok_or_else(|| anyhow::anyhow!("the pinned thread has no executor disclosure"))?;
2399 anyhow::ensure!(
2400 honoured_line.contains("routed: pinned"),
2401 "an honoured pin must say so on the thread's own line: {honoured_line:?}"
2402 );
2403 println!(" honoured-pin executor line: {honoured_line}");
2404
2405 let pin_honoured = run_visual_test(
2406 "omega_route_pin_honoured",
2407 workspace_window.into(),
2408 cx,
2409 update_baseline,
2410 )?;
2411
2412 // omega#78: a pin that cannot be honoured, rendered. No engine is running
2413 // under this harness, so an engine-lane pin falls closed to the native loop
2414 // and the line has to say so — a fallback the user cannot see is the defect
2415 // this packet exists to avoid.
2416 let session_id = cx
2417 .read(|cx| {
2418 panel
2419 .read(cx)
2420 .active_thread_view_for_tests()
2421 .and_then(|conversation| conversation.read(cx).root_thread_view())
2422 .map(|view| view.read(cx).thread.read(cx).session_id().clone())
2423 })
2424 .ok_or_else(|| anyhow::anyhow!("no session to pin"))?;
2425 let router = agent_ui::omega_router::active_router().ok_or_else(|| {
2426 anyhow::anyhow!(
2427 "no router was built for the native agent entry — omega#78's \
2428 wiring is the thing under test and it is absent"
2429 )
2430 })?;
2431 let decision = router.pin_session(
2432 &session_id,
2433 omega_front_door::ExecutorPin::new(omega_front_door::ExecutorClass::EngineLane),
2434 omega_front_door::PinGesture::ExecutorPinMenuItem,
2435 );
2436 anyhow::ensure!(
2437 decision.reason.is_fallback(),
2438 "an engine-lane pin with no engine must fall back, not report {:?}",
2439 decision.reason
2440 );
2441
2442 cx.update_window(workspace_window.into(), |_, window, _cx| {
2443 window.refresh();
2444 })?;
2445 cx.run_until_parked();
2446
2447 let pinned_line = cx
2448 .read(|cx| omega_executor_line(&panel, cx))
2449 .ok_or_else(|| anyhow::anyhow!("the pinned thread has no executor disclosure"))?;
2450 anyhow::ensure!(
2451 pinned_line.contains("fell back to the native loop"),
2452 "the unhonoured pin is not visible on the thread's line: {pinned_line:?}"
2453 );
2454 println!(" unhonoured-pin executor line: {pinned_line}");
2455
2456 let pin_fallback = run_visual_test(
2457 "omega_route_pin_not_honoured",
2458 workspace_window.into(),
2459 cx,
2460 update_baseline,
2461 )?;
2462
2463 // omega#77: the external-ACP kind, on a second thread in the same panel.
2464 let stub: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(StubAgentConnection::new()));
2465 cx.update_window(workspace_window.into(), |_, window, cx| {
2466 panel.update(cx, |panel, cx| {
2467 panel.open_external_thread_with_server(stub.clone(), window, cx);
2468 });
2469 })?;
2470 cx.run_until_parked();
2471
2472 let external_line = cx
2473 .read(|cx| omega_executor_line(&panel, cx))
2474 .ok_or_else(|| anyhow::anyhow!("the external thread has no executor disclosure"))?;
2475 anyhow::ensure!(
2476 external_line.starts_with("external_acp"),
2477 "a thread on a connection Omega did not build must not be disclosed as \
2478 first-party output: {external_line:?}"
2479 );
2480 println!(" external-acp executor line: {external_line}");
2481
2482 let external_disclosure = run_visual_test(
2483 "omega_executor_disclosure_external_acp",
2484 workspace_window.into(),
2485 cx,
2486 update_baseline,
2487 )?;
2488
2489 // omega#77: the engine-lane kind, on that same external thread.
2490 //
2491 // Deliberately *this* thread and not the front door's own. A lane run is a
2492 // `codex-acp` process the host bridge drives, so the honest record is "this
2493 // run delegated to this agent" — and it is a thread the router did not
2494 // route, so its `route` part is absent. Publishing a lane run onto a
2495 // *routed* thread instead would produce a record `is_coherent` rejects: a
2496 // route reason that says the router fell back to the native loop, on a line
2497 // that claims an engine lane. The coherence assertions below are what
2498 // caught that while this test was being written.
2499 let external_thread_id = cx
2500 .read(|cx| panel.read(cx).active_thread_id(cx))
2501 .ok_or_else(|| anyhow::anyhow!("the external thread has no id"))?;
2502 anyhow::ensure!(
2503 external_thread_id != thread_id,
2504 "the external thread must be a second thread, not the front door's own"
2505 );
2506 agent_ui::omega_host_bridge::publish_engine_lane_run_for_tests(
2507 external_thread_id,
2508 "operation.full-auto.visual".to_string(),
2509 );
2510 cx.update_window(workspace_window.into(), |_, window, _cx| {
2511 window.refresh();
2512 })?;
2513 cx.run_until_parked();
2514
2515 let lane_line = cx
2516 .read(|cx| omega_executor_line(&panel, cx))
2517 .ok_or_else(|| anyhow::anyhow!("the lane thread has no executor disclosure"))?;
2518 anyhow::ensure!(
2519 lane_line.starts_with("engine_lane") && lane_line.contains("operation.full-auto.visual"),
2520 "a thread bound to a lane run must disclose the run: {lane_line:?}"
2521 );
2522 println!(" engine-lane executor line: {lane_line}");
2523
2524 let lane_disclosure = run_visual_test(
2525 "omega_executor_disclosure_engine_lane",
2526 workspace_window.into(),
2527 cx,
2528 update_baseline,
2529 )?;
2530
2531 // omega#77's restart proof, first half: leave behind what a relaunch would
2532 // find, and then end. Everything below the captures writes; nothing below
2533 // them photographs, so no committed baseline can move because of it.
2534 //
2535 // A *second* external thread, because the two restart cases have to be
2536 // distinguishable. The correlation journal names the first thread and not
2537 // this one, so a cold process that confused them would disclose a lane run
2538 // on a thread that never had one — a failure a single-thread shape could
2539 // not see.
2540 let plain_stub: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(StubAgentConnection::new()));
2541 cx.update_window(workspace_window.into(), |_, window, cx| {
2542 panel.update(cx, |panel, cx| {
2543 panel.open_external_thread_with_server(plain_stub, window, cx);
2544 });
2545 })?;
2546 cx.run_until_parked();
2547
2548 let plain_thread_id = cx
2549 .read(|cx| panel.read(cx).active_thread_id(cx))
2550 .ok_or_else(|| anyhow::anyhow!("the second external thread has no id"))?;
2551 anyhow::ensure!(
2552 plain_thread_id != external_thread_id,
2553 "the restart phase needs two distinct threads; both ids are {plain_thread_id:?}"
2554 );
2555 let plain_record = cx
2556 .read(|cx| omega_executor_record(&panel, cx))
2557 .ok_or_else(|| anyhow::anyhow!("the second external thread has no disclosure"))?;
2558 anyhow::ensure!(
2559 plain_record.class == omega_front_door::ExecutorClass::ExternalAcp
2560 && plain_record.run_ref.is_none(),
2561 "the second external thread must carry no lane run before the restart: {plain_record:?}"
2562 );
2563
2564 // The durable half. `publish_engine_lane_run_for_tests` above wrote a
2565 // process-local index that a restart empties; this writes the correlation
2566 // journal itself, in the production schema, at the path the shipped startup
2567 // reads. The next process gets this and nothing else.
2568 agent_ui::omega_host_bridge::persist_engine_lane_run_for_tests(
2569 external_thread_id,
2570 "operation.full-auto.visual".to_string(),
2571 )?;
2572
2573 write_restart_handoff(&RestartHandoff {
2574 lane_thread: external_thread_id,
2575 lane_line,
2576 external_thread: plain_thread_id,
2577 external_line: plain_record.label(),
2578 agent_id: plain_record.agent_id,
2579 operation_ref: "operation.full-auto.visual".to_string(),
2580 })?;
2581
2582 cx.update_window(workspace_window.into(), |_, window, _cx| {
2583 window.remove_window();
2584 })
2585 .log_err();
2586 cx.run_until_parked();
2587
2588 // Six captures, and `run_visual_test` returns `Err` on a mismatch, so
2589 // reaching here means every one of them matched its baseline or wrote one.
2590 // The aggregate reports "baseline updated" if any capture wrote one, so an
2591 // `UPDATE_BASELINE=1` run is never reported as a pass.
2592 let results = [
2593 front_door,
2594 typing,
2595 native_disclosure,
2596 pin_honoured,
2597 pin_fallback,
2598 lane_disclosure,
2599 external_disclosure,
2600 ];
2601 for result in &results {
2602 if let TestResult::BaselineUpdated(path) = result {
2603 return Ok(TestResult::BaselineUpdated(path.clone()));
2604 }
2605 }
2606 Ok(TestResult::Passed)
2607}
2608
2609/// The executor line the agent panel's active thread would render.
2610///
2611/// Read through the same `ThreadView::executor_disclosure` the render calls, so
2612/// the assertion and the pixels cannot disagree about what the line says.
2613#[cfg(all(target_os = "macos", feature = "visual-tests"))]
2614fn omega_executor_line(panel: &Entity<agent_ui::AgentPanel>, cx: &App) -> Option<String> {
2615 Some(omega_executor_record(panel, cx)?.label())
2616}
2617
2618/// The executor *record* the agent panel's active thread would render from.
2619///
2620/// The record rather than the line, because omega#77's condition is that
2621/// disclosure is a typed record a label renders. A restart proof that compared
2622/// only rendered strings could not tell a restored record from a restored
2623/// string, which is the distinction the condition exists to protect.
2624#[cfg(all(target_os = "macos", feature = "visual-tests"))]
2625fn omega_executor_record(
2626 panel: &Entity<agent_ui::AgentPanel>,
2627 cx: &App,
2628) -> Option<omega_front_door::ExecutorDisclosure> {
2629 let disclosure = panel
2630 .read(cx)
2631 .active_thread_view_for_tests()?
2632 .read(cx)
2633 .root_thread_view()?
2634 .read(cx)
2635 .executor_disclosure(cx);
2636 // Every captured line is asserted coherent, not merely non-empty. A record
2637 // can render a perfectly readable line while claiming two contradictory
2638 // things — an engine lane and a route reason that says the router fell back
2639 // to the native loop is exactly that, and it is what an earlier draft of
2640 // this test was about to photograph and call proof.
2641 assert!(
2642 disclosure.is_coherent(),
2643 "the rendered executor record is incoherent: {disclosure:?}"
2644 );
2645 Some(disclosure)
2646}
2647
2648/// What the recording process leaves for the restarted one. omega#77.
2649///
2650/// The two thread ids and the agent id are the identifiers a real relaunch
2651/// reads back out of `sidebar_threads`; the runner sets `ZED_STATELESS=1`, which
2652/// deliberately keeps that table in memory, so the harness carries them in this
2653/// file instead. The lane run is **not** here: it goes through the production
2654/// correlation journal, at the production path, and the restarted process reads
2655/// it with the production loader. The lines are here so the restarted process
2656/// can assert it rendered *the same disclosure*, not merely a plausible one.
2657#[cfg(all(target_os = "macos", feature = "visual-tests"))]
2658#[derive(serde::Serialize, serde::Deserialize)]
2659#[serde(rename_all = "camelCase")]
2660struct RestartHandoff {
2661 lane_thread: agent_ui::ThreadId,
2662 lane_line: String,
2663 external_thread: agent_ui::ThreadId,
2664 external_line: String,
2665 agent_id: String,
2666 operation_ref: String,
2667}
2668
2669#[cfg(all(target_os = "macos", feature = "visual-tests"))]
2670fn restart_handoff_path() -> std::path::PathBuf {
2671 paths::data_dir().join("omega-visual-restart-handoff.json")
2672}
2673
2674#[cfg(all(target_os = "macos", feature = "visual-tests"))]
2675fn write_restart_handoff(handoff: &RestartHandoff) -> Result<()> {
2676 let path = restart_handoff_path();
2677 if let Some(parent) = path.parent() {
2678 std::fs::create_dir_all(parent)?;
2679 }
2680 std::fs::write(&path, serde_json::to_vec_pretty(handoff)?)
2681 .with_context(|| format!("writing the restart handoff to {}", path.display()))?;
2682 Ok(())
2683}
2684
2685#[cfg(all(target_os = "macos", feature = "visual-tests"))]
2686fn read_restart_handoff() -> Result<RestartHandoff> {
2687 let path = restart_handoff_path();
2688 let bytes = std::fs::read(&path).with_context(|| {
2689 format!(
2690 "no restart handoff at {} — the restart phase must run in the same \
2691 data directory as the phase that recorded it, and after it",
2692 path.display()
2693 )
2694 })?;
2695 Ok(serde_json::from_slice(&bytes)?)
2696}
2697
2698/// omega#77: the executor line on a `codex-acp`-class thread and on an
2699/// engine-lane thread, **after a restart**.
2700///
2701/// This runs in a second process. Every process-local thing the first process
2702/// built is gone — the lane index, the router's recorded routes, the panel, the
2703/// threads themselves — so anything this renders came from disk or was never
2704/// durable in the first place. That is the whole point: the previous lane
2705/// demonstrated a real relaunch for the native kind and could not reach these
2706/// two, and "the mechanism is shared, so I expect them to hold" is not the
2707/// standard the issue set.
2708#[cfg(all(target_os = "macos", feature = "visual-tests"))]
2709fn run_omega_restart_visual_tests(
2710 app_state: Arc<AppState>,
2711 cx: &mut VisualTestAppContext,
2712 update_baseline: bool,
2713) -> Result<TestResult> {
2714 let outcome = run_omega_restart_visual_tests_inner(app_state, cx, update_baseline);
2715 cx.run_until_parked();
2716 outcome
2717}
2718
2719#[cfg(all(target_os = "macos", feature = "visual-tests"))]
2720fn run_omega_restart_visual_tests_inner(
2721 app_state: Arc<AppState>,
2722 cx: &mut VisualTestAppContext,
2723 update_baseline: bool,
2724) -> Result<TestResult> {
2725 use agent_ui::AgentPanel;
2726
2727 let handoff = read_restart_handoff()?;
2728
2729 // A cold process knows nothing until it reads the journal. Asserted before
2730 // the read, because the read is what is under test: if a static had somehow
2731 // survived, or the harness had published the run itself, every assertion
2732 // below would pass for the wrong reason and the pictures would be of a
2733 // process that never restarted anything.
2734 anyhow::ensure!(
2735 agent_ui::omega_host_bridge::engine_lane_run(handoff.lane_thread).is_none(),
2736 "this process already knows a lane run for {:?} before reading the \
2737 journal, so it is not a cold start",
2738 handoff.lane_thread
2739 );
2740
2741 // The production restart edge — the same function `omega_effectd_host_handler`
2742 // calls at startup, not a copy of it.
2743 let restored = agent_ui::omega_host_bridge::reload_engine_lane_runs_from_disk()?;
2744 anyhow::ensure!(
2745 restored == 1,
2746 "the correlation journal named {restored} lane-bound thread(s); the \
2747 recording phase wrote exactly one"
2748 );
2749 let restored_run = agent_ui::omega_host_bridge::engine_lane_run(handoff.lane_thread)
2750 .ok_or_else(|| {
2751 anyhow::anyhow!(
2752 "the reloaded journal does not name {:?}, so nothing survived \
2753 the restart",
2754 handoff.lane_thread
2755 )
2756 })?;
2757 anyhow::ensure!(
2758 restored_run == handoff.operation_ref,
2759 "the reloaded run is {restored_run:?}, not {:?}",
2760 handoff.operation_ref
2761 );
2762 anyhow::ensure!(
2763 agent_ui::omega_host_bridge::engine_lane_run(handoff.external_thread).is_none(),
2764 "the journal named the plain external thread as a lane run; a restart \
2765 must not invent an engine lane for a thread the user started"
2766 );
2767
2768 let project = cx.update(|cx| {
2769 project::Project::local(
2770 app_state.client.clone(),
2771 app_state.node_runtime.clone(),
2772 app_state.user_store.clone(),
2773 app_state.languages.clone(),
2774 app_state.fs.clone(),
2775 None,
2776 project::LocalProjectFlags {
2777 init_worktree_trust: false,
2778 ..Default::default()
2779 },
2780 cx,
2781 )
2782 });
2783
2784 let window_size = size(px(900.0), px(720.0));
2785 let bounds = Bounds {
2786 origin: point(px(0.0), px(0.0)),
2787 size: window_size,
2788 };
2789 let workspace_window: WindowHandle<Workspace> = cx
2790 .update(|cx| {
2791 cx.open_window(
2792 WindowOptions {
2793 window_bounds: Some(WindowBounds::Windowed(bounds)),
2794 focus: false,
2795 show: false,
2796 ..Default::default()
2797 },
2798 |window, cx| {
2799 cx.new(|cx| {
2800 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
2801 })
2802 },
2803 )
2804 })
2805 .context("Failed to open the restarted window")?;
2806
2807 cx.run_until_parked();
2808
2809 let (weak_workspace, async_window_cx) = workspace_window
2810 .update(cx, |workspace, window, cx| {
2811 (workspace.weak_handle(), window.to_async(cx))
2812 })
2813 .context("Failed to get workspace handle")?;
2814
2815 cx.background_executor.allow_parking();
2816 let panel = cx
2817 .foreground_executor
2818 .block_test(AgentPanel::load(weak_workspace, async_window_cx))
2819 .context("Failed to load AgentPanel")?;
2820 cx.background_executor.forbid_parking();
2821
2822 workspace_window
2823 .update(cx, |workspace, window, cx| {
2824 workspace.add_panel(panel.clone(), window, cx);
2825 workspace.open_panel::<AgentPanel>(window, cx);
2826 })
2827 .context("Failed to add the agent panel")?;
2828
2829 cx.run_until_parked();
2830
2831 // Zoomed for the same reason the recording phase zooms: the line is long by
2832 // design, and a picture of a truncated line is not a picture of the line.
2833 cx.update_window(workspace_window.into(), |_, window, cx| {
2834 panel.update(cx, |panel, cx| {
2835 use workspace::dock::Panel as _;
2836 panel.set_zoomed(true, window, cx);
2837 });
2838 })
2839 .log_err();
2840 cx.run_until_parked();
2841
2842 // The `codex-acp`-class thread, reopened under the id and agent id the
2843 // previous process left behind — the two things `restore_new_draft` reads
2844 // out of the metadata store on a real relaunch.
2845 let agent_id = AgentId::new(handoff.agent_id.clone());
2846 let external_stub: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(
2847 StubAgentConnection::new().with_agent_id(agent_id.clone()),
2848 ));
2849 cx.update_window(workspace_window.into(), |_, window, cx| {
2850 panel.update(cx, |panel, cx| {
2851 panel.open_external_thread_with_server_under_id(
2852 external_stub,
2853 handoff.external_thread,
2854 window,
2855 cx,
2856 );
2857 });
2858 })?;
2859 cx.run_until_parked();
2860
2861 let reopened_external = cx
2862 .read(|cx| panel.read(cx).active_thread_id(cx))
2863 .ok_or_else(|| anyhow::anyhow!("the reopened external thread has no id"))?;
2864 anyhow::ensure!(
2865 reopened_external == handoff.external_thread,
2866 "the reopened thread is {reopened_external:?}, not the persisted \
2867 {:?} — a new thread wearing the same content proves nothing about a \
2868 restart",
2869 handoff.external_thread
2870 );
2871
2872 let external_line = cx
2873 .read(|cx| omega_executor_line(&panel, cx))
2874 .ok_or_else(|| anyhow::anyhow!("the reopened external thread has no disclosure"))?;
2875 anyhow::ensure!(
2876 external_line == handoff.external_line,
2877 "the restarted process discloses {external_line:?}, where the process \
2878 that owned this thread disclosed {:?}",
2879 handoff.external_line
2880 );
2881 anyhow::ensure!(
2882 external_line.starts_with("external_acp"),
2883 "a thread on a connection Omega did not build must not be disclosed as \
2884 first-party output after a restart either: {external_line:?}"
2885 );
2886 println!(" external-acp executor line after restart: {external_line}");
2887
2888 let external_after_restart = run_visual_test(
2889 "omega_executor_disclosure_external_acp_after_restart",
2890 workspace_window.into(),
2891 cx,
2892 update_baseline,
2893 )?;
2894
2895 // The engine-lane thread. Same reopen, different id — and this one the
2896 // journal on disk names, so the line has to carry the run.
2897 let lane_stub: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(
2898 StubAgentConnection::new().with_agent_id(agent_id),
2899 ));
2900 cx.update_window(workspace_window.into(), |_, window, cx| {
2901 panel.update(cx, |panel, cx| {
2902 panel.open_external_thread_with_server_under_id(
2903 lane_stub,
2904 handoff.lane_thread,
2905 window,
2906 cx,
2907 );
2908 });
2909 })?;
2910 cx.run_until_parked();
2911
2912 let reopened_lane = cx
2913 .read(|cx| panel.read(cx).active_thread_id(cx))
2914 .ok_or_else(|| anyhow::anyhow!("the reopened lane thread has no id"))?;
2915 anyhow::ensure!(
2916 reopened_lane == handoff.lane_thread,
2917 "the reopened lane thread is {reopened_lane:?}, not the persisted {:?}",
2918 handoff.lane_thread
2919 );
2920
2921 let lane_record = cx
2922 .read(|cx| omega_executor_record(&panel, cx))
2923 .ok_or_else(|| anyhow::anyhow!("the reopened lane thread has no disclosure"))?;
2924 anyhow::ensure!(
2925 lane_record.run_ref.as_deref() == Some(handoff.operation_ref.as_str()),
2926 "the restored record names run {:?}, not {:?}",
2927 lane_record.run_ref,
2928 handoff.operation_ref
2929 );
2930 let lane_line = lane_record.label();
2931 anyhow::ensure!(
2932 lane_line == handoff.lane_line,
2933 "the restarted process discloses {lane_line:?}, where the process that \
2934 owned this thread disclosed {:?}",
2935 handoff.lane_line
2936 );
2937 println!(" engine-lane executor line after restart: {lane_line}");
2938
2939 let lane_after_restart = run_visual_test(
2940 "omega_executor_disclosure_engine_lane_after_restart",
2941 workspace_window.into(),
2942 cx,
2943 update_baseline,
2944 )?;
2945
2946 cx.update_window(workspace_window.into(), |_, window, _cx| {
2947 window.remove_window();
2948 })
2949 .log_err();
2950 cx.run_until_parked();
2951
2952 for result in [&external_after_restart, &lane_after_restart] {
2953 if let TestResult::BaselineUpdated(path) = result {
2954 return Ok(TestResult::BaselineUpdated(path.clone()));
2955 }
2956 }
2957 Ok(TestResult::Passed)
2958}
2959
2960/// A stub AgentServer for visual testing that returns a pre-programmed connection.
2961#[derive(Clone)]
2962#[cfg(target_os = "macos")]
2963struct StubAgentServer {
2964 connection: StubAgentConnection,
2965}
2966
2967#[cfg(target_os = "macos")]
2968impl StubAgentServer {
2969 fn new(connection: StubAgentConnection) -> Self {
2970 Self { connection }
2971 }
2972}
2973
2974#[cfg(target_os = "macos")]
2975impl AgentServer for StubAgentServer {
2976 fn logo(&self) -> ui::IconName {
2977 ui::IconName::OmegaAssistant
2978 }
2979
2980 fn agent_id(&self) -> AgentId {
2981 "Visual Test Agent".into()
2982 }
2983
2984 fn connect(
2985 &self,
2986 _delegate: AgentServerDelegate,
2987 _project: Entity<Project>,
2988 _cx: &mut App,
2989 ) -> gpui::Task<gpui::Result<Rc<dyn AgentConnection>>> {
2990 gpui::Task::ready(Ok(Rc::new(self.connection.clone())))
2991 }
2992
2993 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
2994 self
2995 }
2996}
2997
2998/// A visual-test server that connects to one real, explicitly named Exo lane.
2999///
3000/// The server stores only the lane path so it remains `Send`, as
3001/// [`AgentServer`] requires. The connection itself stays on the GPUI thread.
3002#[derive(Clone)]
3003#[cfg(target_os = "macos")]
3004struct ExoVisualAgentServer {
3005 lane_path: PathBuf,
3006}
3007
3008#[cfg(target_os = "macos")]
3009impl AgentServer for ExoVisualAgentServer {
3010 fn logo(&self) -> ui::IconName {
3011 ui::IconName::OmegaAssistant
3012 }
3013
3014 fn agent_id(&self) -> AgentId {
3015 "Exo".into()
3016 }
3017
3018 fn connect(
3019 &self,
3020 _delegate: AgentServerDelegate,
3021 project: Entity<Project>,
3022 cx: &mut App,
3023 ) -> gpui::Task<gpui::Result<Rc<dyn AgentConnection>>> {
3024 let lane_path = self.lane_path.clone();
3025 let agent_server_store = project.read(cx).agent_server_store().downgrade();
3026 cx.spawn(async move |cx| {
3027 agent_ui::omega_exo_connection::connect_configured_lane(
3028 &lane_path,
3029 project,
3030 agent_server_store,
3031 cx,
3032 )
3033 .await?
3034 .ok_or_else(|| anyhow::anyhow!("the Exo visual lane file is not configured"))
3035 })
3036 }
3037
3038 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
3039 self
3040 }
3041}
3042
3043#[cfg(all(target_os = "macos", feature = "visual-tests"))]
3044fn run_omega_exo_visual_tests(
3045 app_state: Arc<AppState>,
3046 cx: &mut VisualTestAppContext,
3047 update_baseline: bool,
3048) -> Result<TestResult> {
3049 let lane_path = PathBuf::from(
3050 std::env::var("OMEGA_EXO_VISUAL_LANE_FILE")
3051 .context("set OMEGA_EXO_VISUAL_LANE_FILE to an isolated Exo lane file")?,
3052 );
3053 anyhow::ensure!(
3054 lane_path.is_file(),
3055 "the Exo visual lane does not exist: {}",
3056 lane_path.display()
3057 );
3058
3059 // Use independent native windows instead of resizing one hidden Metal
3060 // window. The macOS platform does not dispatch a bounds callback for that
3061 // test-only resize, so a second screenshot can silently retain the first
3062 // viewport. Two fresh windows prove both responsive branches directly.
3063 let wide = run_omega_exo_visual_capture(
3064 app_state.clone(),
3065 cx,
3066 lane_path.clone(),
3067 "omega_exo_workspace_wide",
3068 size(px(1320.), px(860.)),
3069 update_baseline,
3070 )?;
3071 let narrow = run_omega_exo_visual_capture(
3072 app_state,
3073 cx,
3074 lane_path,
3075 "omega_exo_workspace_narrow",
3076 size(px(720.), px(900.)),
3077 update_baseline,
3078 )?;
3079
3080 for result in [&wide, &narrow] {
3081 if let TestResult::BaselineUpdated(path) = result {
3082 return Ok(TestResult::BaselineUpdated(path.clone()));
3083 }
3084 }
3085 Ok(TestResult::Passed)
3086}
3087
3088#[cfg(all(target_os = "macos", feature = "visual-tests"))]
3089fn run_omega_exo_visual_capture(
3090 app_state: Arc<AppState>,
3091 cx: &mut VisualTestAppContext,
3092 lane_path: PathBuf,
3093 test_name: &'static str,
3094 window_size: gpui::Size<gpui::Pixels>,
3095 update_baseline: bool,
3096) -> Result<TestResult> {
3097 use agent_ui::AgentPanel;
3098
3099 let project_dir = tempfile::tempdir()?.keep().canonicalize()?;
3100 let project = cx.update(|cx| {
3101 Project::local(
3102 app_state.client.clone(),
3103 app_state.node_runtime.clone(),
3104 app_state.user_store.clone(),
3105 app_state.languages.clone(),
3106 app_state.fs.clone(),
3107 None,
3108 project::LocalProjectFlags {
3109 init_worktree_trust: false,
3110 ..Default::default()
3111 },
3112 cx,
3113 )
3114 });
3115 let add_worktree = project.update(cx, |project, cx| {
3116 project.find_or_create_worktree(&project_dir, true, cx)
3117 });
3118 cx.background_executor.allow_parking();
3119 cx.foreground_executor
3120 .block_test(add_worktree)
3121 .context("failed to add the Exo visual worktree")?;
3122 cx.background_executor.forbid_parking();
3123 cx.run_until_parked();
3124
3125 let bounds = Bounds {
3126 origin: point(px(-10_000.), px(-10_000.)),
3127 size: window_size,
3128 };
3129 let workspace_window: WindowHandle<Workspace> = cx
3130 .update(|cx| {
3131 cx.open_window(
3132 WindowOptions {
3133 window_bounds: Some(WindowBounds::Windowed(bounds)),
3134 focus: false,
3135 show: true,
3136 ..Default::default()
3137 },
3138 |window, cx| {
3139 cx.new(|cx| {
3140 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
3141 })
3142 },
3143 )
3144 })
3145 .context("failed to open the Exo workspace window")?;
3146 cx.run_until_parked();
3147
3148 let (weak_workspace, async_window_cx) = workspace_window
3149 .update(cx, |workspace, window, cx| {
3150 (workspace.weak_handle(), window.to_async(cx))
3151 })
3152 .context("failed to get the Exo workspace handle")?;
3153 cx.background_executor.allow_parking();
3154 let panel = cx
3155 .foreground_executor
3156 .block_test(AgentPanel::load(weak_workspace, async_window_cx))
3157 .context("failed to load the Exo Agent panel")?;
3158 cx.background_executor.forbid_parking();
3159
3160 workspace_window
3161 .update(cx, |workspace, window, cx| {
3162 workspace.add_panel(panel.clone(), window, cx);
3163 workspace.open_panel::<AgentPanel>(window, cx);
3164 panel.update(cx, |panel, cx| {
3165 use workspace::dock::Panel as _;
3166 panel.set_zoomed(true, window, cx);
3167 });
3168 })
3169 .context("failed to open the Exo Agent panel")?;
3170 cx.run_until_parked();
3171
3172 // Keep capture failures inside this closure so the real ACP connection is
3173 // always removed below. Otherwise an early assertion or screenshot error
3174 // masks its own cause with GPUI's leaked ElicitationStore check.
3175 let capture = (|| -> Result<TestResult> {
3176 let server: Rc<dyn AgentServer> = Rc::new(ExoVisualAgentServer { lane_path });
3177 cx.update_window(workspace_window.into(), |_, window, cx| {
3178 panel.update(cx, |panel, cx| {
3179 panel.open_external_thread_with_server(server, window, cx);
3180 });
3181 })?;
3182
3183 // Connecting the child process and completing ACP initialization
3184 // require real I/O. Poll the visible state for at most five seconds;
3185 // one `run_until_parked` can return before stdio initialization wakes
3186 // the foreground executor.
3187 cx.background_executor.allow_parking();
3188 for _ in 0..100 {
3189 cx.run_until_parked();
3190 if cx.read(|cx| {
3191 panel
3192 .read(cx)
3193 .active_thread_view_for_tests()
3194 .is_some_and(|view| view.read(cx).active_thread().is_some())
3195 }) {
3196 break;
3197 }
3198 std::thread::sleep(Duration::from_millis(50));
3199 }
3200 cx.background_executor.forbid_parking();
3201
3202 let thread_view = cx
3203 .read(|cx| panel.read(cx).active_thread_view_for_tests().cloned())
3204 .ok_or_else(|| anyhow::anyhow!("the Exo workspace has no active thread"))?;
3205 let thread = cx
3206 .read(|cx| {
3207 thread_view
3208 .read(cx)
3209 .active_thread()
3210 .map(|active| active.read(cx).thread.clone())
3211 })
3212 .ok_or_else(|| anyhow::anyhow!("the Exo workspace thread is not available"))?;
3213
3214 let marker = "OMEGA-EXO-PANE-READY";
3215 let prompt =
3216 format!("Run one tool that prints OMEGA-EXO-TOOL, then reply with exactly {marker}.");
3217 let send = thread.update(cx, |thread, cx| thread.send(vec![prompt.into()], cx));
3218 cx.background_executor.allow_parking();
3219 let send_result = cx.foreground_executor.block_test(send);
3220 cx.background_executor.forbid_parking();
3221 send_result.context("the real Exo visual turn failed")?;
3222 cx.run_until_parked();
3223
3224 let (transcript, turn) = cx.read(|cx| {
3225 let thread = thread.read(cx);
3226 let connection = thread
3227 .connection()
3228 .clone()
3229 .downcast::<agent_ui::omega_exo_connection::ExoHarnessConnection>()
3230 .expect("the visual thread must retain its Exo connection");
3231 (thread.to_markdown(cx), connection.turn())
3232 });
3233 anyhow::ensure!(
3234 transcript.contains(marker),
3235 "the real Exo response did not reach the transcript:\n{transcript}"
3236 );
3237 anyhow::ensure!(
3238 transcript.contains("Tool Call: shell"),
3239 "the real Exo tool call did not reach the transcript:\n{transcript}"
3240 );
3241 anyhow::ensure!(
3242 transcript.contains("OMEGA-EXO-TOOL"),
3243 "the real Exo tool result did not reach the transcript:\n{transcript}"
3244 );
3245 anyhow::ensure!(
3246 turn.phase == agent_ui::omega_exo_connection::ExoTurnPhase::Completed,
3247 "the real Exo turn did not complete: {turn:?}"
3248 );
3249 anyhow::ensure!(
3250 turn.exo_session_id.is_some()
3251 && turn.exo_turn_id.is_some()
3252 && turn.latest_event_id.is_some(),
3253 "the real Exo turn did not return durable references: {turn:?}"
3254 );
3255
3256 let actual_size = cx.update_window(workspace_window.into(), |_, window, _cx| {
3257 window.viewport_size()
3258 })?;
3259 anyhow::ensure!(
3260 actual_size == window_size,
3261 "{test_name} opened at {actual_size:?}, expected {window_size:?}"
3262 );
3263
3264 run_visual_test(test_name, workspace_window.into(), cx, update_baseline)
3265 })();
3266
3267 // Drop every owner of the real ACP connection before the runner's leaked
3268 // entity check. The panel's connection store retains a successful custom
3269 // connection after its thread and window close. Replacing that cached
3270 // entry first lets the Exo child and its ElicitationStore terminate.
3271 panel.update(cx, |panel, cx| {
3272 panel.connection_store().update(cx, |store, cx| {
3273 store.restart_connection(
3274 Agent::Custom { id: "Exo".into() },
3275 Rc::new(StubAgentServer::new(StubAgentConnection::new())),
3276 cx,
3277 );
3278 });
3279 });
3280 cx.run_until_parked();
3281
3282 workspace_window
3283 .update(cx, |workspace, window, cx| {
3284 workspace.remove_panel(&panel, window, cx);
3285 let project = workspace.project().clone();
3286 project.update(cx, |project, cx| {
3287 let worktree_ids: Vec<_> = project
3288 .worktrees(cx)
3289 .map(|worktree| worktree.read(cx).id())
3290 .collect();
3291 for id in worktree_ids {
3292 project.remove_worktree(id, cx);
3293 }
3294 });
3295 })
3296 .log_err();
3297 cx.run_until_parked();
3298 drop(panel);
3299 drop(project);
3300 cx.update_window(workspace_window.into(), |_, window, _cx| {
3301 window.remove_window();
3302 })
3303 .log_err();
3304 cx.run_until_parked();
3305 for _ in 0..15 {
3306 cx.advance_clock(Duration::from_millis(100));
3307 cx.run_until_parked();
3308 }
3309
3310 capture
3311}
3312
3313#[cfg(all(target_os = "macos", feature = "visual-tests"))]
3314fn run_agent_thread_view_test(
3315 app_state: Arc<AppState>,
3316 cx: &mut VisualTestAppContext,
3317 update_baseline: bool,
3318) -> Result<TestResult> {
3319 use agent::{AgentTool, ToolInput};
3320 use agent_ui::AgentPanel;
3321
3322 // Create a temporary directory with the test image
3323 // Canonicalize to resolve symlinks (on macOS, /var -> /private/var)
3324 // Use keep() to prevent auto-cleanup - we'll clean up manually after stopping background tasks
3325 let temp_dir = tempfile::tempdir()?;
3326 let temp_path = temp_dir.keep();
3327 let canonical_temp = temp_path.canonicalize()?;
3328 let project_path = canonical_temp.join("project");
3329 std::fs::create_dir_all(&project_path)?;
3330 let image_path = project_path.join("test-image.png");
3331 std::fs::write(&image_path, EMBEDDED_TEST_IMAGE)?;
3332
3333 // Create a project with the test image
3334 let project = cx.update(|cx| {
3335 project::Project::local(
3336 app_state.client.clone(),
3337 app_state.node_runtime.clone(),
3338 app_state.user_store.clone(),
3339 app_state.languages.clone(),
3340 app_state.fs.clone(),
3341 None,
3342 project::LocalProjectFlags {
3343 init_worktree_trust: false,
3344 ..Default::default()
3345 },
3346 cx,
3347 )
3348 });
3349
3350 // Add the test directory as a worktree
3351 let add_worktree_task = project.update(cx, |project, cx| {
3352 project.find_or_create_worktree(&project_path, true, cx)
3353 });
3354
3355 cx.background_executor.allow_parking();
3356 let (worktree, _) = cx
3357 .foreground_executor
3358 .block_test(add_worktree_task)
3359 .context("Failed to add worktree")?;
3360 cx.background_executor.forbid_parking();
3361
3362 cx.run_until_parked();
3363
3364 let worktree_name = cx.read(|cx| worktree.read(cx).root_name_str().to_string());
3365
3366 // Create the necessary entities for the ReadFileTool
3367 let action_log = cx.update(|cx| cx.new(|_| action_log::ActionLog::new(project.clone())));
3368
3369 // Create the ReadFileTool
3370 let tool = Arc::new(agent::ReadFileTool::new(project.clone(), action_log, true));
3371
3372 // Create a test event stream to capture tool output
3373 let (event_stream, mut event_receiver) = agent::ToolCallEventStream::test();
3374
3375 // Run the real ReadFileTool to get the actual image content
3376 let input = agent::ReadFileToolInput {
3377 path: format!("{}/test-image.png", worktree_name),
3378 start_line: None,
3379 end_line: None,
3380 };
3381 let run_task = cx.update(|cx| {
3382 tool.clone()
3383 .run(ToolInput::resolved(input), event_stream, cx)
3384 });
3385
3386 cx.background_executor.allow_parking();
3387 let run_result = cx.foreground_executor.block_test(run_task);
3388 cx.background_executor.forbid_parking();
3389 run_result.map_err(|e| match e {
3390 language_model::LanguageModelToolResultContent::Text(text) => {
3391 anyhow::anyhow!("ReadFileTool failed: {text}")
3392 }
3393 other => anyhow::anyhow!("ReadFileTool failed: {other:?}"),
3394 })?;
3395
3396 cx.run_until_parked();
3397
3398 // Collect the events from the tool execution
3399 let mut tool_content: Vec<acp::ToolCallContent> = Vec::new();
3400 let mut tool_locations: Vec<acp::ToolCallLocation> = Vec::new();
3401
3402 while let Ok(event) = event_receiver.try_recv() {
3403 if let Ok(agent::ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(
3404 update,
3405 ))) = event
3406 {
3407 if let Some(content) = update.fields.content {
3408 tool_content.extend(content);
3409 }
3410 if let Some(locations) = update.fields.locations {
3411 tool_locations.extend(locations);
3412 }
3413 }
3414 }
3415
3416 if tool_content.is_empty() {
3417 return Err(anyhow::anyhow!("ReadFileTool did not produce any content"));
3418 }
3419
3420 // Create stub connection with the real tool output
3421 let connection = StubAgentConnection::new();
3422 connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
3423 acp::ToolCall::new(
3424 "read_file",
3425 format!("Read file `{}/test-image.png`", worktree_name),
3426 )
3427 .kind(acp::ToolKind::Read)
3428 .status(acp::ToolCallStatus::Completed)
3429 .locations(tool_locations)
3430 .content(tool_content),
3431 )]);
3432
3433 let stub_agent: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(connection));
3434
3435 // Create a window sized for the agent panel
3436 let window_size = size(px(500.0), px(900.0));
3437 let bounds = Bounds {
3438 origin: point(px(0.0), px(0.0)),
3439 size: window_size,
3440 };
3441
3442 let workspace_window: WindowHandle<Workspace> = cx
3443 .update(|cx| {
3444 cx.open_window(
3445 WindowOptions {
3446 window_bounds: Some(WindowBounds::Windowed(bounds)),
3447 focus: false,
3448 show: false,
3449 ..Default::default()
3450 },
3451 |window, cx| {
3452 cx.new(|cx| {
3453 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
3454 })
3455 },
3456 )
3457 })
3458 .context("Failed to open agent window")?;
3459
3460 cx.run_until_parked();
3461
3462 // Load the AgentPanel
3463 let (weak_workspace, async_window_cx) = workspace_window
3464 .update(cx, |workspace, window, cx| {
3465 (workspace.weak_handle(), window.to_async(cx))
3466 })
3467 .context("Failed to get workspace handle")?;
3468
3469 cx.background_executor.allow_parking();
3470 let panel = cx
3471 .foreground_executor
3472 .block_test(AgentPanel::load(weak_workspace, async_window_cx))
3473 .context("Failed to load AgentPanel")?;
3474 cx.background_executor.forbid_parking();
3475
3476 cx.update_window(workspace_window.into(), |_, _window, cx| {
3477 workspace_window
3478 .update(cx, |workspace, window, cx| {
3479 workspace.add_panel(panel.clone(), window, cx);
3480 workspace.open_panel::<AgentPanel>(window, cx);
3481 })
3482 .log_err();
3483 })?;
3484
3485 cx.run_until_parked();
3486
3487 // Inject the stub server and open the stub thread
3488 cx.update_window(workspace_window.into(), |_, window, cx| {
3489 panel.update(cx, |panel, cx| {
3490 panel.open_external_thread_with_server(stub_agent.clone(), window, cx);
3491 });
3492 })?;
3493
3494 cx.run_until_parked();
3495
3496 // Get the thread view and send a message
3497 let thread_view = cx
3498 .read(|cx| panel.read(cx).active_thread_view_for_tests().cloned())
3499 .ok_or_else(|| anyhow::anyhow!("No active thread view"))?;
3500
3501 let thread = cx
3502 .read(|cx| {
3503 thread_view
3504 .read(cx)
3505 .active_thread()
3506 .map(|active| active.read(cx).thread.clone())
3507 })
3508 .ok_or_else(|| anyhow::anyhow!("Thread not available"))?;
3509
3510 // Send the message to trigger the image response
3511 let send_future = thread.update(cx, |thread, cx| {
3512 thread.send(vec!["Show me the Omega logo".into()], cx)
3513 });
3514
3515 cx.background_executor.allow_parking();
3516 let send_result = cx.foreground_executor.block_test(send_future);
3517 cx.background_executor.forbid_parking();
3518 send_result.context("Failed to send message")?;
3519
3520 cx.run_until_parked();
3521
3522 // Get the tool call ID for expanding later
3523 let tool_call_id = cx
3524 .read(|cx| {
3525 thread.read(cx).entries().iter().find_map(|entry| {
3526 if let acp_thread::AgentThreadEntry::ToolCall(tool_call) = entry {
3527 Some(tool_call.id.clone())
3528 } else {
3529 None
3530 }
3531 })
3532 })
3533 .ok_or_else(|| anyhow::anyhow!("Expected a ToolCall entry in thread"))?;
3534
3535 cx.update_window(workspace_window.into(), |_, window, _cx| {
3536 window.refresh();
3537 })?;
3538
3539 cx.run_until_parked();
3540
3541 // Capture the COLLAPSED state
3542 let collapsed_result = run_visual_test(
3543 "agent_thread_with_image_collapsed",
3544 workspace_window.into(),
3545 cx,
3546 update_baseline,
3547 )?;
3548
3549 // Now expand the tool call so the image is visible
3550 thread_view.update(cx, |view, cx| {
3551 view.expand_tool_call(tool_call_id, cx);
3552 });
3553
3554 cx.run_until_parked();
3555
3556 cx.update_window(workspace_window.into(), |_, window, _cx| {
3557 window.refresh();
3558 })?;
3559
3560 cx.run_until_parked();
3561
3562 // Capture the EXPANDED state
3563 let expanded_result = run_visual_test(
3564 "agent_thread_with_image_expanded",
3565 workspace_window.into(),
3566 cx,
3567 update_baseline,
3568 )?;
3569
3570 // Remove the worktree from the project to stop background scanning tasks
3571 // This prevents "root path could not be canonicalized" errors when we clean up
3572 workspace_window
3573 .update(cx, |workspace, _window, cx| {
3574 let project = workspace.project().clone();
3575 project.update(cx, |project, cx| {
3576 let worktree_ids: Vec<_> =
3577 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
3578 for id in worktree_ids {
3579 project.remove_worktree(id, cx);
3580 }
3581 });
3582 })
3583 .log_err();
3584
3585 cx.run_until_parked();
3586
3587 // Close the window
3588 // Note: This may cause benign "editor::scroll window not found" errors from scrollbar
3589 // auto-hide timers that were scheduled before the window was closed. These errors
3590 // don't affect test results.
3591 cx.update_window(workspace_window.into(), |_, window, _cx| {
3592 window.remove_window();
3593 })
3594 .log_err();
3595
3596 // Run until all cleanup tasks complete
3597 cx.run_until_parked();
3598
3599 // Give background tasks time to finish, including scrollbar hide timers (1 second)
3600 for _ in 0..15 {
3601 cx.advance_clock(Duration::from_millis(100));
3602 cx.run_until_parked();
3603 }
3604
3605 // Note: We don't delete temp_path here because background worktree tasks may still
3606 // be running. The directory will be cleaned up when the process exits.
3607
3608 match (&collapsed_result, &expanded_result) {
3609 (TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
3610 (TestResult::BaselineUpdated(p), _) | (_, TestResult::BaselineUpdated(p)) => {
3611 Ok(TestResult::BaselineUpdated(p.clone()))
3612 }
3613 }
3614}
3615
3616/// Visual test for the Tool Permissions Settings UI page
3617///
3618/// Takes a screenshot showing the tool config page with matched patterns and verdict.
3619#[cfg(target_os = "macos")]
3620fn run_tool_permissions_visual_tests(
3621 app_state: Arc<AppState>,
3622 cx: &mut VisualTestAppContext,
3623 _update_baseline: bool,
3624) -> Result<TestResult> {
3625 use agent_settings::{AgentSettings, CompiledRegex, ToolPermissions, ToolRules};
3626 use collections::HashMap;
3627 use settings::ToolPermissionMode;
3628 use zed_actions::OpenSettingsAt;
3629
3630 // Set up tool permissions with "hi" as both always_deny and always_allow for terminal
3631 cx.update(|cx| {
3632 let mut tools = HashMap::default();
3633 tools.insert(
3634 Arc::from("terminal"),
3635 ToolRules {
3636 default: None,
3637 always_allow: vec![CompiledRegex::new("hi", false).unwrap()],
3638 always_deny: vec![CompiledRegex::new("hi", false).unwrap()],
3639 always_confirm: vec![],
3640 invalid_patterns: vec![],
3641 },
3642 );
3643 let mut settings = AgentSettings::get_global(cx).clone();
3644 settings.tool_permissions = ToolPermissions {
3645 default: ToolPermissionMode::Confirm,
3646 tools,
3647 };
3648 AgentSettings::override_global(settings, cx);
3649 });
3650
3651 // Create a minimal workspace to dispatch the settings action from
3652 let window_size = size(px(900.0), px(700.0));
3653 let bounds = Bounds {
3654 origin: point(px(0.0), px(0.0)),
3655 size: window_size,
3656 };
3657
3658 let project = cx.update(|cx| {
3659 project::Project::local(
3660 app_state.client.clone(),
3661 app_state.node_runtime.clone(),
3662 app_state.user_store.clone(),
3663 app_state.languages.clone(),
3664 app_state.fs.clone(),
3665 None,
3666 project::LocalProjectFlags {
3667 init_worktree_trust: false,
3668 ..Default::default()
3669 },
3670 cx,
3671 )
3672 });
3673
3674 let workspace_window: WindowHandle<MultiWorkspace> = cx
3675 .update(|cx| {
3676 cx.open_window(
3677 WindowOptions {
3678 window_bounds: Some(WindowBounds::Windowed(bounds)),
3679 focus: false,
3680 show: false,
3681 ..Default::default()
3682 },
3683 |window, cx| {
3684 let workspace = cx.new(|cx| {
3685 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
3686 });
3687 cx.new(|cx| MultiWorkspace::new(workspace, window, cx))
3688 },
3689 )
3690 })
3691 .context("Failed to open workspace window for settings test")?;
3692
3693 cx.run_until_parked();
3694
3695 // Dispatch the OpenSettingsAt action to open settings at the tool_permissions path
3696 workspace_window
3697 .update(cx, |_workspace, window, cx| {
3698 window.dispatch_action(
3699 Box::new(OpenSettingsAt {
3700 path: "agent.tool_permissions".to_string(),
3701 target: None,
3702 }),
3703 cx,
3704 );
3705 })
3706 .context("Failed to dispatch OpenSettingsAt action")?;
3707
3708 cx.run_until_parked();
3709
3710 // Give the settings window time to open and render
3711 for _ in 0..10 {
3712 cx.advance_clock(Duration::from_millis(50));
3713 cx.run_until_parked();
3714 }
3715
3716 // Find the settings window - it should be the newest window (last in the list)
3717 let all_windows = cx.update(|cx| cx.windows());
3718 let settings_window = all_windows.last().copied().context("No windows found")?;
3719
3720 let output_dir = std::env::var("VISUAL_TEST_OUTPUT_DIR")
3721 .unwrap_or_else(|_| "target/visual_tests".to_string());
3722 std::fs::create_dir_all(&output_dir).log_err();
3723
3724 // Navigate to the tool permissions sub-page using the public API
3725 let settings_window_handle = settings_window
3726 .downcast::<settings_ui::SettingsWindow>()
3727 .context("Failed to downcast to SettingsWindow")?;
3728
3729 settings_window_handle
3730 .update(cx, |settings_window, window, cx| {
3731 settings_window.navigate_to_sub_page("agent.tool_permissions", window, cx);
3732 })
3733 .context("Failed to navigate to tool permissions sub-page")?;
3734
3735 cx.run_until_parked();
3736
3737 // Give the sub-page time to render
3738 for _ in 0..10 {
3739 cx.advance_clock(Duration::from_millis(50));
3740 cx.run_until_parked();
3741 }
3742
3743 // Now navigate into a specific tool (Terminal) to show the tool config page
3744 settings_window_handle
3745 .update(cx, |settings_window, window, cx| {
3746 settings_window.push_dynamic_sub_page(
3747 "Terminal",
3748 "Configure Tool Rules",
3749 None,
3750 true,
3751 settings_ui::pages::render_terminal_tool_config,
3752 window,
3753 cx,
3754 );
3755 })
3756 .context("Failed to navigate to Terminal tool config")?;
3757
3758 cx.run_until_parked();
3759
3760 // Give the tool config page time to render
3761 for _ in 0..10 {
3762 cx.advance_clock(Duration::from_millis(50));
3763 cx.run_until_parked();
3764 }
3765
3766 // Refresh and redraw so the "Test Your Rules" input is present
3767 cx.update_window(settings_window, |_, window, cx| {
3768 window.draw(cx).clear(cx);
3769 })
3770 .log_err();
3771 cx.run_until_parked();
3772
3773 cx.update_window(settings_window, |_, window, _cx| {
3774 window.refresh();
3775 })
3776 .log_err();
3777 cx.run_until_parked();
3778
3779 // Focus the first tab stop in the window (the "Test Your Rules" editor
3780 // has tab_index(0) and tab_stop(true)) and type "hi" into it.
3781 cx.update_window(settings_window, |_, window, cx| {
3782 window.focus_next(cx);
3783 })
3784 .log_err();
3785 cx.run_until_parked();
3786
3787 cx.simulate_input(settings_window, "hi");
3788
3789 // Let the UI update with the matched patterns
3790 for _ in 0..5 {
3791 cx.advance_clock(Duration::from_millis(50));
3792 cx.run_until_parked();
3793 }
3794
3795 // Refresh and redraw
3796 cx.update_window(settings_window, |_, window, cx| {
3797 window.draw(cx).clear(cx);
3798 })
3799 .log_err();
3800 cx.run_until_parked();
3801
3802 cx.update_window(settings_window, |_, window, _cx| {
3803 window.refresh();
3804 })
3805 .log_err();
3806 cx.run_until_parked();
3807
3808 // Save screenshot: Tool config page with "hi" typed and matched patterns visible
3809 let tool_config_output_path =
3810 PathBuf::from(&output_dir).join("tool_permissions_test_rules.png");
3811
3812 if let Ok(screenshot) = cx.capture_screenshot(settings_window) {
3813 screenshot.save(&tool_config_output_path).log_err();
3814 println!(
3815 "Screenshot (test rules) saved to: {}",
3816 tool_config_output_path.display()
3817 );
3818 }
3819
3820 // Clean up - close the settings window
3821 cx.update_window(settings_window, |_, window, _cx| {
3822 window.remove_window();
3823 })
3824 .log_err();
3825
3826 // Close the workspace window
3827 cx.update_window(workspace_window.into(), |_, window, _cx| {
3828 window.remove_window();
3829 })
3830 .log_err();
3831
3832 cx.run_until_parked();
3833
3834 // Give background tasks time to finish
3835 for _ in 0..5 {
3836 cx.advance_clock(Duration::from_millis(100));
3837 cx.run_until_parked();
3838 }
3839
3840 // Return success - we're just capturing screenshots, not comparing baselines
3841 Ok(TestResult::Passed)
3842}
3843
3844#[cfg(target_os = "macos")]
3845fn run_multi_workspace_sidebar_visual_tests(
3846 app_state: Arc<AppState>,
3847 cx: &mut VisualTestAppContext,
3848 update_baseline: bool,
3849) -> Result<TestResult> {
3850 // Create temporary directories to act as worktrees for active workspaces
3851 let temp_dir = tempfile::tempdir()?;
3852 let temp_path = temp_dir.keep();
3853 let canonical_temp = temp_path.canonicalize()?;
3854
3855 let workspace1_dir = canonical_temp.join("private-test-remote");
3856 let workspace2_dir = canonical_temp.join("zed");
3857 std::fs::create_dir_all(&workspace1_dir)?;
3858 std::fs::create_dir_all(&workspace2_dir)?;
3859
3860 // Create both projects upfront so we can build both workspaces during
3861 // window creation, before the MultiWorkspace entity exists.
3862 // This avoids a re-entrant read panic that occurs when Workspace::new
3863 // tries to access the window root (MultiWorkspace) while it's being updated.
3864 let project1 = cx.update(|cx| {
3865 project::Project::local(
3866 app_state.client.clone(),
3867 app_state.node_runtime.clone(),
3868 app_state.user_store.clone(),
3869 app_state.languages.clone(),
3870 app_state.fs.clone(),
3871 None,
3872 project::LocalProjectFlags {
3873 init_worktree_trust: false,
3874 ..Default::default()
3875 },
3876 cx,
3877 )
3878 });
3879
3880 let project2 = cx.update(|cx| {
3881 project::Project::local(
3882 app_state.client.clone(),
3883 app_state.node_runtime.clone(),
3884 app_state.user_store.clone(),
3885 app_state.languages.clone(),
3886 app_state.fs.clone(),
3887 None,
3888 project::LocalProjectFlags {
3889 init_worktree_trust: false,
3890 ..Default::default()
3891 },
3892 cx,
3893 )
3894 });
3895
3896 let window_size = size(px(1280.0), px(800.0));
3897 let bounds = Bounds {
3898 origin: point(px(0.0), px(0.0)),
3899 size: window_size,
3900 };
3901
3902 // Open a MultiWorkspace window with both workspaces created at construction time
3903 let multi_workspace_window: WindowHandle<MultiWorkspace> = cx
3904 .update(|cx| {
3905 cx.open_window(
3906 WindowOptions {
3907 window_bounds: Some(WindowBounds::Windowed(bounds)),
3908 focus: false,
3909 show: false,
3910 ..Default::default()
3911 },
3912 |window, cx| {
3913 let workspace1 = cx.new(|cx| {
3914 Workspace::new(None, project1.clone(), app_state.clone(), window, cx)
3915 });
3916 let workspace2 = cx.new(|cx| {
3917 Workspace::new(None, project2.clone(), app_state.clone(), window, cx)
3918 });
3919 cx.new(|cx| {
3920 let mut multi_workspace = MultiWorkspace::new(workspace1, window, cx);
3921 multi_workspace.activate(workspace2, None, window, cx);
3922 multi_workspace
3923 })
3924 },
3925 )
3926 })
3927 .context("Failed to open MultiWorkspace window")?;
3928
3929 cx.run_until_parked();
3930
3931 // Add worktree to workspace 1 (index 0) so it shows as "private-test-remote"
3932 let add_worktree1_task = multi_workspace_window
3933 .update(cx, |multi_workspace, _window, cx| {
3934 let workspace1 = multi_workspace.workspaces().next().unwrap();
3935 let project = workspace1.read(cx).project().clone();
3936 project.update(cx, |project, cx| {
3937 project.find_or_create_worktree(&workspace1_dir, true, cx)
3938 })
3939 })
3940 .context("Failed to start adding worktree 1")?;
3941
3942 cx.background_executor.allow_parking();
3943 cx.foreground_executor
3944 .block_test(add_worktree1_task)
3945 .context("Failed to add worktree 1")?;
3946 cx.background_executor.forbid_parking();
3947
3948 cx.run_until_parked();
3949
3950 // Add worktree to workspace 2 (index 1) so it shows as "zed"
3951 let add_worktree2_task = multi_workspace_window
3952 .update(cx, |multi_workspace, _window, cx| {
3953 let workspace2 = multi_workspace.workspaces().nth(1).unwrap();
3954 let project = workspace2.read(cx).project().clone();
3955 project.update(cx, |project, cx| {
3956 project.find_or_create_worktree(&workspace2_dir, true, cx)
3957 })
3958 })
3959 .context("Failed to start adding worktree 2")?;
3960
3961 cx.background_executor.allow_parking();
3962 cx.foreground_executor
3963 .block_test(add_worktree2_task)
3964 .context("Failed to add worktree 2")?;
3965 cx.background_executor.forbid_parking();
3966
3967 cx.run_until_parked();
3968
3969 // Switch to workspace 1 so it's highlighted as active (index 0)
3970 multi_workspace_window
3971 .update(cx, |multi_workspace, window, cx| {
3972 let workspace = multi_workspace.workspaces().next().unwrap().clone();
3973 multi_workspace.activate(workspace, None, window, cx);
3974 })
3975 .context("Failed to activate workspace 1")?;
3976
3977 cx.run_until_parked();
3978
3979 // Create the sidebar outside the MultiWorkspace update to avoid a
3980 // re-entrant read panic (Sidebar::new reads the MultiWorkspace).
3981 let sidebar = cx
3982 .update_window(multi_workspace_window.into(), |root_view, window, cx| {
3983 let multi_workspace_handle: Entity<MultiWorkspace> = root_view.downcast().unwrap();
3984 cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx))
3985 })
3986 .context("Failed to create sidebar")?;
3987
3988 multi_workspace_window
3989 .update(cx, |multi_workspace, _window, cx| {
3990 multi_workspace.register_sidebar(sidebar.clone(), cx);
3991 })
3992 .context("Failed to register sidebar")?;
3993
3994 cx.run_until_parked();
3995
3996 // Save test threads to the ThreadStore for each workspace
3997 let save_tasks = multi_workspace_window
3998 .update(cx, |multi_workspace, _window, cx| {
3999 let thread_store = agent::ThreadStore::global(cx);
4000 let workspaces: Vec<_> = multi_workspace.workspaces().cloned().collect();
4001 let mut tasks = Vec::new();
4002
4003 for (index, workspace) in workspaces.iter().enumerate() {
4004 let workspace_ref = workspace.read(cx);
4005 let mut paths = Vec::new();
4006 for worktree in workspace_ref.worktrees(cx) {
4007 let worktree_ref = worktree.read(cx);
4008 if worktree_ref.is_visible() {
4009 paths.push(worktree_ref.abs_path().to_path_buf());
4010 }
4011 }
4012 let path_list = util::path_list::PathList::new(&paths);
4013
4014 let (session_id, title, updated_at) = match index {
4015 0 => (
4016 "visual-test-thread-0",
4017 "Refine thread view scrolling behavior",
4018 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 6, 15, 10, 30, 0)
4019 .unwrap(),
4020 ),
4021 1 => (
4022 "visual-test-thread-1",
4023 "Add line numbers option to FileEditBlock",
4024 chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 6, 15, 11, 0, 0)
4025 .unwrap(),
4026 ),
4027 _ => continue,
4028 };
4029
4030 let task = thread_store.update(cx, |store, cx| {
4031 store.save_thread(
4032 acp::SessionId::new(Arc::from(session_id)),
4033 agent::DbThread {
4034 title: title.to_string().into(),
4035 messages: Vec::new(),
4036 updated_at,
4037 detailed_summary: None,
4038 initial_project_snapshot: None,
4039 cumulative_token_usage: Default::default(),
4040 request_token_usage: Default::default(),
4041 model: None,
4042 profile: None,
4043 subagent_context: None,
4044 speed: None,
4045 thinking_enabled: false,
4046 thinking_effort: None,
4047 ui_scroll_position: None,
4048 draft_prompt: None,
4049 sandboxed_terminal_temp_dir: None,
4050 sandbox_grants: Default::default(),
4051 },
4052 path_list,
4053 cx,
4054 )
4055 });
4056 tasks.push(task);
4057 }
4058 tasks
4059 })
4060 .context("Failed to create test threads")?;
4061
4062 cx.background_executor.allow_parking();
4063 for task in save_tasks {
4064 cx.foreground_executor
4065 .block_test(task)
4066 .context("Failed to save test thread")?;
4067 }
4068 cx.background_executor.forbid_parking();
4069
4070 cx.run_until_parked();
4071
4072 // Open the sidebar
4073 multi_workspace_window
4074 .update(cx, |multi_workspace, window, cx| {
4075 multi_workspace.toggle_sidebar(window, cx);
4076 })
4077 .context("Failed to toggle sidebar")?;
4078
4079 // Let rendering settle
4080 for _ in 0..10 {
4081 cx.advance_clock(Duration::from_millis(100));
4082 cx.run_until_parked();
4083 }
4084
4085 // Refresh the window
4086 cx.update_window(multi_workspace_window.into(), |_, window, _cx| {
4087 window.refresh();
4088 })?;
4089
4090 cx.run_until_parked();
4091
4092 // Capture: sidebar open with active workspaces and recent projects
4093 let test_result = run_visual_test(
4094 "multi_workspace_sidebar_open",
4095 multi_workspace_window.into(),
4096 cx,
4097 update_baseline,
4098 )?;
4099
4100 // Clean up worktrees
4101 multi_workspace_window
4102 .update(cx, |multi_workspace, _window, cx| {
4103 for workspace in multi_workspace.workspaces() {
4104 let project = workspace.read(cx).project().clone();
4105 project.update(cx, |project, cx| {
4106 let worktree_ids: Vec<_> =
4107 project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
4108 for id in worktree_ids {
4109 project.remove_worktree(id, cx);
4110 }
4111 });
4112 }
4113 })
4114 .log_err();
4115
4116 cx.run_until_parked();
4117
4118 // Close the window
4119 cx.update_window(multi_workspace_window.into(), |_, window, _cx| {
4120 window.remove_window();
4121 })
4122 .log_err();
4123
4124 cx.run_until_parked();
4125
4126 for _ in 0..15 {
4127 cx.advance_clock(Duration::from_millis(100));
4128 cx.run_until_parked();
4129 }
4130
4131 Ok(test_result)
4132}
4133
4134#[cfg(target_os = "macos")]
4135struct ErrorWrappingTestView;
4136
4137#[cfg(target_os = "macos")]
4138impl gpui::Render for ErrorWrappingTestView {
4139 fn render(
4140 &mut self,
4141 _window: &mut gpui::Window,
4142 cx: &mut gpui::Context<Self>,
4143 ) -> impl gpui::IntoElement {
4144 use ui::{Button, Callout, IconName, LabelSize, Severity, prelude::*, v_flex};
4145
4146 let long_error_message = "Rate limit reached for gpt-5.2-codex in organization \
4147 org-QmYpir6k6dkULKU1XUSN6pal on tokens per min (TPM): Limit 500000, Used 442480, \
4148 Requested 59724. Please try again in 264ms. Visit \
4149 https://platform.openai.com/account/rate-limits to learn more.";
4150
4151 let retry_description = "Retrying. Next attempt in 4 seconds (Attempt 1 of 2).";
4152
4153 v_flex()
4154 .size_full()
4155 .bg(cx.theme().colors().background)
4156 .p_4()
4157 .gap_4()
4158 .child(
4159 Callout::new()
4160 .icon(IconName::Warning)
4161 .severity(Severity::Warning)
4162 .title(long_error_message)
4163 .description(retry_description),
4164 )
4165 .child(
4166 Callout::new()
4167 .severity(Severity::Error)
4168 .icon(IconName::XCircle)
4169 .title("An Error Happened")
4170 .description(long_error_message)
4171 .actions_slot(Button::new("dismiss", "Dismiss").label_size(LabelSize::Small)),
4172 )
4173 .child(
4174 Callout::new()
4175 .severity(Severity::Error)
4176 .icon(IconName::XCircle)
4177 .title(long_error_message)
4178 .actions_slot(Button::new("retry", "Retry").label_size(LabelSize::Small)),
4179 )
4180 }
4181}
4182
4183#[cfg(target_os = "macos")]
4184struct ThreadItemBranchNameTestView;
4185
4186#[cfg(target_os = "macos")]
4187impl gpui::Render for ThreadItemBranchNameTestView {
4188 fn render(
4189 &mut self,
4190 _window: &mut gpui::Window,
4191 cx: &mut gpui::Context<Self>,
4192 ) -> impl gpui::IntoElement {
4193 use ui::{
4194 IconName, Label, LabelSize, ThreadItem, ThreadItemWorktreeInfo, WorktreeKind,
4195 prelude::*,
4196 };
4197
4198 let section_label = |text: &str| {
4199 Label::new(text.to_string())
4200 .size(LabelSize::Small)
4201 .color(Color::Muted)
4202 };
4203
4204 let container = || {
4205 v_flex()
4206 .w_80()
4207 .border_1()
4208 .border_color(cx.theme().colors().border_variant)
4209 .bg(cx.theme().colors().panel_background)
4210 };
4211
4212 v_flex()
4213 .size_full()
4214 .bg(cx.theme().colors().background)
4215 .p_4()
4216 .gap_3()
4217 .child(
4218 Label::new("ThreadItem Branch Names")
4219 .size(LabelSize::Large)
4220 .color(Color::Default),
4221 )
4222 .child(section_label(
4223 "Linked worktree with branch (worktree / branch)",
4224 ))
4225 .child(
4226 container().child(
4227 ThreadItem::new("ti-linked-branch", "Fix scrolling behavior")
4228 .icon(IconName::AiClaude)
4229 .timestamp("5m")
4230 .worktrees(vec![ThreadItemWorktreeInfo {
4231 worktree_name: Some("jade-glen".into()),
4232 full_path: "/worktrees/jade-glen/zed".into(),
4233 highlight_positions: Vec::new(),
4234 kind: WorktreeKind::Linked,
4235 branch_name: Some("fix-scrolling".into()),
4236 }]),
4237 ),
4238 )
4239 .child(section_label(
4240 "Linked worktree without branch (detached HEAD)",
4241 ))
4242 .child(
4243 container().child(
4244 ThreadItem::new("ti-linked-no-branch", "Review worktree cleanup")
4245 .icon(IconName::AiClaude)
4246 .timestamp("1h")
4247 .worktrees(vec![ThreadItemWorktreeInfo {
4248 worktree_name: Some("focal-arrow".into()),
4249 full_path: "/worktrees/focal-arrow/zed".into(),
4250 highlight_positions: Vec::new(),
4251 kind: WorktreeKind::Linked,
4252 branch_name: None,
4253 }]),
4254 ),
4255 )
4256 .child(section_label("Main worktree with branch (nothing shown)"))
4257 .child(
4258 container().child(
4259 ThreadItem::new("ti-main-branch", "Request for Long Classic Poem")
4260 .icon(IconName::OmegaAgent)
4261 .timestamp("2d")
4262 .worktrees(vec![ThreadItemWorktreeInfo {
4263 worktree_name: Some("zed".into()),
4264 full_path: "/projects/zed".into(),
4265 highlight_positions: Vec::new(),
4266 kind: WorktreeKind::Main,
4267 branch_name: Some("main".into()),
4268 }]),
4269 ),
4270 )
4271 .child(section_label(
4272 "Main worktree without branch (nothing shown)",
4273 ))
4274 .child(
4275 container().child(
4276 ThreadItem::new("ti-main-no-branch", "Simple greeting thread")
4277 .icon(IconName::OmegaAgent)
4278 .timestamp("3d")
4279 .worktrees(vec![ThreadItemWorktreeInfo {
4280 worktree_name: Some("zed".into()),
4281 full_path: "/projects/zed".into(),
4282 highlight_positions: Vec::new(),
4283 kind: WorktreeKind::Main,
4284 branch_name: None,
4285 }]),
4286 ),
4287 )
4288 .child(section_label("Linked worktree where name matches branch"))
4289 .child(
4290 container().child(
4291 ThreadItem::new("ti-same-name", "Implement feature")
4292 .icon(IconName::AiClaude)
4293 .timestamp("6d")
4294 .worktrees(vec![ThreadItemWorktreeInfo {
4295 worktree_name: Some("stoic-reed".into()),
4296 full_path: "/worktrees/stoic-reed/zed".into(),
4297 highlight_positions: Vec::new(),
4298 kind: WorktreeKind::Linked,
4299 branch_name: Some("stoic-reed".into()),
4300 }]),
4301 ),
4302 )
4303 .child(section_label(
4304 "Manually opened linked worktree (main_path resolves to original repo)",
4305 ))
4306 .child(
4307 container().child(
4308 ThreadItem::new("ti-manual-linked", "Robust Git Worktree Rollback")
4309 .icon(IconName::OmegaAgent)
4310 .timestamp("40m")
4311 .worktrees(vec![ThreadItemWorktreeInfo {
4312 worktree_name: Some("focal-arrow".into()),
4313 full_path: "/worktrees/focal-arrow/zed".into(),
4314 highlight_positions: Vec::new(),
4315 kind: WorktreeKind::Linked,
4316 branch_name: Some("persist-worktree-3-wiring".into()),
4317 }]),
4318 ),
4319 )
4320 .child(section_label(
4321 "Linked worktree + branch + diff stats + timestamp",
4322 ))
4323 .child(
4324 container().child(
4325 ThreadItem::new("ti-linked-full", "Full metadata with diff stats")
4326 .icon(IconName::AiClaude)
4327 .timestamp("3w")
4328 .added(42)
4329 .removed(17)
4330 .worktrees(vec![ThreadItemWorktreeInfo {
4331 worktree_name: Some("jade-glen".into()),
4332 full_path: "/worktrees/jade-glen/zed".into(),
4333 highlight_positions: Vec::new(),
4334 kind: WorktreeKind::Linked,
4335 branch_name: Some("feature-branch".into()),
4336 }]),
4337 ),
4338 )
4339 .child(section_label("Long branch name truncation with diff stats"))
4340 .child(
4341 container().child(
4342 ThreadItem::new("ti-long-branch", "Overflow test with very long branch")
4343 .icon(IconName::AiClaude)
4344 .timestamp("2d")
4345 .added(108)
4346 .removed(53)
4347 .worktrees(vec![ThreadItemWorktreeInfo {
4348 worktree_name: Some("my-project".into()),
4349 full_path: "/worktrees/my-project/zed".into(),
4350 highlight_positions: Vec::new(),
4351 kind: WorktreeKind::Linked,
4352 branch_name: Some(
4353 "fix-very-long-branch-name-that-should-truncate".into(),
4354 ),
4355 }]),
4356 ),
4357 )
4358 .child(section_label(
4359 "Main worktree with branch + diff stats + timestamp (branch hidden)",
4360 ))
4361 .child(
4362 container().child(
4363 ThreadItem::new("ti-main-full", "Main worktree with everything")
4364 .icon(IconName::OmegaAgent)
4365 .timestamp("5m")
4366 .added(23)
4367 .removed(8)
4368 .worktrees(vec![ThreadItemWorktreeInfo {
4369 worktree_name: Some("zed".into()),
4370 full_path: "/projects/zed".into(),
4371 highlight_positions: Vec::new(),
4372 kind: WorktreeKind::Main,
4373 branch_name: Some("sidebar-show-branch-name".into()),
4374 }]),
4375 ),
4376 )
4377 }
4378}
4379
4380#[cfg(target_os = "macos")]
4381fn run_thread_item_branch_name_visual_tests(
4382 _app_state: Arc<AppState>,
4383 cx: &mut VisualTestAppContext,
4384 update_baseline: bool,
4385) -> Result<TestResult> {
4386 let window_size = size(px(400.0), px(1150.0));
4387 let bounds = Bounds {
4388 origin: point(px(0.0), px(0.0)),
4389 size: window_size,
4390 };
4391
4392 let window = cx
4393 .update(|cx| {
4394 cx.open_window(
4395 WindowOptions {
4396 window_bounds: Some(WindowBounds::Windowed(bounds)),
4397 focus: false,
4398 show: false,
4399 ..Default::default()
4400 },
4401 |_window, cx| cx.new(|_| ThreadItemBranchNameTestView),
4402 )
4403 })
4404 .context("Failed to open thread item branch name test window")?;
4405
4406 cx.run_until_parked();
4407
4408 cx.update_window(window.into(), |_, window, _cx| {
4409 window.refresh();
4410 })?;
4411
4412 cx.run_until_parked();
4413
4414 let test_result = run_visual_test(
4415 "thread_item_branch_names",
4416 window.into(),
4417 cx,
4418 update_baseline,
4419 )?;
4420
4421 cx.update_window(window.into(), |_, window, _cx| {
4422 window.remove_window();
4423 })
4424 .log_err();
4425
4426 cx.run_until_parked();
4427
4428 for _ in 0..15 {
4429 cx.advance_clock(Duration::from_millis(100));
4430 cx.run_until_parked();
4431 }
4432
4433 Ok(test_result)
4434}
4435
4436#[cfg(target_os = "macos")]
4437struct ThreadItemIconDecorationsTestView;
4438
4439#[cfg(target_os = "macos")]
4440impl gpui::Render for ThreadItemIconDecorationsTestView {
4441 fn render(
4442 &mut self,
4443 _window: &mut gpui::Window,
4444 cx: &mut gpui::Context<Self>,
4445 ) -> impl gpui::IntoElement {
4446 use ui::{IconName, Label, LabelSize, ThreadItem, prelude::*};
4447
4448 let section_label = |text: &str| {
4449 Label::new(text.to_string())
4450 .size(LabelSize::Small)
4451 .color(Color::Muted)
4452 };
4453
4454 let container = || {
4455 v_flex()
4456 .w_80()
4457 .border_1()
4458 .border_color(cx.theme().colors().border_variant)
4459 .bg(cx.theme().colors().panel_background)
4460 };
4461
4462 v_flex()
4463 .size_full()
4464 .bg(cx.theme().colors().background)
4465 .p_4()
4466 .gap_3()
4467 .child(
4468 Label::new("ThreadItem Icon Decorations")
4469 .size(LabelSize::Large)
4470 .color(Color::Default),
4471 )
4472 .child(section_label("No decoration (default idle)"))
4473 .child(
4474 container()
4475 .child(ThreadItem::new("ti-none", "Default idle thread").timestamp("1:00 AM")),
4476 )
4477 .child(section_label("Blue dot (notified)"))
4478 .child(
4479 container().child(
4480 ThreadItem::new("ti-done", "Generation completed successfully")
4481 .timestamp("1:05 AM")
4482 .notified(true),
4483 ),
4484 )
4485 .child(section_label("Yellow triangle (waiting for confirmation)"))
4486 .child(
4487 container().child(
4488 ThreadItem::new("ti-waiting", "Waiting for user confirmation")
4489 .timestamp("1:10 AM")
4490 .status(ui::AgentThreadStatus::WaitingForConfirmation),
4491 ),
4492 )
4493 .child(section_label("Red X (error)"))
4494 .child(
4495 container().child(
4496 ThreadItem::new("ti-error", "Failed to connect to server")
4497 .timestamp("1:15 AM")
4498 .status(ui::AgentThreadStatus::Error),
4499 ),
4500 )
4501 .child(section_label("Spinner (running)"))
4502 .child(
4503 container().child(
4504 ThreadItem::new("ti-running", "Generating response...")
4505 .icon(IconName::AiClaude)
4506 .timestamp("1:20 AM")
4507 .status(ui::AgentThreadStatus::Running),
4508 ),
4509 )
4510 .child(section_label(
4511 "Spinner + yellow triangle (waiting for confirmation)",
4512 ))
4513 .child(
4514 container().child(
4515 ThreadItem::new("ti-running-waiting", "Running but needs confirmation")
4516 .icon(IconName::AiClaude)
4517 .timestamp("1:25 AM")
4518 .status(ui::AgentThreadStatus::WaitingForConfirmation),
4519 ),
4520 )
4521 }
4522}
4523
4524#[cfg(target_os = "macos")]
4525fn run_thread_item_icon_decorations_visual_tests(
4526 _app_state: Arc<AppState>,
4527 cx: &mut VisualTestAppContext,
4528 update_baseline: bool,
4529) -> Result<TestResult> {
4530 let window_size = size(px(400.0), px(600.0));
4531 let bounds = Bounds {
4532 origin: point(px(0.0), px(0.0)),
4533 size: window_size,
4534 };
4535
4536 let window = cx
4537 .update(|cx| {
4538 cx.open_window(
4539 WindowOptions {
4540 window_bounds: Some(WindowBounds::Windowed(bounds)),
4541 focus: false,
4542 show: false,
4543 ..Default::default()
4544 },
4545 |_window, cx| cx.new(|_| ThreadItemIconDecorationsTestView),
4546 )
4547 })
4548 .context("Failed to open thread item icon decorations test window")?;
4549
4550 cx.run_until_parked();
4551
4552 cx.update_window(window.into(), |_, window, _cx| {
4553 window.refresh();
4554 })?;
4555
4556 cx.run_until_parked();
4557
4558 let test_result = run_visual_test(
4559 "thread_item_icon_decorations",
4560 window.into(),
4561 cx,
4562 update_baseline,
4563 )?;
4564
4565 cx.update_window(window.into(), |_, window, _cx| {
4566 window.remove_window();
4567 })
4568 .log_err();
4569
4570 cx.run_until_parked();
4571
4572 for _ in 0..15 {
4573 cx.advance_clock(Duration::from_millis(100));
4574 cx.run_until_parked();
4575 }
4576
4577 Ok(test_result)
4578}
4579
4580#[cfg(target_os = "macos")]
4581fn run_error_wrapping_visual_tests(
4582 _app_state: Arc<AppState>,
4583 cx: &mut VisualTestAppContext,
4584 update_baseline: bool,
4585) -> Result<TestResult> {
4586 let window_size = size(px(500.0), px(400.0));
4587 let bounds = Bounds {
4588 origin: point(px(0.0), px(0.0)),
4589 size: window_size,
4590 };
4591
4592 let window = cx
4593 .update(|cx| {
4594 cx.open_window(
4595 WindowOptions {
4596 window_bounds: Some(WindowBounds::Windowed(bounds)),
4597 focus: false,
4598 show: false,
4599 ..Default::default()
4600 },
4601 |_window, cx| cx.new(|_| ErrorWrappingTestView),
4602 )
4603 })
4604 .context("Failed to open error wrapping test window")?;
4605
4606 cx.run_until_parked();
4607
4608 cx.update_window(window.into(), |_, window, _cx| {
4609 window.refresh();
4610 })?;
4611
4612 cx.run_until_parked();
4613
4614 let test_result =
4615 run_visual_test("error_message_wrapping", window.into(), cx, update_baseline)?;
4616
4617 cx.update_window(window.into(), |_, window, _cx| {
4618 window.remove_window();
4619 })
4620 .log_err();
4621
4622 cx.run_until_parked();
4623
4624 for _ in 0..15 {
4625 cx.advance_clock(Duration::from_millis(100));
4626 cx.run_until_parked();
4627 }
4628
4629 Ok(test_result)
4630}
4631
4632#[cfg(target_os = "macos")]
4633/// Helper to create a project, add a worktree at the given path, and return the project.
4634fn create_project_with_worktree(
4635 worktree_dir: &Path,
4636 app_state: &Arc<AppState>,
4637 cx: &mut VisualTestAppContext,
4638) -> Result<Entity<Project>> {
4639 let project = cx.update(|cx| {
4640 project::Project::local(
4641 app_state.client.clone(),
4642 app_state.node_runtime.clone(),
4643 app_state.user_store.clone(),
4644 app_state.languages.clone(),
4645 app_state.fs.clone(),
4646 None,
4647 project::LocalProjectFlags {
4648 init_worktree_trust: false,
4649 ..Default::default()
4650 },
4651 cx,
4652 )
4653 });
4654
4655 let add_task = cx.update(|cx| {
4656 project.update(cx, |project, cx| {
4657 project.find_or_create_worktree(worktree_dir, true, cx)
4658 })
4659 });
4660
4661 cx.background_executor.allow_parking();
4662 cx.foreground_executor
4663 .block_test(add_task)
4664 .context("Failed to add worktree")?;
4665 cx.background_executor.forbid_parking();
4666
4667 cx.run_until_parked();
4668 Ok(project)
4669}
4670
4671#[cfg(target_os = "macos")]
4672fn open_sidebar_test_window(
4673 projects: Vec<Entity<Project>>,
4674 app_state: &Arc<AppState>,
4675 cx: &mut VisualTestAppContext,
4676) -> Result<WindowHandle<MultiWorkspace>> {
4677 anyhow::ensure!(!projects.is_empty(), "need at least one project");
4678
4679 let window_size = size(px(400.0), px(600.0));
4680 let bounds = Bounds {
4681 origin: point(px(0.0), px(0.0)),
4682 size: window_size,
4683 };
4684
4685 let mut projects_iter = projects.into_iter();
4686 let first_project = projects_iter
4687 .next()
4688 .ok_or_else(|| anyhow::anyhow!("need at least one project"))?;
4689 let remaining: Vec<_> = projects_iter.collect();
4690
4691 let multi_workspace_window: WindowHandle<MultiWorkspace> = cx
4692 .update(|cx| {
4693 cx.open_window(
4694 WindowOptions {
4695 window_bounds: Some(WindowBounds::Windowed(bounds)),
4696 focus: false,
4697 show: false,
4698 ..Default::default()
4699 },
4700 |window, cx| {
4701 let first_ws = cx.new(|cx| {
4702 Workspace::new(None, first_project.clone(), app_state.clone(), window, cx)
4703 });
4704 cx.new(|cx| {
4705 let mut mw = MultiWorkspace::new(first_ws, window, cx);
4706 for project in remaining {
4707 let ws = cx.new(|cx| {
4708 Workspace::new(None, project, app_state.clone(), window, cx)
4709 });
4710 mw.activate(ws, None, window, cx);
4711 }
4712 mw
4713 })
4714 },
4715 )
4716 })
4717 .context("Failed to open MultiWorkspace window")?;
4718
4719 cx.run_until_parked();
4720
4721 // Create the sidebar outside the MultiWorkspace update to avoid a
4722 // re-entrant read panic (Sidebar::new reads the MultiWorkspace).
4723 let sidebar = cx
4724 .update_window(multi_workspace_window.into(), |root_view, window, cx| {
4725 let mw_handle: Entity<MultiWorkspace> = root_view
4726 .downcast()
4727 .map_err(|_| anyhow::anyhow!("Failed to downcast root view to MultiWorkspace"))?;
4728 Ok::<_, anyhow::Error>(cx.new(|cx| sidebar::Sidebar::new(mw_handle, window, cx)))
4729 })
4730 .context("Failed to create sidebar")??;
4731
4732 multi_workspace_window
4733 .update(cx, |mw, _window, cx| {
4734 mw.register_sidebar(sidebar.clone(), cx);
4735 })
4736 .context("Failed to register sidebar")?;
4737
4738 cx.run_until_parked();
4739
4740 // Open the sidebar
4741 multi_workspace_window
4742 .update(cx, |mw, window, cx| {
4743 mw.toggle_sidebar(window, cx);
4744 })
4745 .context("Failed to toggle sidebar")?;
4746
4747 // Let rendering settle
4748 for _ in 0..10 {
4749 cx.advance_clock(Duration::from_millis(100));
4750 cx.run_until_parked();
4751 }
4752
4753 // Refresh the window
4754 cx.update_window(multi_workspace_window.into(), |_, window, _cx| {
4755 window.refresh();
4756 })?;
4757
4758 cx.run_until_parked();
4759
4760 Ok(multi_workspace_window)
4761}
4762
4763#[cfg(target_os = "macos")]
4764fn cleanup_sidebar_test_window(
4765 window: WindowHandle<MultiWorkspace>,
4766 cx: &mut VisualTestAppContext,
4767) -> Result<()> {
4768 window.update(cx, |mw, _window, cx| {
4769 for workspace in mw.workspaces() {
4770 let project = workspace.read(cx).project().clone();
4771 project.update(cx, |project, cx| {
4772 let ids: Vec<_> = project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
4773 for id in ids {
4774 project.remove_worktree(id, cx);
4775 }
4776 });
4777 }
4778 })?;
4779
4780 cx.run_until_parked();
4781
4782 cx.update_window(window.into(), |_, window, _cx| {
4783 window.remove_window();
4784 })?;
4785
4786 cx.run_until_parked();
4787
4788 for _ in 0..15 {
4789 cx.advance_clock(Duration::from_millis(100));
4790 cx.run_until_parked();
4791 }
4792
4793 Ok(())
4794}
4795
4796#[cfg(target_os = "macos")]
4797fn run_sidebar_duplicate_project_names_visual_tests(
4798 app_state: Arc<AppState>,
4799 cx: &mut VisualTestAppContext,
4800 update_baseline: bool,
4801) -> Result<TestResult> {
4802 let temp_dir = tempfile::tempdir()?;
4803 let temp_path = temp_dir.keep();
4804 let canonical_temp = temp_path.canonicalize()?;
4805
4806 // Create directory structure where every leaf directory is named "zed" but
4807 // lives at a distinct path. This lets us test that the sidebar correctly
4808 // disambiguates projects whose names would otherwise collide.
4809 //
4810 // code/zed/ — project1 (single worktree)
4811 // code/foo/zed/ — project2 (single worktree)
4812 // code/bar/zed/ — project3, first worktree
4813 // code/baz/zed/ — project3, second worktree
4814 //
4815 // No two projects share a worktree path, so ProjectGroupBuilder will
4816 // place each in its own group.
4817 let code_zed = canonical_temp.join("code").join("zed");
4818 let foo_zed = canonical_temp.join("code").join("foo").join("zed");
4819 let bar_zed = canonical_temp.join("code").join("bar").join("zed");
4820 let baz_zed = canonical_temp.join("code").join("baz").join("zed");
4821 std::fs::create_dir_all(&code_zed)?;
4822 std::fs::create_dir_all(&foo_zed)?;
4823 std::fs::create_dir_all(&bar_zed)?;
4824 std::fs::create_dir_all(&baz_zed)?;
4825
4826 cx.update(|cx| {
4827 cx.update_flags(true, vec!["agent-v2".to_string()]);
4828 });
4829
4830 let mut has_baseline_update = None;
4831
4832 // Two single-worktree projects whose leaf name is "zed"
4833 {
4834 let project1 = create_project_with_worktree(&code_zed, &app_state, cx)?;
4835 let project2 = create_project_with_worktree(&foo_zed, &app_state, cx)?;
4836
4837 let window = open_sidebar_test_window(vec![project1, project2], &app_state, cx)?;
4838
4839 let result = run_visual_test(
4840 "sidebar_two_projects_same_leaf_name",
4841 window.into(),
4842 cx,
4843 update_baseline,
4844 );
4845
4846 cleanup_sidebar_test_window(window, cx)?;
4847 match result? {
4848 TestResult::Passed => {}
4849 TestResult::BaselineUpdated(path) => {
4850 has_baseline_update = Some(path);
4851 }
4852 }
4853 }
4854
4855 // Three projects, third has two worktrees (all leaf names "zed")
4856 //
4857 // project1: code/zed
4858 // project2: code/foo/zed
4859 // project3: code/bar/zed + code/baz/zed
4860 //
4861 // Each project has a unique set of worktree paths, so they form
4862 // separate groups. The sidebar must disambiguate all three.
4863 {
4864 let project1 = create_project_with_worktree(&code_zed, &app_state, cx)?;
4865 let project2 = create_project_with_worktree(&foo_zed, &app_state, cx)?;
4866
4867 let project3 = create_project_with_worktree(&bar_zed, &app_state, cx)?;
4868 let add_second_worktree = cx.update(|cx| {
4869 project3.update(cx, |project, cx| {
4870 project.find_or_create_worktree(&baz_zed, true, cx)
4871 })
4872 });
4873 cx.background_executor.allow_parking();
4874 cx.foreground_executor
4875 .block_test(add_second_worktree)
4876 .context("Failed to add second worktree to project 3")?;
4877 cx.background_executor.forbid_parking();
4878 cx.run_until_parked();
4879
4880 let window = open_sidebar_test_window(vec![project1, project2, project3], &app_state, cx)?;
4881
4882 let result = run_visual_test(
4883 "sidebar_three_projects_with_multi_worktree",
4884 window.into(),
4885 cx,
4886 update_baseline,
4887 );
4888
4889 cleanup_sidebar_test_window(window, cx)?;
4890 match result? {
4891 TestResult::Passed => {}
4892 TestResult::BaselineUpdated(path) => {
4893 has_baseline_update = Some(path);
4894 }
4895 }
4896 }
4897
4898 if let Some(path) = has_baseline_update {
4899 Ok(TestResult::BaselineUpdated(path))
4900 } else {
4901 Ok(TestResult::Passed)
4902 }
4903}
4904
4905/// Visual test for harness maintenance on the External Agents settings page.
4906/// omega#81, `OMEGA-DELTA-0033`.
4907///
4908/// The gap omega#81 stayed open for was that every maintenance decision existed
4909/// and nothing rendered one: a refusal reached the owner only as agent-launch
4910/// error text, and there was no control to take or remove a pin. So the proof
4911/// that gap is closed has to be a picture of the row.
4912///
4913/// Nothing here is synthetic state handed to a widget. Two registry agents are
4914/// registered, their installed trees are written to the directories the launch
4915/// path measures, one is pinned to a different version through the same
4916/// `pin_installed_harness` the button calls, and the page is then opened and
4917/// photographed. What the screenshot shows is what the enforcement path
4918/// decided.
4919#[cfg(target_os = "macos")]
4920fn run_external_agent_maintenance_visual_tests(
4921 app_state: Arc<AppState>,
4922 cx: &mut VisualTestAppContext,
4923 update_baseline: bool,
4924) -> Result<TestResult> {
4925 use ::collections::HashMap;
4926 use gpui::UpdateGlobal as _;
4927 use project::agent_registry_store::{
4928 AgentRegistryStore, RegistryAgent, RegistryAgentMetadata, RegistryBinaryAgent,
4929 RegistryTargetConfig,
4930 };
4931 use settings::SettingsStore;
4932
4933 const HEALTHY: &str = "omega-visual-harness";
4934 const PINNED: &str = "omega-visual-pinned-harness";
4935
4936 let platform_key = format!(
4937 "{}-{}",
4938 if cfg!(target_os = "macos") {
4939 "darwin"
4940 } else {
4941 "linux"
4942 },
4943 if cfg!(target_arch = "aarch64") {
4944 "aarch64"
4945 } else {
4946 "x86_64"
4947 }
4948 );
4949
4950 let registry_agent = |id: &str, name: &str, version: &str| {
4951 let mut targets = HashMap::default();
4952 targets.insert(
4953 platform_key.clone(),
4954 RegistryTargetConfig {
4955 archive: format!("https://example.invalid/{id}-{version}.tar.gz"),
4956 cmd: "./harness".to_string(),
4957 args: vec![],
4958 sha256: None,
4959 env: HashMap::default(),
4960 },
4961 );
4962 RegistryAgent::Binary(RegistryBinaryAgent {
4963 metadata: RegistryAgentMetadata {
4964 id: AgentId(id.to_string().into()),
4965 name: name.into(),
4966 description: "A wrapped ACP harness.".into(),
4967 version: version.into(),
4968 repository: None,
4969 website: None,
4970 icon_path: None,
4971 },
4972 targets,
4973 supports_current_platform: true,
4974 })
4975 };
4976
4977 cx.update(|cx| {
4978 AgentRegistryStore::init_test_global(
4979 cx,
4980 vec![
4981 registry_agent(HEALTHY, "Visual Harness", "1.0.0"),
4982 // The registry now offers 1.1.0; the pin below freezes 1.0.0,
4983 // so this row is the refusal an owner has to be able to read.
4984 registry_agent(PINNED, "Pinned Harness", "1.1.0"),
4985 ],
4986 );
4987 });
4988
4989 cx.update(|cx| {
4990 SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
4991 store.update_user_settings(cx, |content| {
4992 let agent_servers = content.agent_servers.get_or_insert_default();
4993 for id in [HEALTHY, PINNED] {
4994 agent_servers.insert(
4995 id.to_string(),
4996 settings::CustomAgentServerSettings::Registry {
4997 default_mode: None,
4998 env: Default::default(),
4999 default_config_options: Default::default(),
5000 favorite_config_option_values: Default::default(),
5001 },
5002 );
5003 }
5004 });
5005 });
5006 });
5007
5008 // `OpenSettingsAt` reuses an open settings window, and an earlier test's
5009 // window is bound to an earlier test's project — which has no external
5010 // agents. Close them so the window this test opens is the one it set up.
5011 let existing: Vec<gpui::AnyWindowHandle> = cx
5012 .update(|cx| cx.windows())
5013 .into_iter()
5014 .filter(|window| window.downcast::<SettingsWindow>().is_some())
5015 .collect();
5016 for window in existing {
5017 cx.update_window(window, |_, window, _cx| {
5018 window.remove_window();
5019 })
5020 .log_err();
5021 }
5022 cx.run_until_parked();
5023
5024 let window_size = size(px(900.0), px(700.0));
5025 let bounds = Bounds {
5026 origin: point(px(0.0), px(0.0)),
5027 size: window_size,
5028 };
5029
5030 let project = cx.update(|cx| {
5031 project::Project::local(
5032 app_state.client.clone(),
5033 app_state.node_runtime.clone(),
5034 app_state.user_store.clone(),
5035 app_state.languages.clone(),
5036 app_state.fs.clone(),
5037 None,
5038 project::LocalProjectFlags {
5039 init_worktree_trust: false,
5040 ..Default::default()
5041 },
5042 cx,
5043 )
5044 });
5045
5046 cx.run_until_parked();
5047
5048 // The store owns the derivation of the measured tree, so the test asks it
5049 // rather than re-deriving one. A test that guessed the directory would
5050 // prove a row about a tree the launch path never looks at.
5051 let targets: Vec<(String, PathBuf, String)> = cx.update(|cx| {
5052 let store = project.read(cx).agent_server_store().clone();
5053 let store = store.read(cx);
5054 [HEALTHY, PINNED]
5055 .into_iter()
5056 .filter_map(|id| {
5057 let target = store.maintenance_target(&AgentId(id.to_string().into()))?;
5058 Some((
5059 id.to_string(),
5060 target.installed_dir?,
5061 target.version.to_string(),
5062 ))
5063 })
5064 .collect()
5065 });
5066 anyhow::ensure!(
5067 targets.len() == 2,
5068 "the store did not offer a maintenance target for both registry agents"
5069 );
5070
5071 for (_, dir, version) in &targets {
5072 std::fs::create_dir_all(dir)?;
5073 std::fs::write(dir.join("harness"), format!("harness {version}"))?;
5074 std::fs::write(dir.join("LICENSE"), b"Apache-2.0")?;
5075 }
5076
5077 // Attest the healthy one and freeze the other, through the production
5078 // functions the settings buttons call.
5079 let fs = app_state.fs.clone();
5080 let healthy = targets[0].clone();
5081 let pinned = targets[1].clone();
5082 // Driven on the test executor rather than by blocking this thread: the
5083 // filesystem work these functions do is scheduled by that executor, so
5084 // blocking the thread it runs on deadlocks instead of waiting.
5085 // `RealFs` does its work on real IO threads, so the deterministic scheduler
5086 // has to be allowed to park while it waits for them. This test runs last.
5087 cx.executor().allow_parking();
5088
5089 let outcome: Arc<std::sync::Mutex<Option<Result<()>>>> = Arc::new(std::sync::Mutex::new(None));
5090 let setup = cx.update(|cx| {
5091 let outcome = outcome.clone();
5092 cx.background_spawn(async move {
5093 let result = async {
5094 project::harness_maintenance::reprobe_installed_harness(
5095 fs.clone(),
5096 &healthy.0,
5097 &healthy.2,
5098 &healthy.1,
5099 1_784_894_400_000,
5100 )
5101 .await?;
5102 project::harness_maintenance::pin_installed_harness(
5103 fs.clone(),
5104 &pinned.0,
5105 // Frozen at 1.0.0 while the registry advertises 1.1.0.
5106 "1.0.0",
5107 &pinned.1,
5108 1_784_894_400_001,
5109 )
5110 .await?;
5111 anyhow::Ok(())
5112 }
5113 .await;
5114 *outcome.lock().unwrap() = Some(result);
5115 })
5116 });
5117 for _ in 0..200 {
5118 cx.run_until_parked();
5119 if outcome.lock().unwrap().is_some() {
5120 break;
5121 }
5122 std::thread::sleep(Duration::from_millis(20));
5123 }
5124 setup.detach();
5125 match outcome.lock().unwrap().take() {
5126 Some(Ok(())) => {}
5127 Some(Err(error)) => return Err(error).context("preparing harness maintenance state"),
5128 None => anyhow::bail!("harness maintenance setup did not finish"),
5129 }
5130
5131 let workspace_window: WindowHandle<MultiWorkspace> = cx
5132 .update(|cx| {
5133 cx.open_window(
5134 WindowOptions {
5135 window_bounds: Some(WindowBounds::Windowed(bounds)),
5136 focus: false,
5137 show: false,
5138 ..Default::default()
5139 },
5140 |window, cx| {
5141 let workspace = cx.new(|cx| {
5142 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
5143 });
5144 cx.new(|cx| MultiWorkspace::new(workspace, window, cx))
5145 },
5146 )
5147 })
5148 .context("Failed to open workspace window for external agents test")?;
5149
5150 cx.run_until_parked();
5151
5152 workspace_window
5153 .update(cx, |_workspace, window, cx| {
5154 window.dispatch_action(
5155 Box::new(OpenSettingsAt {
5156 path: "agent_servers".to_string(),
5157 target: None,
5158 }),
5159 cx,
5160 );
5161 })
5162 .context("Failed to dispatch OpenSettingsAt for external agents")?;
5163
5164 cx.run_until_parked();
5165 for _ in 0..10 {
5166 cx.advance_clock(Duration::from_millis(50));
5167 cx.run_until_parked();
5168 }
5169
5170 // Earlier tests leave their own settings windows open, so "the newest
5171 // window" is not reliably this test's. Take the newest one that is a
5172 // settings window.
5173 let all_windows = cx.update(|cx| cx.windows());
5174 let (settings_window, settings_window_handle) = all_windows
5175 .iter()
5176 .rev()
5177 .find_map(|window| Some((*window, window.downcast::<SettingsWindow>()?)))
5178 .context("No settings window found")?;
5179
5180 settings_window_handle
5181 .update(cx, |settings_window, window, cx| {
5182 settings_window.navigate_to_sub_page("agent_servers", window, cx);
5183 settings_window.refresh_harness_maintenance_for_test(cx);
5184 })
5185 .context("Failed to navigate to the External Agents sub-page")?;
5186
5187 cx.run_until_parked();
5188 for _ in 0..20 {
5189 cx.advance_clock(Duration::from_millis(50));
5190 cx.run_until_parked();
5191 }
5192
5193 let result = run_visual_test(
5194 "external_agent_harness_maintenance",
5195 settings_window,
5196 cx,
5197 update_baseline,
5198 )?;
5199
5200 cx.update_window(settings_window, |_, window, _cx| {
5201 window.remove_window();
5202 })
5203 .log_err();
5204 cx.update_window(workspace_window.into(), |_, window, _cx| {
5205 window.remove_window();
5206 })
5207 .log_err();
5208 cx.run_until_parked();
5209
5210 Ok(result)
5211}
5212