11c6117c
Add end-to-end tests for editor integration module
a73x 2026-03-21 16:19
20 tests covering the public API: resolve_editor (env var precedence, fallback, empty/unset handling), open_editor_at (success, nonzero exit, missing file, bad binary, multi-word editor, VISUAL precedence), and find_comment_at_scroll (empty, exact, above, below, boundary, unsorted, ties, large scroll). Env-mutating tests are serialised via a mutex. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/tests/editor_test.rs b/tests/editor_test.rs new file mode 100644 index 0000000..577a913 --- /dev/null +++ b/tests/editor_test.rs @@ -0,0 +1,354 @@ //! End-to-end tests for the editor integration module. //! //! These tests exercise the public API of `git_collab::editor`, including //! real process spawning (using `true`/`false` as stand-in editors) and //! the `find_comment_at_scroll` helper used by the TUI. //! //! Tests that mutate environment variables (EDITOR, VISUAL) must not run //! in parallel with each other. We serialise them via a process-wide mutex. use std::io::Write; use std::sync::Mutex; use git_collab::editor::{find_comment_at_scroll, open_editor_at, resolve_editor}; /// Guard to serialise tests that mutate EDITOR / VISUAL env vars. static ENV_LOCK: Mutex<()> = Mutex::new(()); // =========================================================================== // resolve_editor — end-to-end (reads real env vars) // =========================================================================== #[test] fn e2e_resolve_editor_returns_visual_when_set() { let _guard = ENV_LOCK.lock().unwrap(); let old_visual = std::env::var("VISUAL").ok(); let old_editor = std::env::var("EDITOR").ok(); std::env::set_var("VISUAL", "nvim"); std::env::set_var("EDITOR", "vi"); let result = resolve_editor(); assert_eq!(result, Some("nvim".to_string())); // Restore restore_env("VISUAL", old_visual); restore_env("EDITOR", old_editor); } #[test] fn e2e_resolve_editor_falls_back_to_editor_when_visual_unset() { let _guard = ENV_LOCK.lock().unwrap(); let old_visual = std::env::var("VISUAL").ok(); let old_editor = std::env::var("EDITOR").ok(); std::env::remove_var("VISUAL"); std::env::set_var("EDITOR", "nano"); let result = resolve_editor(); assert_eq!(result, Some("nano".to_string())); restore_env("VISUAL", old_visual); restore_env("EDITOR", old_editor); } #[test] fn e2e_resolve_editor_none_when_both_unset() { let _guard = ENV_LOCK.lock().unwrap(); let old_visual = std::env::var("VISUAL").ok(); let old_editor = std::env::var("EDITOR").ok(); std::env::remove_var("VISUAL"); std::env::remove_var("EDITOR"); let result = resolve_editor(); assert_eq!(result, None); restore_env("VISUAL", old_visual); restore_env("EDITOR", old_editor); } #[test] fn e2e_resolve_editor_skips_empty_visual() { let _guard = ENV_LOCK.lock().unwrap(); let old_visual = std::env::var("VISUAL").ok(); let old_editor = std::env::var("EDITOR").ok(); std::env::set_var("VISUAL", ""); std::env::set_var("EDITOR", "emacs"); let result = resolve_editor(); assert_eq!(result, Some("emacs".to_string())); restore_env("VISUAL", old_visual); restore_env("EDITOR", old_editor); } // =========================================================================== // open_editor_at — end-to-end (spawns real processes) // =========================================================================== #[test] fn e2e_open_editor_at_succeeds_with_true() { let _guard = ENV_LOCK.lock().unwrap(); let old_visual = std::env::var("VISUAL").ok(); let old_editor = std::env::var("EDITOR").ok(); // `true` exits 0 and ignores all arguments std::env::set_var("EDITOR", "true"); std::env::remove_var("VISUAL"); let mut tmp = tempfile::NamedTempFile::new().unwrap(); writeln!(tmp, "some content").unwrap(); let path = tmp.path().to_str().unwrap(); let result = open_editor_at(path, 10); assert!(result.is_ok(), "Expected Ok, got {:?}", result); restore_env("VISUAL", old_visual); restore_env("EDITOR", old_editor); } #[test] fn e2e_open_editor_at_reports_nonzero_exit() { let _guard = ENV_LOCK.lock().unwrap(); let old_visual = std::env::var("VISUAL").ok(); let old_editor = std::env::var("EDITOR").ok(); // `false` exits 1 std::env::set_var("EDITOR", "false"); std::env::remove_var("VISUAL"); let mut tmp = tempfile::NamedTempFile::new().unwrap(); writeln!(tmp, "some content").unwrap(); let path = tmp.path().to_str().unwrap(); let result = open_editor_at(path, 1); assert!(result.is_err()); let msg = format!("{}", result.unwrap_err()); assert!(msg.contains("Editor exited with status"), "got: {}", msg); restore_env("VISUAL", old_visual); restore_env("EDITOR", old_editor); } #[test] fn e2e_open_editor_at_errors_when_no_editor_configured() { let _guard = ENV_LOCK.lock().unwrap(); let old_visual = std::env::var("VISUAL").ok(); let old_editor = std::env::var("EDITOR").ok(); std::env::remove_var("VISUAL"); std::env::remove_var("EDITOR"); let mut tmp = tempfile::NamedTempFile::new().unwrap(); writeln!(tmp, "content").unwrap(); let path = tmp.path().to_str().unwrap(); let result = open_editor_at(path, 1); assert!(result.is_err()); let msg = format!("{}", result.unwrap_err()); assert!( msg.contains("No editor configured"), "Expected 'No editor configured', got: {}", msg ); restore_env("VISUAL", old_visual); restore_env("EDITOR", old_editor); } #[test] fn e2e_open_editor_at_file_not_found() { let _guard = ENV_LOCK.lock().unwrap(); let old_visual = std::env::var("VISUAL").ok(); let old_editor = std::env::var("EDITOR").ok(); std::env::set_var("EDITOR", "true"); std::env::remove_var("VISUAL"); let result = open_editor_at("/tmp/nonexistent_editor_e2e_test_file.txt", 1); assert!(result.is_err()); let msg = format!("{}", result.unwrap_err()); assert!(msg.contains("File not found"), "got: {}", msg); restore_env("VISUAL", old_visual); restore_env("EDITOR", old_editor); } #[test] fn e2e_open_editor_at_bad_binary() { let _guard = ENV_LOCK.lock().unwrap(); let old_visual = std::env::var("VISUAL").ok(); let old_editor = std::env::var("EDITOR").ok(); std::env::set_var("EDITOR", "nonexistent_binary_xyz_999"); std::env::remove_var("VISUAL"); let mut tmp = tempfile::NamedTempFile::new().unwrap(); writeln!(tmp, "content").unwrap(); let path = tmp.path().to_str().unwrap(); let result = open_editor_at(path, 1); assert!(result.is_err()); let msg = format!("{}", result.unwrap_err()); assert!( msg.contains("Failed to launch editor"), "Expected launch failure, got: {}", msg ); restore_env("VISUAL", old_visual); restore_env("EDITOR", old_editor); } #[test] fn e2e_open_editor_at_with_multi_word_editor() { let _guard = ENV_LOCK.lock().unwrap(); let old_visual = std::env::var("VISUAL").ok(); let old_editor = std::env::var("EDITOR").ok(); // Simulate `code --wait` style editor; `true` ignores extra args std::env::set_var("EDITOR", "true --wait"); std::env::remove_var("VISUAL"); let mut tmp = tempfile::NamedTempFile::new().unwrap(); writeln!(tmp, "content").unwrap(); let path = tmp.path().to_str().unwrap(); let result = open_editor_at(path, 42); assert!(result.is_ok(), "Expected Ok with multi-word editor, got {:?}", result); restore_env("VISUAL", old_visual); restore_env("EDITOR", old_editor); } #[test] fn e2e_open_editor_at_visual_takes_precedence_over_editor() { let _guard = ENV_LOCK.lock().unwrap(); let old_visual = std::env::var("VISUAL").ok(); let old_editor = std::env::var("EDITOR").ok(); // VISUAL=true should be used instead of EDITOR=false // If EDITOR were used, the test would fail because `false` exits 1 std::env::set_var("VISUAL", "true"); std::env::set_var("EDITOR", "false"); let mut tmp = tempfile::NamedTempFile::new().unwrap(); writeln!(tmp, "content").unwrap(); let path = tmp.path().to_str().unwrap(); let result = open_editor_at(path, 1); assert!( result.is_ok(), "VISUAL=true should take precedence over EDITOR=false, got {:?}", result ); restore_env("VISUAL", old_visual); restore_env("EDITOR", old_editor); } // =========================================================================== // find_comment_at_scroll — realistic scenarios // =========================================================================== #[test] fn e2e_find_comment_at_scroll_empty_list() { let comments: Vec<(String, u32, usize)> = vec![]; assert_eq!(find_comment_at_scroll(&comments, 50), None); } #[test] fn e2e_find_comment_at_scroll_single_exact() { let comments = vec![("src/main.rs".to_string(), 10, 20)]; let result = find_comment_at_scroll(&comments, 20); assert_eq!(result, Some(("src/main.rs".to_string(), 10))); } #[test] fn e2e_find_comment_at_scroll_single_above() { let comments = vec![("src/lib.rs".to_string(), 5, 10)]; let result = find_comment_at_scroll(&comments, 50); assert_eq!(result, Some(("src/lib.rs".to_string(), 5))); } #[test] fn e2e_find_comment_at_scroll_single_below() { let comments = vec![("src/lib.rs".to_string(), 5, 100)]; let result = find_comment_at_scroll(&comments, 50); assert_eq!(result, None); } #[test] fn e2e_find_comment_at_scroll_picks_closest_above_from_many() { // Simulate a diff with inline comments scattered across files let comments = vec![ ("src/editor.rs".to_string(), 12, 5), ("src/editor.rs".to_string(), 30, 15), ("src/tui.rs".to_string(), 100, 40), ("src/tui.rs".to_string(), 150, 60), ("src/main.rs".to_string(), 1, 80), ]; // Scroll at 45 — closest at-or-above is rendered_pos=40 (src/tui.rs:100) let result = find_comment_at_scroll(&comments, 45); assert_eq!(result, Some(("src/tui.rs".to_string(), 100))); } #[test] fn e2e_find_comment_at_scroll_at_boundary_zero() { let comments = vec![ ("header.rs".to_string(), 1, 0), ("body.rs".to_string(), 10, 20), ]; let result = find_comment_at_scroll(&comments, 0); assert_eq!(result, Some(("header.rs".to_string(), 1))); } #[test] fn e2e_find_comment_at_scroll_all_at_same_position() { // Multiple comments at the same rendered position — first wins let comments = vec![ ("a.rs".to_string(), 1, 10), ("b.rs".to_string(), 2, 10), ("c.rs".to_string(), 3, 10), ]; let result = find_comment_at_scroll(&comments, 10); assert_eq!(result, Some(("a.rs".to_string(), 1))); } #[test] fn e2e_find_comment_at_scroll_large_scroll_position() { let comments = vec![ ("early.rs".to_string(), 1, 10), ("late.rs".to_string(), 999, 500), ]; // Scroll way past all comments — should return the last one let result = find_comment_at_scroll(&comments, u16::MAX); assert_eq!(result, Some(("late.rs".to_string(), 999))); } #[test] fn e2e_find_comment_at_scroll_unsorted_input() { // Comments may not arrive sorted by rendered position let comments = vec![ ("c.rs".to_string(), 30, 50), ("a.rs".to_string(), 10, 5), ("b.rs".to_string(), 20, 25), ]; // Scroll at 30 — closest at-or-above is rendered_pos=25 let result = find_comment_at_scroll(&comments, 30); assert_eq!(result, Some(("b.rs".to_string(), 20))); } // =========================================================================== // Helpers // =========================================================================== fn restore_env(var: &str, old: Option<String>) { match old { Some(val) => std::env::set_var(var, val), None => std::env::remove_var(var), } }