117a3f44
Add end-to-end test coverage
a73x 2026-04-03 17:17
diff --git a/tests/cli_test.rs b/tests/cli_test.rs index 1ca1b58..8014bfb 100644 --- a/tests/cli_test.rs +++ b/tests/cli_test.rs @@ -618,6 +618,43 @@ fn test_init_no_remotes() { } // =========================================================================== // Top-level status/log commands // =========================================================================== #[test] fn test_status_command_reports_summary_and_recent_items() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.issue_open("Status bug"); repo.patch_create("Status patch"); let out = repo.run_ok(&["status"]); assert!(out.contains("Issues: 1 open, 0 closed"), "unexpected status output: {}", out); assert!(out.contains("Patches: 1 open, 0 merged, 0 closed"), "unexpected status output: {}", out); assert!(out.contains("Recently updated:"), "unexpected status output: {}", out); assert!(out.contains("[issue]") && out.contains("Status bug"), "unexpected status output: {}", out); assert!(out.contains("[patch]") && out.contains("Status patch"), "unexpected status output: {}", out); } #[test] fn test_log_command_shows_recent_collab_events_and_limit() { let repo = TestRepo::new("Alice", "alice@example.com"); let issue_id = repo.issue_open("Logged issue"); repo.run_ok(&["issue", "comment", &issue_id, "-b", "Logged comment"]); repo.patch_create("Logged patch"); let out = repo.run_ok(&["log"]); assert!(out.contains("IssueOpen issue"), "unexpected log output: {}", out); assert!(out.contains("IssueComment issue"), "unexpected log output: {}", out); assert!(out.contains("PatchCreate patch"), "unexpected log output: {}", out); assert!(out.contains("open \"Logged issue\""), "unexpected log output: {}", out); assert!(out.contains("Logged comment"), "unexpected log output: {}", out); assert!(out.contains("create \"Logged patch\""), "unexpected log output: {}", out); let limited = repo.run_ok(&["log", "-n", "2"]); assert_eq!(limited.lines().count(), 2, "expected exactly 2 log lines, got: {}", limited); } // =========================================================================== // Full scenario tests // =========================================================================== @@ -881,3 +918,41 @@ fn test_patch_delete_then_show_errors() { repo.run_ok(&["patch", "delete", &id]); repo.run_err(&["patch", "show", &id]); } // =========================================================================== // Dashboard smoke // =========================================================================== #[test] fn test_dashboard_smoke_launches_and_quits_cleanly() { let repo = TestRepo::new("Alice", "alice@example.com"); let output = repo.run_dashboard_smoke("q"); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( output.status.success(), "dashboard should exit cleanly\nstdout: {}\nstderr: {}", stdout, stderr ); } #[test] fn test_dashboard_smoke_with_seeded_collab_data_exits_cleanly() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.issue_open("Dashboard issue"); repo.patch_create("Dashboard patch"); let output = repo.run_dashboard_smoke("q"); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( output.status.success(), "dashboard with seeded data should exit cleanly\nstdout: {}\nstderr: {}", stdout, stderr ); } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index d8b2da7..14be07c 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,7 +1,14 @@ #![allow(dead_code)] use std::path::Path; use std::process::{Command, Output}; use std::env; use std::ffi::OsString; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Output, Stdio}; use std::sync::{Mutex, MutexGuard, OnceLock}; use std::thread; use std::time::{Duration, Instant}; use ed25519_dalek::SigningKey; use git2::Repository; @@ -45,6 +52,127 @@ pub fn setup_signing_key(config_dir: &Path) { git_collab::signing::generate_keypair(config_dir).expect("generate test keypair"); } fn test_env_lock() -> &'static Mutex<()> { static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } fn set_or_remove_env(key: &str, value: Option<OsString>) { match value { Some(value) => env::set_var(key, value), None => env::remove_var(key), } } pub struct TestHome { root: TempDir, home_dir: PathBuf, xdg_config_home: PathBuf, git_config_global: PathBuf, } impl TestHome { pub fn new() -> Self { let root = TempDir::new().unwrap(); let home_dir = root.path().join("home"); let xdg_config_home = root.path().join("xdg-config"); std::fs::create_dir_all(&home_dir).unwrap(); std::fs::create_dir_all(&xdg_config_home).unwrap(); let git_config_global = root.path().join("gitconfig"); std::fs::write(&git_config_global, "").unwrap(); Self { root, home_dir, xdg_config_home, git_config_global, } } pub fn home_dir(&self) -> &Path { &self.home_dir } pub fn config_home(&self) -> &Path { &self.xdg_config_home } pub fn collab_config_dir(&self) -> PathBuf { self.xdg_config_home.join("git-collab") } pub fn ensure_signing_key(&self) { let config_dir = self.collab_config_dir(); if !config_dir.join("signing-key").exists() { setup_signing_key(&config_dir); } } pub fn apply_to_command<'a>(&self, cmd: &'a mut Command) -> &'a mut Command { let _ = &self.root; cmd.env("HOME", &self.home_dir) .env("XDG_CONFIG_HOME", &self.xdg_config_home) .env("GIT_CONFIG_GLOBAL", &self.git_config_global) .env("GIT_CONFIG_NOSYSTEM", "1") } fn apply_to_process(&self) { let _ = &self.root; env::set_var("HOME", &self.home_dir); env::set_var("XDG_CONFIG_HOME", &self.xdg_config_home); env::set_var("GIT_CONFIG_GLOBAL", &self.git_config_global); env::set_var("GIT_CONFIG_NOSYSTEM", "1"); } } pub struct ScopedTestConfig { _lock: MutexGuard<'static, ()>, env: TestHome, old_home: Option<OsString>, old_xdg_config_home: Option<OsString>, old_git_config_global: Option<OsString>, old_git_config_nosystem: Option<OsString>, } impl ScopedTestConfig { pub fn new() -> Self { let lock = test_env_lock().lock().unwrap(); let test_home = TestHome::new(); let old_home = env::var_os("HOME"); let old_xdg_config_home = env::var_os("XDG_CONFIG_HOME"); let old_git_config_global = env::var_os("GIT_CONFIG_GLOBAL"); let old_git_config_nosystem = env::var_os("GIT_CONFIG_NOSYSTEM"); test_home.apply_to_process(); Self { _lock: lock, env: test_home, old_home, old_xdg_config_home, old_git_config_global, old_git_config_nosystem, } } pub fn config_dir(&self) -> PathBuf { self.env.collab_config_dir() } pub fn ensure_signing_key(&self) { self.env.ensure_signing_key(); } } impl Drop for ScopedTestConfig { fn drop(&mut self) { set_or_remove_env("HOME", self.old_home.take()); set_or_remove_env("XDG_CONFIG_HOME", self.old_xdg_config_home.take()); set_or_remove_env("GIT_CONFIG_GLOBAL", self.old_git_config_global.take()); set_or_remove_env("GIT_CONFIG_NOSYSTEM", self.old_git_config_nosystem.take()); } } /// Create a non-bare repo in a directory with user identity configured /// and an initial empty commit on `main`. pub fn init_repo(dir: &Path, author: &Author) -> Repository { @@ -175,37 +303,47 @@ pub fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: R /// A temporary git repository for end-to-end CLI testing. pub struct TestRepo { pub dir: TempDir, env: TestHome, } impl TestRepo { /// Create a new repo with user identity and an initial empty commit on `main`. /// Also ensures a signing key exists in the default config dir. /// Also ensures a signing key exists in an isolated temp config dir. pub fn new(name: &str, email: &str) -> Self { let dir = TempDir::new().unwrap(); git(dir.path(), &["init", "-b", "main"]); git(dir.path(), &["config", "user.name", name]); git(dir.path(), &["config", "user.email", email]); git(dir.path(), &["commit", "--allow-empty", "-m", "initial"]); // Ensure signing key exists for CLI operations let config_dir = dirs::config_dir() .unwrap_or_else(|| { let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); std::path::PathBuf::from(home).join(".config") }) .join("git-collab"); if !config_dir.join("signing-key").exists() { setup_signing_key(&config_dir); } let env = TestHome::new(); git_with_env(dir.path(), &["init", "-b", "main"], &env); git_with_env(dir.path(), &["config", "user.name", name], &env); git_with_env(dir.path(), &["config", "user.email", email], &env); git_with_env(dir.path(), &["commit", "--allow-empty", "-m", "initial"], &env); env.ensure_signing_key(); TestRepo { dir, env } } TestRepo { dir } pub fn home_dir(&self) -> &Path { self.env.home_dir() } pub fn config_dir(&self) -> PathBuf { self.env.collab_config_dir() } pub fn apply_env<'a>(&self, cmd: &'a mut Command) -> &'a mut Command { self.env.apply_to_command(cmd) } pub fn cli_command(&self) -> Command { let mut cmd = Command::new(env!("CARGO_BIN_EXE_git-collab")); self.apply_env(&mut cmd); cmd.current_dir(self.dir.path()); cmd } /// Run git-collab and return raw output. pub fn run(&self, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_git-collab")) self.cli_command() .args(args) .current_dir(self.dir.path()) .output() .expect("failed to run git-collab") } @@ -238,6 +376,40 @@ impl TestRepo { String::from_utf8(output.stderr).unwrap() } /// Run `git-collab dashboard` in a pseudo-terminal, feed input, and return raw output. pub fn run_dashboard_smoke(&self, input: &str) -> Output { let mut command = Command::new("script"); self.apply_env(&mut command); let mut child = command .args([ "-qec", &format!("{} dashboard", env!("CARGO_BIN_EXE_git-collab")), "/dev/null", ]) .current_dir(self.dir.path()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("failed to launch dashboard in pty"); if let Some(mut stdin) = child.stdin.take() { stdin.write_all(input.as_bytes()).unwrap(); } let deadline = Instant::now() + Duration::from_secs(5); loop { if let Some(_status) = child.try_wait().expect("failed to poll dashboard process") { return child.wait_with_output().expect("failed to collect dashboard output"); } if Instant::now() >= deadline { let _ = child.kill(); return child.wait_with_output().expect("failed to collect timed out dashboard output"); } thread::sleep(Duration::from_millis(20)); } } /// Open an issue and return the 8-char short ID. pub fn issue_open(&self, title: &str) -> String { let out = self.run_ok(&["issue", "open", "-t", title]); @@ -269,7 +441,9 @@ impl TestRepo { /// Run a git command in this repo and return stdout. pub fn git(&self, args: &[&str]) -> String { let output = Command::new("git") let mut command = Command::new("git"); self.apply_env(&mut command); let output = command .args(args) .current_dir(self.dir.path()) .output() @@ -296,6 +470,158 @@ impl TestRepo { } } pub struct HttpResponse { pub status_line: String, pub body: String, } pub struct ServerHarness { root: TempDir, repo_name: String, work_repo: TestRepo, server: Child, http_addr: SocketAddr, } impl ServerHarness { pub fn new(repo_name: &str) -> Self { let root = TempDir::new().unwrap(); let repos_dir = root.path().join("repos"); std::fs::create_dir_all(&repos_dir).unwrap(); let bare_repo_dir = repos_dir.join(format!("{repo_name}.git")); git_cmd(root.path(), &["init", "--bare", bare_repo_dir.to_str().unwrap()]); let work_repo = TestRepo::new("Alice", "alice@example.com"); work_repo.git(&["remote", "add", "origin", bare_repo_dir.to_str().unwrap()]); let authorized_keys = root.path().join("authorized_keys"); std::fs::write(&authorized_keys, "").unwrap(); let http_addr = pick_loopback_addr(); let ssh_addr = pick_loopback_addr(); let config_path = root.path().join("server.toml"); std::fs::write( &config_path, format!( "repos_dir = {:?}\nhttp_bind = \"{}\"\nssh_bind = \"{}\"\nauthorized_keys = {:?}\nsite_title = \"git-collab test\"\n", repos_dir, http_addr, ssh_addr, authorized_keys, ), ) .unwrap(); let server = Command::new(env!("CARGO_BIN_EXE_git-collab-server")) .args(["--config", config_path.to_str().unwrap()]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("failed to start git-collab-server"); let mut harness = Self { root, repo_name: repo_name.to_string(), work_repo, server, http_addr, }; harness.wait_until_ready(); harness } pub fn repo_name(&self) -> &str { &self.repo_name } pub fn work_repo(&self) -> &TestRepo { &self.work_repo } pub fn push_head(&self) { self.work_repo.git(&["push", "-u", "origin", "main"]); } pub fn push_collab_refs(&self) { self.work_repo .git(&["push", "origin", "refs/collab/*:refs/collab/*"]); } pub fn get_ok(&self, path: &str) -> HttpResponse { let response = self.get(path); assert!( response.status_line.contains("200"), "expected 200 for {path}, got {}\nbody:\n{}", response.status_line, response.body ); response } pub fn get(&self, path: &str) -> HttpResponse { let mut stream = TcpStream::connect(self.http_addr) .unwrap_or_else(|e| panic!("failed to connect to http server on {}: {}", self.http_addr, e)); stream .write_all( format!( "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", path, self.http_addr ) .as_bytes(), ) .unwrap(); let mut raw = Vec::new(); stream.read_to_end(&mut raw).unwrap(); let raw = String::from_utf8(raw).unwrap(); let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((&raw, "")); let status_line = head.lines().next().unwrap_or("").to_string(); HttpResponse { status_line, body: body.to_string(), } } fn wait_until_ready(&mut self) { let deadline = Instant::now() + Duration::from_secs(10); loop { if let Some(status) = self.server_status() { panic!("git-collab-server exited before becoming ready: {status}"); } if let Ok(response) = TcpStream::connect(self.http_addr) { drop(response); let response = self.get("/"); if !response.status_line.is_empty() { return; } } if Instant::now() >= deadline { panic!("timed out waiting for git-collab-server on {}", self.http_addr); } thread::sleep(Duration::from_millis(50)); } } fn server_status(&mut self) -> Option<String> { self.server .try_wait() .ok() .flatten() .map(|status| format!("exit status {:?}", status.code())) } } impl Drop for ServerHarness { fn drop(&mut self) { let _ = self.server.kill(); let _ = self.server.wait(); let _ = &self.root; } } /// Create an unsigned event commit (plain Event JSON, no signature/pubkey blobs). /// Returns the commit OID. pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid { @@ -350,6 +676,10 @@ pub fn git_cmd(dir: &Path, args: &[&str]) { git(dir, args); } pub fn git_cmd_with_env(dir: &Path, args: &[&str], env: &TestHome) { git_with_env(dir, args, env); } fn git(dir: &Path, args: &[&str]) { let output = Command::new("git") .args(args) @@ -363,3 +693,26 @@ fn git(dir: &Path, args: &[&str]) { String::from_utf8_lossy(&output.stderr) ); } fn git_with_env(dir: &Path, args: &[&str], test_home: &TestHome) { let mut command = Command::new("git"); test_home.apply_to_command(&mut command); let output = command .args(args) .current_dir(dir) .output() .expect("failed to run git"); assert!( output.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&output.stderr) ); } fn pick_loopback_addr() -> SocketAddr { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); drop(listener); addr } diff --git a/tests/server_behavior_test.rs b/tests/server_behavior_test.rs new file mode 100644 index 0000000..6254074 --- /dev/null +++ b/tests/server_behavior_test.rs @@ -0,0 +1,102 @@ mod common; use common::ServerHarness; #[test] fn issue_created_locally_and_pushed_renders_in_server_ui() { let harness = ServerHarness::new("behavior-issues"); harness.push_head(); let issue_id = harness.work_repo().issue_open("My first bug"); harness.push_collab_refs(); let list_page = harness.get_ok(&format!("/{}/issues", harness.repo_name())); assert!(list_page.body.contains("My first bug")); assert!(list_page.body.contains("open")); let detail_page = harness.get_ok(&format!("/{}/issues/{}", harness.repo_name(), issue_id)); assert!(detail_page.body.contains("My first bug")); assert!(detail_page.body.contains("Alice")); assert!(detail_page.body.contains("open")); } #[test] fn pushed_repository_content_renders_across_repo_http_pages() { let harness = ServerHarness::new("behavior-pages"); let commit_oid = harness.work_repo().commit_file( "src/lib.rs", "pub fn answer() -> u32 {\n 42\n}\n", "add source file", ); let issue_id = harness.work_repo().issue_open("Server issue"); let patch_id = harness.work_repo().patch_create("Server patch"); harness.push_head(); harness.push_collab_refs(); let repo_list = harness.get_ok("/"); assert!(repo_list.body.contains(harness.repo_name())); let overview = harness.get_ok(&format!("/{}", harness.repo_name())); assert!(overview.body.contains("Server issue")); assert!(overview.body.contains("Server patch")); assert!(overview.body.contains("add source file")); let commits = harness.get_ok(&format!("/{}/commits", harness.repo_name())); assert!(commits.body.contains("add source file")); assert!(commits.body.contains("main")); let diff = harness.get_ok(&format!("/{}/diff/{}", harness.repo_name(), commit_oid)); assert!(diff.body.contains("add source file")); assert!(diff.body.contains("src/lib.rs")); assert!(diff.body.contains("answer")); let tree = harness.get_ok(&format!("/{}/tree", harness.repo_name())); assert!(tree.body.contains("src/")); let blob = harness.get_ok(&format!("/{}/blob/main/src/lib.rs", harness.repo_name())); assert!(blob.body.contains("src/lib.rs")); assert!(blob.body.contains("answer")); let patches = harness.get_ok(&format!("/{}/patches", harness.repo_name())); assert!(patches.body.contains("Server patch")); assert!(patches.body.contains("open")); let patch_detail = harness.get_ok(&format!("/{}/patches/{}", harness.repo_name(), patch_id)); assert!(patch_detail.body.contains("Server patch")); assert!(patch_detail.body.contains("Alice")); assert!(patch_detail.body.contains("main")); let issues = harness.get_ok(&format!("/{}/issues", harness.repo_name())); assert!(issues.body.contains("Server issue")); assert!(issues.body.contains("open")); let issue_detail = harness.get_ok(&format!("/{}/issues/{}", harness.repo_name(), issue_id)); assert!(issue_detail.body.contains("Server issue")); assert!(issue_detail.body.contains("Alice")); assert!(issue_detail.body.contains("open")); } #[test] fn missing_repository_and_missing_objects_return_not_found() { let harness = ServerHarness::new("behavior-not-found"); harness.push_head(); let missing_repo = harness.get("/missing-repo"); assert!(missing_repo.status_line.contains("404")); assert!(missing_repo.body.contains("missing-repo")); assert!(missing_repo.body.contains("not found")); let missing_issue = harness.get(&format!("/{}/issues/missing-issue", harness.repo_name())); assert!(missing_issue.status_line.contains("404")); assert!(missing_issue.body.contains("missing-issue")); assert!(missing_issue.body.contains("not found")); let missing_blob = harness.get(&format!("/{}/blob/main/src/missing.rs", harness.repo_name())); assert!(missing_blob.status_line.contains("404")); assert!(missing_blob.body.contains("src/missing.rs")); assert!(missing_blob.body.contains("not found")); } diff --git a/tests/sync_test.rs b/tests/sync_test.rs index 9ac8e59..070a63a 100644 --- a/tests/sync_test.rs +++ b/tests/sync_test.rs @@ -7,11 +7,12 @@ mod common; use std::process::{Command, Output}; use tempfile::TempDir; use git2::Repository; use git_collab::dag; use git_collab::error; use git_collab::event::{Action, Event, ReviewVerdict}; use git_collab::signing; use git_collab::state::{self, IssueState, IssueStatus, PatchState}; @@ -19,7 +20,7 @@ use git_collab::sync; use common::{ add_comment, alice, bob, close_issue, create_tampered_event, create_unsigned_event, now, open_issue, setup_signing_key, test_signing_key, open_issue, test_signing_key, ScopedTestConfig, }; // --------------------------------------------------------------------------- @@ -30,11 +31,21 @@ struct TestCluster { bare_dir: TempDir, alice_dir: TempDir, bob_dir: TempDir, _key_setup: (), // signing key created in default config dir _config: ScopedTestConfig, } impl TestCluster { fn new() -> Self { let cluster = Self::new_without_collab_init(); sync::init(&cluster.alice_repo()).unwrap(); sync::init(&cluster.bob_repo()).unwrap(); cluster } fn new_without_collab_init() -> Self { let config = ScopedTestConfig::new(); config.ensure_signing_key(); let bare_dir = TempDir::new().unwrap(); let bare_repo = Repository::init_bare(bare_dir.path()).unwrap(); @@ -58,7 +69,6 @@ impl TestCluster { config.set_str("user.name", "Alice").unwrap(); config.set_str("user.email", "alice@example.com").unwrap(); } sync::init(&alice_repo).unwrap(); let bob_repo = Repository::clone(bare_dir.path().to_str().unwrap(), bob_dir.path()).unwrap(); @@ -67,24 +77,12 @@ impl TestCluster { config.set_str("user.name", "Bob").unwrap(); config.set_str("user.email", "bob@example.com").unwrap(); } sync::init(&bob_repo).unwrap(); // Ensure signing key exists for sync reconciliation let config_dir = dirs::config_dir() .unwrap_or_else(|| { let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); std::path::PathBuf::from(home).join(".config") }) .join("git-collab"); if !config_dir.join("signing-key").exists() { setup_signing_key(&config_dir); } TestCluster { bare_dir, alice_dir, bob_dir, _key_setup: (), _config: config, } } @@ -96,10 +94,51 @@ impl TestCluster { Repository::open(self.bob_dir.path()).unwrap() } fn run_collab(&self, repo_dir: &std::path::Path, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_git-collab")) .args(args) .current_dir(repo_dir) .output() .expect("failed to run git-collab") } fn run_collab_ok(&self, repo_dir: &std::path::Path, args: &[&str]) -> String { let output = self.run_collab(repo_dir, args); let stdout = String::from_utf8(output.stdout).unwrap(); let stderr = String::from_utf8(output.stderr).unwrap(); assert!( output.status.success(), "git-collab {:?} failed (exit {:?}):\nstdout: {}\nstderr: {}", args, output.status.code(), stdout, stderr ); stdout } fn run_collab_err(&self, repo_dir: &std::path::Path, args: &[&str]) -> (String, String) { let output = self.run_collab(repo_dir, args); let stdout = String::from_utf8(output.stdout).unwrap(); let stderr = String::from_utf8(output.stderr).unwrap(); assert!( !output.status.success(), "expected git-collab {:?} to fail but it succeeded:\nstdout: {}\nstderr: {}", args, stdout, stderr ); (stdout, stderr) } /// Return the path to the bare remote directory. fn bare_dir(&self) -> &std::path::Path { self.bare_dir.path() } fn config_dir(&self) -> std::path::PathBuf { self._config.config_dir() } } // --------------------------------------------------------------------------- @@ -125,6 +164,94 @@ fn test_alice_creates_issue_bob_syncs_and_sees_it() { } #[test] fn test_cli_init_and_sync_transfer_issue_between_repos() { let cluster = TestCluster::new_without_collab_init(); let alice_init = cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]); assert!(alice_init.contains("Configured remote 'origin'")); assert!(alice_init.contains("Collab refspecs initialized.")); let bob_init = cluster.run_collab_ok(cluster.bob_dir.path(), &["init"]); assert!(bob_init.contains("Configured remote 'origin'")); assert!(bob_init.contains("Collab refspecs initialized.")); let issue_open = cluster.run_collab_ok( cluster.alice_dir.path(), &["issue", "open", "-t", "CLI sync issue"], ); assert!(issue_open.contains("Opened issue")); let issue_id = state::list_issues(&cluster.alice_repo()) .unwrap() .into_iter() .find(|issue| issue.title == "CLI sync issue") .expect("issue created through CLI should exist locally") .id; let alice_sync = cluster.run_collab_ok(cluster.alice_dir.path(), &["sync", "origin"]); assert!(alice_sync.contains("Fetching from 'origin'...")); assert!(alice_sync.contains("Pushing to 'origin'...")); assert!(alice_sync.contains("Sync complete.")); let bob_sync = cluster.run_collab_ok(cluster.bob_dir.path(), &["sync", "origin"]); assert!(bob_sync.contains("Fetching from 'origin'...")); assert!(bob_sync.contains("Sync complete.")); let bob_repo = cluster.bob_repo(); let bob_ref = format!("refs/collab/issues/{}", issue_id); let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &issue_id).unwrap(); assert_eq!(bob_state.title, "CLI sync issue"); assert_eq!(bob_state.author.name, "Alice"); } #[test] fn test_cli_sync_reports_missing_remote_failure() { let cluster = TestCluster::new_without_collab_init(); cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]); let (_stdout, stderr) = cluster.run_collab_err(cluster.alice_dir.path(), &["sync", "upstream"]); assert!(stderr.contains("error:")); assert!(stderr.contains("git fetch exited with status")); } #[test] fn test_cli_sync_partial_failure_can_resume_successfully() { let cluster = TestCluster::new_without_collab_init(); cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]); let alice_repo = cluster.alice_repo(); let (_ref1, id1) = open_issue(&alice_repo, &alice(), "CLI rejected issue"); let (_ref2, id2) = open_issue(&alice_repo, &alice(), "CLI accepted issue"); install_reject_hook(cluster.bare_dir(), &id1[..8]); let (_stdout, stderr) = cluster.run_collab_err(cluster.alice_dir.path(), &["sync", "origin"]); assert!(stderr.contains("Sync partially failed: 1 of 2 refs pushed.")); assert!(stderr.contains(&format!("refs/collab/issues/{}", id1))); let state = sync::SyncState::load(&cluster.alice_repo()).expect("partial sync should save state"); assert_eq!(state.pending_refs.len(), 1); assert!(state.pending_refs[0].0.contains(&id1)); let bare_repo = Repository::open_bare(cluster.bare_dir()).unwrap(); assert!( bare_repo .refname_to_id(&format!("refs/collab/issues/{}", id2)) .is_ok(), "successful ref should still reach the remote" ); remove_reject_hook(cluster.bare_dir()); let resume_output = cluster.run_collab_ok(cluster.alice_dir.path(), &["sync", "origin"]); assert!(resume_output.contains("Resuming sync to 'origin'")); assert!(resume_output.contains("Sync complete.")); assert!(sync::SyncState::load(&cluster.alice_repo()).is_none()); } #[test] fn test_bob_comments_on_alice_issue_then_sync() { let cluster = TestCluster::new(); let alice_repo = cluster.alice_repo(); @@ -599,14 +726,8 @@ fn test_reconciliation_merge_commit_is_signed() { } // Verify the merge commit specifically is signed by the syncing user's key // (the key stored in the config dir, which sync::sync() loads) let config_dir = dirs::config_dir() .unwrap_or_else(|| { let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); std::path::PathBuf::from(home).join(".config") }) .join("git-collab"); let syncing_vk = signing::load_verifying_key(&config_dir).unwrap(); // (the key stored in the test config dir, which sync::sync() loads) let syncing_vk = signing::load_verifying_key(&cluster.config_dir()).unwrap(); let syncing_pubkey = base64::Engine::encode( &base64::engine::general_purpose::STANDARD, syncing_vk.to_bytes(),